From 3308e964ce178b35d738b25a2780fbb961a32ca3 Mon Sep 17 00:00:00 2001 From: Daishan Peng Date: Mon, 21 Oct 2024 16:16:57 -0700 Subject: [PATCH] Knowledge (#241) * Knowledge UX revamp Signed-off-by: Daishan Peng --------- Signed-off-by: Daishan Peng --- apiclient/types/knowledge.go | 2 + apiclient/types/remoteKnowledgeSource.go | 31 +- apiclient/types/zz_generated.deepcopy.go | 61 +- pkg/api/handlers/agent.go | 23 + pkg/api/handlers/files.go | 2 + pkg/api/handlers/remoteknowledgesource.go | 5 +- pkg/api/router/router.go | 1 + .../handlers/knowledge/knowledge.go | 93 ++- .../handlers/uploads/remoteknowledgesource.go | 13 +- pkg/knowledge/knowledge.go | 13 + pkg/storage/apis/otto.gptscript.ai/v1/file.go | 1 + .../v1/zz_generated.deepcopy.go | 7 +- .../openapi/generated/openapi_generated.go | 152 ++-- pkg/system/tools.go | 7 +- .../app/components/knowledge/AddFileModal.tsx | 157 ----- .../knowledge/AgentKnowledgePanel.tsx | 654 +++++++----------- .../app/components/knowledge/FileItem.tsx | 45 +- .../components/knowledge/FileStatusIcon.tsx | 61 ++ .../components/knowledge/IngestionStatus.tsx | 113 +++ .../components/knowledge/RemoteFileAvatar.tsx | 10 +- .../knowledge/RemoteFileItemChip.tsx | 100 +-- .../knowledge/RemoteKnowledgeSourceStatus.tsx | 21 +- .../knowledge/RemoteSourceSettingModal.tsx | 110 +++ .../components/knowledge/file/FileModal.tsx | 177 +++++ .../knowledge/notion/NotionModal.tsx | 270 ++++---- .../knowledge/onedrive/AddLinkModal.tsx | 87 +++ .../knowledge/onedrive/OneDriveModal.tsx | 510 ++++++++------ .../knowledge/website/AddWebsiteModal.tsx | 78 +++ .../knowledge/website/WebsiteModal.tsx | 436 ++++++------ ui/admin/app/components/ui/checkbox.tsx | 28 + ui/admin/app/lib/model/knowledge.ts | 28 +- ui/admin/app/lib/routers/apiRoutes.ts | 2 + .../app/lib/service/api/knowledgeService.ts | 14 + ui/admin/package-lock.json | 30 + ui/admin/package.json | 1 + 35 files changed, 2042 insertions(+), 1301 deletions(-) delete mode 100644 ui/admin/app/components/knowledge/AddFileModal.tsx create mode 100644 ui/admin/app/components/knowledge/FileStatusIcon.tsx create mode 100644 ui/admin/app/components/knowledge/IngestionStatus.tsx create mode 100644 ui/admin/app/components/knowledge/RemoteSourceSettingModal.tsx create mode 100644 ui/admin/app/components/knowledge/file/FileModal.tsx create mode 100644 ui/admin/app/components/knowledge/onedrive/AddLinkModal.tsx create mode 100644 ui/admin/app/components/knowledge/website/AddWebsiteModal.tsx create mode 100644 ui/admin/app/components/ui/checkbox.tsx diff --git a/apiclient/types/knowledge.go b/apiclient/types/knowledge.go index 562f22d72..6bb506233 100644 --- a/apiclient/types/knowledge.go +++ b/apiclient/types/knowledge.go @@ -11,6 +11,7 @@ type KnowledgeFile struct { IngestionStatus IngestionStatus `json:"ingestionStatus,omitempty"` FileDetails FileDetails `json:"fileDetails,omitempty"` UploadID string `json:"uploadID,omitempty"` + Approved *bool `json:"approved,omitempty"` } type FileDetails struct { @@ -18,6 +19,7 @@ type FileDetails struct { URL string `json:"url,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"` Checksum string `json:"checksum,omitempty"` + Ingested bool `json:"ingested,omitempty"` } type IngestionStatus struct { diff --git a/apiclient/types/remoteKnowledgeSource.go b/apiclient/types/remoteKnowledgeSource.go index b1beb13bc..81f5e1680 100644 --- a/apiclient/types/remoteKnowledgeSource.go +++ b/apiclient/types/remoteKnowledgeSource.go @@ -21,27 +21,24 @@ type RemoteKnowledgeSource struct { type RemoteKnowledgeSourceManifest struct { SyncSchedule string `json:"syncSchedule,omitempty"` + AutoApprove *bool `json:"autoApprove,omitempty"` RemoteKnowledgeSourceInput `json:",inline"` } type RemoteKnowledgeSourceList List[RemoteKnowledgeSource] type RemoteKnowledgeSourceInput struct { - DisableIngestionAfterSync bool `json:"disableIngestionAfterSync,omitempty"` - SourceType RemoteKnowledgeSourceType `json:"sourceType,omitempty"` - Exclude []string `json:"exclude,omitempty"` - OneDriveConfig *OneDriveConfig `json:"onedriveConfig,omitempty"` - NotionConfig *NotionConfig `json:"notionConfig,omitempty"` - WebsiteCrawlingConfig *WebsiteCrawlingConfig `json:"websiteCrawlingConfig,omitempty"` + SourceType RemoteKnowledgeSourceType `json:"sourceType,omitempty"` + OneDriveConfig *OneDriveConfig `json:"onedriveConfig,omitempty"` + NotionConfig *NotionConfig `json:"notionConfig,omitempty"` + WebsiteCrawlingConfig *WebsiteCrawlingConfig `json:"websiteCrawlingConfig,omitempty"` } type OneDriveConfig struct { SharedLinks []string `json:"sharedLinks,omitempty"` } -type NotionConfig struct { - Pages []string `json:"pages,omitempty"` -} +type NotionConfig struct{} type WebsiteCrawlingConfig struct { URLs []string `json:"urls,omitempty"` @@ -56,6 +53,12 @@ type RemoteKnowledgeSourceState struct { type OneDriveLinksConnectorState struct { Folders FolderSet `json:"folders,omitempty"` Files map[string]FileState `json:"files,omitempty"` + Links map[string]LinkState `json:"links,omitempty"` +} + +type LinkState struct { + IsFolder bool `json:"isFolder,omitempty"` + Name string `json:"name,omitempty"` } type FileState struct { @@ -75,7 +78,11 @@ type NotionPage struct { } type WebsiteCrawlingConnectorState struct { - ScrapeJobIds map[string]string `json:"scrapeJobIds"` - Folders FolderSet `json:"folders"` - Pages map[string]Item `json:"pages"` + ScrapeJobIds map[string]string `json:"scrapeJobIds"` + Folders FolderSet `json:"folders"` + Pages map[string]PageDetails `json:"pages"` +} + +type PageDetails struct { + ParentURL string `json:"parentUrl"` } diff --git a/apiclient/types/zz_generated.deepcopy.go b/apiclient/types/zz_generated.deepcopy.go index 33af6a08c..686e4b223 100644 --- a/apiclient/types/zz_generated.deepcopy.go +++ b/apiclient/types/zz_generated.deepcopy.go @@ -401,6 +401,11 @@ func (in *KnowledgeFile) DeepCopyInto(out *KnowledgeFile) { in.Metadata.DeepCopyInto(&out.Metadata) out.IngestionStatus = in.IngestionStatus out.FileDetails = in.FileDetails + if in.Approved != nil { + in, out := &in.Approved, &out.Approved + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KnowledgeFile. @@ -435,6 +440,21 @@ func (in *KnowledgeFileList) DeepCopy() *KnowledgeFileList { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinkState) DeepCopyInto(out *LinkState) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinkState. +func (in *LinkState) DeepCopy() *LinkState { + if in == nil { + return nil + } + out := new(LinkState) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Metadata) DeepCopyInto(out *Metadata) { *out = *in @@ -472,11 +492,6 @@ func (in *Metadata) DeepCopy() *Metadata { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NotionConfig) DeepCopyInto(out *NotionConfig) { *out = *in - if in.Pages != nil { - in, out := &in.Pages, &out.Pages - *out = make([]string, len(*in)) - copy(*out, *in) - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotionConfig. @@ -632,6 +647,13 @@ func (in *OneDriveLinksConnectorState) DeepCopyInto(out *OneDriveLinksConnectorS (*out)[key] = val } } + if in.Links != nil { + in, out := &in.Links, &out.Links + *out = make(map[string]LinkState, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OneDriveLinksConnectorState. @@ -644,6 +666,21 @@ func (in *OneDriveLinksConnectorState) DeepCopy() *OneDriveLinksConnectorState { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PageDetails) DeepCopyInto(out *PageDetails) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PageDetails. +func (in *PageDetails) DeepCopy() *PageDetails { + if in == nil { + return nil + } + out := new(PageDetails) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Progress) DeepCopyInto(out *Progress) { *out = *in @@ -745,11 +782,6 @@ func (in *RemoteKnowledgeSource) DeepCopy() *RemoteKnowledgeSource { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RemoteKnowledgeSourceInput) DeepCopyInto(out *RemoteKnowledgeSourceInput) { *out = *in - if in.Exclude != nil { - in, out := &in.Exclude, &out.Exclude - *out = make([]string, len(*in)) - copy(*out, *in) - } if in.OneDriveConfig != nil { in, out := &in.OneDriveConfig, &out.OneDriveConfig *out = new(OneDriveConfig) @@ -758,7 +790,7 @@ func (in *RemoteKnowledgeSourceInput) DeepCopyInto(out *RemoteKnowledgeSourceInp if in.NotionConfig != nil { in, out := &in.NotionConfig, &out.NotionConfig *out = new(NotionConfig) - (*in).DeepCopyInto(*out) + **out = **in } if in.WebsiteCrawlingConfig != nil { in, out := &in.WebsiteCrawlingConfig, &out.WebsiteCrawlingConfig @@ -802,6 +834,11 @@ func (in *RemoteKnowledgeSourceList) DeepCopy() *RemoteKnowledgeSourceList { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RemoteKnowledgeSourceManifest) DeepCopyInto(out *RemoteKnowledgeSourceManifest) { *out = *in + if in.AutoApprove != nil { + in, out := &in.AutoApprove, &out.AutoApprove + *out = new(bool) + **out = **in + } in.RemoteKnowledgeSourceInput.DeepCopyInto(&out.RemoteKnowledgeSourceInput) } @@ -1326,7 +1363,7 @@ func (in *WebsiteCrawlingConnectorState) DeepCopyInto(out *WebsiteCrawlingConnec } if in.Pages != nil { in, out := &in.Pages, &out.Pages - *out = make(map[string]Item, len(*in)) + *out = make(map[string]PageDetails, len(*in)) for key, val := range *in { (*out)[key] = val } diff --git a/pkg/api/handlers/agent.go b/pkg/api/handlers/agent.go index 462ccb6d6..950fa9bec 100644 --- a/pkg/api/handlers/agent.go +++ b/pkg/api/handlers/agent.go @@ -184,6 +184,29 @@ func (a *AgentHandler) UploadKnowledge(req api.Context) error { return uploadKnowledge(req, a.gptscript, agent.Status.KnowledgeSetNames...) } +func (a *AgentHandler) ApproveKnowledgeFile(req api.Context) error { + var body struct { + Approve bool `json:"approve"` + } + + if err := req.Read(&body); err != nil { + return err + } + var file v1.KnowledgeFile + if err := req.Storage.Get(req.Context(), kclient.ObjectKey{ + Namespace: req.Namespace(), + Name: req.PathValue("file_id"), + }, &file); err != nil { + return err + } + + if file.Spec.Approved == nil || *file.Spec.Approved != body.Approve { + file.Spec.Approved = &body.Approve + return req.Storage.Update(req.Context(), &file) + } + return nil +} + func (a *AgentHandler) DeleteKnowledge(req api.Context) error { var agent v1.Agent if err := req.Get(&agent, req.PathValue("id")); err != nil { diff --git a/pkg/api/handlers/files.go b/pkg/api/handlers/files.go index 79d93ebad..11a3ad48c 100644 --- a/pkg/api/handlers/files.go +++ b/pkg/api/handlers/files.go @@ -114,6 +114,7 @@ func uploadKnowledgeToWorkspace(req api.Context, gClient *gptscript.GPTScript, w Spec: v1.KnowledgeFileSpec{ FileName: filename, WorkspaceName: ws.Name, + Approved: &[]bool{true}[0], }, } @@ -137,6 +138,7 @@ func convertKnowledgeFile(file v1.KnowledgeFile, ws v1.Workspace) types.Knowledg RemoteKnowledgeSourceID: file.Spec.RemoteKnowledgeSourceName, RemoteKnowledgeSourceType: file.Spec.RemoteKnowledgeSourceType, UploadID: file.Status.UploadID, + Approved: file.Spec.Approved, } } diff --git a/pkg/api/handlers/remoteknowledgesource.go b/pkg/api/handlers/remoteknowledgesource.go index e6754f74a..591af6f72 100644 --- a/pkg/api/handlers/remoteknowledgesource.go +++ b/pkg/api/handlers/remoteknowledgesource.go @@ -209,8 +209,9 @@ func checkConfigChanged(input types.RemoteKnowledgeSourceInput, remoteKnowledgeS return !equality.Semantic.DeepEqual(*input.OneDriveConfig, *remoteKnowledgeSource.Spec.Manifest.OneDriveConfig) } - if input.NotionConfig != nil && remoteKnowledgeSource.Spec.Manifest.NotionConfig != nil { - return !equality.Semantic.DeepEqual(*input.NotionConfig, *remoteKnowledgeSource.Spec.Manifest.NotionConfig) + if remoteKnowledgeSource.Spec.Manifest.SourceType == types.RemoteKnowledgeSourceTypeNotion { + // we never resync notion on update, this is because by default we sync every page that it has access to + return false } if input.WebsiteCrawlingConfig != nil && remoteKnowledgeSource.Spec.Manifest.WebsiteCrawlingConfig != nil { diff --git a/pkg/api/router/router.go b/pkg/api/router/router.go index 9466087ef..95d4ac83f 100644 --- a/pkg/api/router/router.go +++ b/pkg/api/router/router.go @@ -38,6 +38,7 @@ func Router(services *services.Services) (http.Handler, error) { // Agent knowledge files mux.HandleFunc("GET /api/agents/{id}/knowledge", agents.Knowledge) mux.HandleFunc("POST /api/agents/{id}/knowledge/{file}", agents.UploadKnowledge) + mux.HandleFunc("PUT /api/agents/{id}/knowledge/{file_id}/approve", agents.ApproveKnowledgeFile) mux.HandleFunc("DELETE /api/agents/{id}/knowledge/{file...}", agents.DeleteKnowledge) mux.HandleFunc("POST /api/agents/{agent_id}/remote-knowledge-sources", agents.CreateRemoteKnowledgeSource) diff --git a/pkg/controller/handlers/knowledge/knowledge.go b/pkg/controller/handlers/knowledge/knowledge.go index ca416014c..5818681f1 100644 --- a/pkg/controller/handlers/knowledge/knowledge.go +++ b/pkg/controller/handlers/knowledge/knowledge.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "maps" + "path/filepath" "sort" "strings" "time" @@ -21,6 +22,7 @@ import ( "github.com/otto8-ai/otto8/pkg/events" "github.com/otto8-ai/otto8/pkg/knowledge" v1 "github.com/otto8-ai/otto8/pkg/storage/apis/otto.gptscript.ai/v1" + "github.com/otto8-ai/otto8/pkg/workspace" "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -70,28 +72,6 @@ func (a *Handler) DeleteKnowledge(req router.Request, _ router.Response) error { return nil } -func (a *Handler) isIngestionBlocked(ctx context.Context, c kclient.Client, ws *v1.Workspace) (bool, error) { - var ks v1.KnowledgeSet - if err := c.Get(ctx, router.Key(ws.Namespace, ws.Spec.KnowledgeSetName), &ks); err != nil { - return false, err - } - - var rks v1.RemoteKnowledgeSourceList - if err := c.List(ctx, &rks, kclient.InNamespace(ws.Namespace), kclient.MatchingFields{ - "spec.knowledgeSetName": ks.Name, - }); err != nil { - return false, err - } - - for _, rks := range rks.Items { - if rks.Spec.Manifest.DisableIngestionAfterSync { - return true, nil - } - } - - return false, nil -} - func (a *Handler) IngestKnowledge(req router.Request, resp router.Response) error { ws := req.Object.(*v1.Workspace) if !ws.Spec.IsKnowledge || ws.Spec.KnowledgeSetName == "" { @@ -103,10 +83,6 @@ func (a *Handler) IngestKnowledge(req router.Request, resp router.Response) erro return nil } - if blocked, err := a.isIngestionBlocked(req.Ctx, req.Client, ws); blocked || err != nil { - return err - } - if !ws.Status.IngestionLastRunTime.IsZero() && ws.Status.IngestionLastRunTime.Add(30*time.Second).After(time.Now()) { resp.RetryAfter(10 * time.Second) return nil @@ -119,10 +95,27 @@ func (a *Handler) IngestKnowledge(req router.Request, resp router.Response) erro return err } - if len(files.Items) == 0 { + var approvedFiles v1.KnowledgeFileList + for _, file := range files.Items { + if file.Spec.Approved != nil && *file.Spec.Approved { + approvedFiles.Items = append(approvedFiles.Items, file) + } + } + + if len(approvedFiles.Items) == 0 { return nil } + // Sleep for 5 seconds before invoking to fetch the files. In case when files are approved at the same time, the first invoke will + // have partial file approve list. It will eventually have all files but the first ingestion will be incompleted. Sleeping for 5 seconds + // so that we can make sure we wait for the approved file that happens at the same time + time.Sleep(5 * time.Second) + if err := req.Client.List(req.Ctx, uncached.List(&files), kclient.InNamespace(ws.Namespace), kclient.MatchingFields{ + "spec.workspaceName": ws.Name, + }); err != nil { + return err + } + sort.Slice(files.Items, func(i, j int) bool { return files.Items[i].UID < files.Items[j].UID }) @@ -130,12 +123,13 @@ func (a *Handler) IngestKnowledge(req router.Request, resp router.Response) erro digest := sha256.New() for _, file := range files.Items { - digest.Write([]byte(file.Name)) - digest.Write([]byte{0}) - digest.Write([]byte(file.Status.FileDetails.UpdatedAt)) - digest.Write([]byte{0}) + if file.Spec.Approved != nil && *file.Spec.Approved { + digest.Write([]byte(file.Name)) + digest.Write([]byte{0}) + digest.Write([]byte(file.Status.FileDetails.UpdatedAt)) + digest.Write([]byte{0}) + } } - var syncNeeded bool hash := fmt.Sprintf("%x", digest.Sum(nil)) @@ -162,6 +156,32 @@ func (a *Handler) IngestKnowledge(req router.Request, resp router.Response) erro } if syncNeeded { + var ignoreFileContent string + for _, file := range files.Items { + if file.Spec.Approved == nil || !*file.Spec.Approved { + if file.Status.FileDetails.FilePath != "" { + rel, err := filepath.Rel(workspace.GetDir(ws.Status.WorkspaceID), file.Status.FileDetails.FilePath) + if err != nil { + return fmt.Errorf("failed to get relative path for file: %w", err) + } + ignoreFileContent += fmt.Sprintf("%s\n", rel) + } else { + ignoreFileContent += fmt.Sprintf("%s\n", file.Spec.FileName) + } + } + } + + if ignoreFileContent != "" { + err := a.gptscript.WriteFileInWorkspace(req.Ctx, ws.Status.WorkspaceID, ".knowignore", []byte(ignoreFileContent)) + if err != nil { + return fmt.Errorf("failed to create knowledge metadata file: %w", err) + } + } else { + if err := a.gptscript.DeleteFileInWorkspace(req.Ctx, ws.Status.WorkspaceID, ".knowignore"); err != nil { + return fmt.Errorf("failed to delete ignore file: %w", err) + } + } + run, err := a.ingester.IngestKnowledge(req.Ctx, ws.GetNamespace(), ws.Spec.KnowledgeSetName, ws.Status.WorkspaceID) if err != nil { return err @@ -264,11 +284,6 @@ func compileFileStatuses(ctx context.Context, client kclient.Client, ws *v1.Work errs = append(errs, fmt.Errorf("failed to get knowledge file: %s", err)) } - if ingestionStatus.Status == "skipped" { - // Don't record the rather useless skipped messages - continue - } - if ingestionStatus.Status == "finished" { delete(final, file.Name) } @@ -303,5 +318,9 @@ func (a *Handler) CleanupFile(req router.Request, resp router.Response) error { return err } + if _, err := a.ingester.DeleteKnowledgeFiles(req.Ctx, kFile.Namespace, filepath.Join(workspace.GetDir(ws.Status.WorkspaceID), kFile.Spec.FileName), ws.Spec.KnowledgeSetName); err != nil { + return err + } + return nil } diff --git a/pkg/controller/handlers/uploads/remoteknowledgesource.go b/pkg/controller/handlers/uploads/remoteknowledgesource.go index cb690cecc..287c2a568 100644 --- a/pkg/controller/handlers/uploads/remoteknowledgesource.go +++ b/pkg/controller/handlers/uploads/remoteknowledgesource.go @@ -220,12 +220,12 @@ func (u *UploadHandler) HandleUploadRun(req router.Request, resp router.Response return err } - if err := u.writeMetadataForKnowledge(req.Ctx, metadata.Output.Files, ws, remoteKnowledgeSource); err != nil { + knowledgeFileNamesFromOutput, err := compileKnowledgeFiles(req.Ctx, req.Client, remoteKnowledgeSource, metadata.Output.Files, ws) + if err != nil { return err } - knowledgeFileNamesFromOutput, err := compileKnowledgeFilesFromOneDriveConnector(req.Ctx, req.Client, remoteKnowledgeSource, metadata.Output.Files, ws) - if err != nil { + if err := u.writeMetadataForKnowledge(req.Ctx, metadata.Output.Files, ws, remoteKnowledgeSource); err != nil { return err } @@ -313,7 +313,7 @@ func createFileMetadata(files map[string]types.FileDetails, ws v1.Workspace) map return fileMetadata } -func compileKnowledgeFilesFromOneDriveConnector(ctx context.Context, c client.Client, +func compileKnowledgeFiles(ctx context.Context, c client.Client, remoteKnowledgeSource *v1.RemoteKnowledgeSource, files map[string]types.FileDetails, ws *v1.Workspace) (map[string]struct{}, error) { var ( @@ -322,6 +322,7 @@ func compileKnowledgeFilesFromOneDriveConnector(ctx context.Context, c client.Cl outputDir = workspace.GetDir(ws.Status.WorkspaceID) knowledgeFileNamesFromOutput = make(map[string]struct{}, len(files)) ) + for id, v := range files { fileRelPath, err := filepath.Rel(outputDir, v.FilePath) if err != nil { @@ -342,12 +343,16 @@ func compileKnowledgeFilesFromOneDriveConnector(ctx context.Context, c client.Cl RemoteKnowledgeSourceType: remoteKnowledgeSource.Spec.Manifest.SourceType, }, } + if remoteKnowledgeSource.Spec.Manifest.AutoApprove != nil && *remoteKnowledgeSource.Spec.Manifest.AutoApprove { + newKnowledgeFile.Spec.Approved = &[]bool{true}[0] + } if err := c.Create(ctx, newKnowledgeFile); err == nil || apierrors.IsAlreadyExists(err) { // If the file was created or already existed, ensure it has the latest details from the metadata. if err = retry.RetryOnConflict(retry.DefaultBackoff, func() error { if err := c.Get(ctx, router.Key(newKnowledgeFile.Namespace, newKnowledgeFile.Name), uncached.Get(newKnowledgeFile)); err != nil { return err } + v.Ingested = newKnowledgeFile.Status.IngestionStatus.Status == "finished" || newKnowledgeFile.Status.IngestionStatus.Status == "skipped" if newKnowledgeFile.Status.UploadID == id && newKnowledgeFile.Status.FileDetails == v { // The file has the correct details, no need to update. return nil diff --git a/pkg/knowledge/knowledge.go b/pkg/knowledge/knowledge.go index 3dde2771b..5774f8612 100644 --- a/pkg/knowledge/knowledge.go +++ b/pkg/knowledge/knowledge.go @@ -31,6 +31,19 @@ func (i *Ingester) IngestKnowledge(ctx context.Context, namespace, knowledgeSetN ) } +func (i *Ingester) DeleteKnowledgeFiles(ctx context.Context, namespace, knowledgeFilePath string, knowledgeSetName string) (*invoke.Response, error) { + return i.invoker.SystemAction( + ctx, + "ingest-delete-file-", + namespace, + system.KnowledgeDeleteFileTool, + knowledgeFilePath, + "GPTSCRIPT_DATASET="+knowledgeSetName, + "KNOW_JSON=true", + ) + +} + func (i *Ingester) DeleteKnowledge(ctx context.Context, namespace, knowledgeSetName string) (*invoke.Response, error) { return i.invoker.SystemAction( ctx, diff --git a/pkg/storage/apis/otto.gptscript.ai/v1/file.go b/pkg/storage/apis/otto.gptscript.ai/v1/file.go index 0e4d60c25..d4cce9c20 100644 --- a/pkg/storage/apis/otto.gptscript.ai/v1/file.go +++ b/pkg/storage/apis/otto.gptscript.ai/v1/file.go @@ -58,6 +58,7 @@ type KnowledgeFileSpec struct { WorkspaceName string `json:"workspaceName,omitempty"` RemoteKnowledgeSourceName string `json:"remoteKnowledgeSourceName,omitempty"` RemoteKnowledgeSourceType types.RemoteKnowledgeSourceType `json:"remoteKnowledgeSourceType,omitempty"` + Approved *bool `json:"approved,omitempty"` } type KnowledgeFileStatus struct { diff --git a/pkg/storage/apis/otto.gptscript.ai/v1/zz_generated.deepcopy.go b/pkg/storage/apis/otto.gptscript.ai/v1/zz_generated.deepcopy.go index d7a2670b6..ff2c7edf1 100644 --- a/pkg/storage/apis/otto.gptscript.ai/v1/zz_generated.deepcopy.go +++ b/pkg/storage/apis/otto.gptscript.ai/v1/zz_generated.deepcopy.go @@ -228,7 +228,7 @@ func (in *KnowledgeFile) DeepCopyInto(out *KnowledgeFile) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) out.Status = in.Status } @@ -285,6 +285,11 @@ func (in *KnowledgeFileList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KnowledgeFileSpec) DeepCopyInto(out *KnowledgeFileSpec) { *out = *in + if in.Approved != nil { + in, out := &in.Approved, &out.Approved + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KnowledgeFileSpec. diff --git a/pkg/storage/openapi/generated/openapi_generated.go b/pkg/storage/openapi/generated/openapi_generated.go index f75476fa4..9b059d640 100644 --- a/pkg/storage/openapi/generated/openapi_generated.go +++ b/pkg/storage/openapi/generated/openapi_generated.go @@ -35,6 +35,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/otto8-ai/otto8/apiclient/types.Item": schema_otto8_ai_otto8_apiclient_types_Item(ref), "github.com/otto8-ai/otto8/apiclient/types.KnowledgeFile": schema_otto8_ai_otto8_apiclient_types_KnowledgeFile(ref), "github.com/otto8-ai/otto8/apiclient/types.KnowledgeFileList": schema_otto8_ai_otto8_apiclient_types_KnowledgeFileList(ref), + "github.com/otto8-ai/otto8/apiclient/types.LinkState": schema_otto8_ai_otto8_apiclient_types_LinkState(ref), "github.com/otto8-ai/otto8/apiclient/types.Metadata": schema_otto8_ai_otto8_apiclient_types_Metadata(ref), "github.com/otto8-ai/otto8/apiclient/types.NotionConfig": schema_otto8_ai_otto8_apiclient_types_NotionConfig(ref), "github.com/otto8-ai/otto8/apiclient/types.NotionConnectorState": schema_otto8_ai_otto8_apiclient_types_NotionConnectorState(ref), @@ -45,6 +46,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/otto8-ai/otto8/apiclient/types.OAuthAppManifest": schema_otto8_ai_otto8_apiclient_types_OAuthAppManifest(ref), "github.com/otto8-ai/otto8/apiclient/types.OneDriveConfig": schema_otto8_ai_otto8_apiclient_types_OneDriveConfig(ref), "github.com/otto8-ai/otto8/apiclient/types.OneDriveLinksConnectorState": schema_otto8_ai_otto8_apiclient_types_OneDriveLinksConnectorState(ref), + "github.com/otto8-ai/otto8/apiclient/types.PageDetails": schema_otto8_ai_otto8_apiclient_types_PageDetails(ref), "github.com/otto8-ai/otto8/apiclient/types.Progress": schema_otto8_ai_otto8_apiclient_types_Progress(ref), "github.com/otto8-ai/otto8/apiclient/types.Prompt": schema_otto8_ai_otto8_apiclient_types_Prompt(ref), "github.com/otto8-ai/otto8/apiclient/types.RemoteKnowledgeSource": schema_otto8_ai_otto8_apiclient_types_RemoteKnowledgeSource(ref), @@ -717,6 +719,12 @@ func schema_otto8_ai_otto8_apiclient_types_FileDetails(ref common.ReferenceCallb Format: "", }, }, + "ingested": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, }, }, }, @@ -1018,6 +1026,12 @@ func schema_otto8_ai_otto8_apiclient_types_KnowledgeFile(ref common.ReferenceCal Format: "", }, }, + "approved": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, }, Required: []string{"Metadata", "fileName"}, }, @@ -1055,6 +1069,30 @@ func schema_otto8_ai_otto8_apiclient_types_KnowledgeFileList(ref common.Referenc } } +func schema_otto8_ai_otto8_apiclient_types_LinkState(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "isFolder": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, + "name": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + func schema_otto8_ai_otto8_apiclient_types_Metadata(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -1120,22 +1158,6 @@ func schema_otto8_ai_otto8_apiclient_types_NotionConfig(ref common.ReferenceCall Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "pages": { - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - }, }, }, } @@ -1439,11 +1461,45 @@ func schema_otto8_ai_otto8_apiclient_types_OneDriveLinksConnectorState(ref commo }, }, }, + "links": { + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/otto8-ai/otto8/apiclient/types.LinkState"), + }, + }, + }, + }, + }, }, }, }, Dependencies: []string{ - "github.com/otto8-ai/otto8/apiclient/types.FileState", "github.com/otto8-ai/otto8/apiclient/types.Item"}, + "github.com/otto8-ai/otto8/apiclient/types.FileState", "github.com/otto8-ai/otto8/apiclient/types.Item", "github.com/otto8-ai/otto8/apiclient/types.LinkState"}, + } +} + +func schema_otto8_ai_otto8_apiclient_types_PageDetails(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "parentUrl": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"parentUrl"}, + }, + }, } } @@ -1657,7 +1713,7 @@ func schema_otto8_ai_otto8_apiclient_types_RemoteKnowledgeSource(ref common.Refe Format: "", }, }, - "disableIngestionAfterSync": { + "autoApprove": { SchemaProps: spec.SchemaProps{ Type: []string{"boolean"}, Format: "", @@ -1669,20 +1725,6 @@ func schema_otto8_ai_otto8_apiclient_types_RemoteKnowledgeSource(ref common.Refe Format: "", }, }, - "exclude": { - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, "onedriveConfig": { SchemaProps: spec.SchemaProps{ Ref: ref("github.com/otto8-ai/otto8/apiclient/types.OneDriveConfig"), @@ -1749,32 +1791,12 @@ func schema_otto8_ai_otto8_apiclient_types_RemoteKnowledgeSourceInput(ref common SchemaProps: spec.SchemaProps{ Type: []string{"object"}, Properties: map[string]spec.Schema{ - "disableIngestionAfterSync": { - SchemaProps: spec.SchemaProps{ - Type: []string{"boolean"}, - Format: "", - }, - }, "sourceType": { SchemaProps: spec.SchemaProps{ Type: []string{"string"}, Format: "", }, }, - "exclude": { - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, "onedriveConfig": { SchemaProps: spec.SchemaProps{ Ref: ref("github.com/otto8-ai/otto8/apiclient/types.OneDriveConfig"), @@ -1838,7 +1860,7 @@ func schema_otto8_ai_otto8_apiclient_types_RemoteKnowledgeSourceManifest(ref com Format: "", }, }, - "disableIngestionAfterSync": { + "autoApprove": { SchemaProps: spec.SchemaProps{ Type: []string{"boolean"}, Format: "", @@ -1850,20 +1872,6 @@ func schema_otto8_ai_otto8_apiclient_types_RemoteKnowledgeSourceManifest(ref com Format: "", }, }, - "exclude": { - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, "onedriveConfig": { SchemaProps: spec.SchemaProps{ Ref: ref("github.com/otto8-ai/otto8/apiclient/types.OneDriveConfig"), @@ -2879,7 +2887,7 @@ func schema_otto8_ai_otto8_apiclient_types_WebsiteCrawlingConnectorState(ref com Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/otto8-ai/otto8/apiclient/types.Item"), + Ref: ref("github.com/otto8-ai/otto8/apiclient/types.PageDetails"), }, }, }, @@ -2890,7 +2898,7 @@ func schema_otto8_ai_otto8_apiclient_types_WebsiteCrawlingConnectorState(ref com }, }, Dependencies: []string{ - "github.com/otto8-ai/otto8/apiclient/types.Item"}, + "github.com/otto8-ai/otto8/apiclient/types.Item", "github.com/otto8-ai/otto8/apiclient/types.PageDetails"}, } } @@ -3725,6 +3733,12 @@ func schema_storage_apis_ottogptscriptai_v1_KnowledgeFileSpec(ref common.Referen Format: "", }, }, + "approved": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, }, Required: []string{"fileName"}, }, diff --git a/pkg/system/tools.go b/pkg/system/tools.go index 7022d7b88..5194a4c51 100644 --- a/pkg/system/tools.go +++ b/pkg/system/tools.go @@ -1,7 +1,8 @@ package system const ( - KnowledgeIngestTool = "knowledge-ingest" - KnowledgeDeleteTool = "knowledge-delete" - KnowledgeRetrievalTool = "knowledge-retrieval" + KnowledgeIngestTool = "knowledge-ingest" + KnowledgeDeleteTool = "knowledge-delete" + KnowledgeDeleteFileTool = "knowledge-delete-file" + KnowledgeRetrievalTool = "knowledge-retrieval" ) diff --git a/ui/admin/app/components/knowledge/AddFileModal.tsx b/ui/admin/app/components/knowledge/AddFileModal.tsx deleted file mode 100644 index 58091c3ca..000000000 --- a/ui/admin/app/components/knowledge/AddFileModal.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { Globe, Plus } from "lucide-react"; -import { RefObject, useState } from "react"; - -import { RemoteKnowledgeSource } from "~/lib/model/knowledge"; -import { KnowledgeService } from "~/lib/service/api/knowledgeService"; -import { assetUrl } from "~/lib/utils"; - -import { Avatar } from "~/components/ui/avatar"; -import { Button } from "~/components/ui/button"; -import { - Dialog, - DialogContent, - DialogOverlay, - DialogPortal, - DialogTitle, -} from "~/components/ui/dialog"; - -import { NotionModal } from "./notion/NotionModal"; -import { OnedriveModal } from "./onedrive/OneDriveModal"; -import { WebsiteModal } from "./website/WebsiteModal"; - -interface AddFileModalProps { - fileInputRef: RefObject; - agentId: string; - isOpen: boolean; - startPolling: () => void; - onOpenChange: (open: boolean) => void; - remoteKnowledgeSources: RemoteKnowledgeSource[]; -} - -export const AddFileModal = ({ - fileInputRef, - agentId, - isOpen, - startPolling, - onOpenChange, - remoteKnowledgeSources, -}: AddFileModalProps) => { - const [isOnedriveModalOpen, setIsOnedriveModalOpen] = useState(false); - const [isNotionModalOpen, setIsNotionModalOpen] = useState(false); - const [isWebsiteModalOpen, setIsWebsiteModalOpen] = useState(false); - - const getNotionSource = async () => { - const notionSource = remoteKnowledgeSources.find( - (source) => source.sourceType === "notion" - ); - return notionSource; - }; - - const onClickNotion = async () => { - // For notion, we need to ensure the remote knowledge source is created so that client can fetch a list of pages - const notionSource = await getNotionSource(); - if (!notionSource) { - await KnowledgeService.createRemoteKnowledgeSource(agentId, { - sourceType: "notion", - }); - } - onOpenChange(false); - setIsNotionModalOpen(true); - startPolling(); - }; - - return ( -
- - - - - -
- - - - -
-
-
-
- - - -
- ); -}; diff --git a/ui/admin/app/components/knowledge/AgentKnowledgePanel.tsx b/ui/admin/app/components/knowledge/AgentKnowledgePanel.tsx index d4dff4f14..c874d51ea 100644 --- a/ui/admin/app/components/knowledge/AgentKnowledgePanel.tsx +++ b/ui/admin/app/components/knowledge/AgentKnowledgePanel.tsx @@ -1,48 +1,34 @@ -import { CheckIcon, Info, PlusIcon, XCircleIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import useSWR from "swr"; +import { Globe, SettingsIcon, UploadIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import useSWR, { SWRResponse } from "swr"; import { IngestionStatus, KnowledgeFile, - KnowledgeIngestionStatus, + RemoteKnowledgeSourceType, + getIngestedFilesCount, getIngestionStatus, - getMessage, - getRemoteFileDisplayName, } from "~/lib/model/knowledge"; import { ApiRoutes } from "~/lib/routers/apiRoutes"; import { KnowledgeService } from "~/lib/service/api/knowledgeService"; -import { cn, getErrorMessage } from "~/lib/utils"; +import { assetUrl } from "~/lib/utils"; import { Button } from "~/components/ui/button"; -import { ScrollArea } from "~/components/ui/scroll-area"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "~/components/ui/tooltip"; -import { useAsync } from "~/hooks/useAsync"; -import { useMultiAsync } from "~/hooks/useMultiAsync"; -import { LoadingSpinner } from "../ui/LoadingSpinner"; -import { Input } from "../ui/input"; -import { AddFileModal } from "./AddFileModal"; -import { FileChip } from "./FileItem"; -import RemoteFileItemChip from "./RemoteFileItemChip"; -import RemoteKnowledgeSourceStatus from "./RemoteKnowledgeSourceStatus"; +import { Avatar } from "../ui/avatar"; +import FileModal from "./file/FileModal"; +import { NotionModal } from "./notion/NotionModal"; +import { OnedriveModal } from "./onedrive/OneDriveModal"; +import { WebsiteModal } from "./website/WebsiteModal"; -export function AgentKnowledgePanel({ - agentId, - className, -}: { - agentId: string; - className?: string; -}) { +export function AgentKnowledgePanel({ agentId }: { agentId: string }) { const [blockPolling, setBlockPolling] = useState(false); const [isAddFileModalOpen, setIsAddFileModalOpen] = useState(false); + const [isOnedriveModalOpen, setIsOnedriveModalOpen] = useState(false); + const [isNotionModalOpen, setIsNotionModalOpen] = useState(false); + const [isWebsiteModalOpen, setIsWebsiteModalOpen] = useState(false); - const getKnowledge = useSWR( + const getKnowledgeFiles: SWRResponse = useSWR( KnowledgeService.getKnowledgeForAgent.key(agentId), ({ agentId }) => KnowledgeService.getKnowledgeForAgent(agentId).then((items) => @@ -67,7 +53,10 @@ export function AgentKnowledgePanel({ refreshInterval: blockPolling ? undefined : 1000, } ); - const knowledge = getKnowledge.data || []; + const knowledge = useMemo( + () => getKnowledgeFiles.data || [], + [getKnowledgeFiles.data] + ); const getRemoteKnowledgeSources = useSWR( KnowledgeService.getRemoteKnowledgeSource.key(agentId), @@ -82,120 +71,20 @@ export function AgentKnowledgePanel({ [getRemoteKnowledgeSources.data] ); - const deleteKnowledge = useAsync(async (item: KnowledgeFile) => { - await KnowledgeService.deleteKnowledgeFromAgent(agentId, item.fileName); - - const remoteKnowledgeSource = remoteKnowledgeSources?.find( - (source) => source.sourceType === item.remoteKnowledgeSourceType - ); - if (remoteKnowledgeSource) { - await KnowledgeService.updateRemoteKnowledgeSource( - agentId, - remoteKnowledgeSource.id, - { - ...remoteKnowledgeSource, - exclude: [ - ...(remoteKnowledgeSource.exclude || []), - item.uploadID || "", - ], - } - ); - } - - // optomistic update without cache revalidation - getKnowledge.mutate((prev) => - prev?.filter((prevItem) => prevItem.fileName !== item.fileName) - ); - }); - - const handleAddKnowledge = useCallback( - async (_index: number, file: File) => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - await KnowledgeService.addKnowledgeToAgent(agentId, file); - - // once added, we can immediately mutate the cache value - // without revalidating. - // Revalidating here would cause knowledge to be refreshed - // for each file being uploaded, which is not desirable. - const newItem: KnowledgeFile = { - fileName: file.name, - agentID: agentId, - // set ingestion status to starting to ensure polling is enabled - ingestionStatus: { status: IngestionStatus.Queued }, - fileDetails: {}, - }; - - getKnowledge.mutate( - (prev) => { - const existingItemIndex = prev?.findIndex( - (item) => item.fileName === newItem.fileName - ); - if (existingItemIndex !== -1 && prev) { - const updatedPrev = [...prev]; - updatedPrev[existingItemIndex!] = newItem; - return updatedPrev; - } else { - return [newItem, ...(prev || [])]; - } - }, - { - revalidate: false, - } - ); - }, - [agentId, getKnowledge] - ); - - // use multi async to handle uploading multiple files at once - const uploadKnowledge = useMultiAsync(handleAddKnowledge); - - const fileInputRef = useRef(null); - - const startUpload = (files: FileList) => { - if (!files.length) return; - - setIgnoredFiles([]); - - uploadKnowledge.execute( - Array.from(files).map((file) => [file] as const) - ); - - if (fileInputRef.current) fileInputRef.current.value = ""; - }; - - const [ignoredFiles, setIgnoredFiles] = useState([]); - - const uploadingFiles = useMemo( - () => - uploadKnowledge.states.filter( - (state) => - !state.isSuccessful && - !ignoredFiles.includes(state.params[0].name) - ), - [ignoredFiles, uploadKnowledge.states] - ); - useEffect(() => { - // we can assume that the knowledge is completely ingested if all items have a status of completed or skipped - // if that is the case, then we can block polling for updates - const hasCompleteIngestion = getKnowledge.data?.every((item) => { - const ingestionStatus = getIngestionStatus(item.ingestionStatus); - return ( - ingestionStatus === IngestionStatus.Finished || - ingestionStatus === IngestionStatus.Skipped + if (knowledge.length > 0) { + setBlockPolling( + remoteKnowledgeSources.every((source) => !source.runID) && + knowledge.every( + (item) => + item.ingestionStatus?.status === + IngestionStatus.Finished || + item.ingestionStatus?.status === + IngestionStatus.Skipped + ) ); - }); - - const hasIncompleteUpload = uploadKnowledge.states.some( - (state) => state.isLoading - ); - - setBlockPolling( - hasCompleteIngestion || - hasIncompleteUpload || - deleteKnowledge.isLoading - ); - }, [uploadKnowledge.states, deleteKnowledge.isLoading, getKnowledge.data]); + } + }, [remoteKnowledgeSources, knowledge]); useEffect(() => { remoteKnowledgeSources?.forEach((source) => { @@ -233,273 +122,268 @@ export function AgentKnowledgePanel({ }); }, [remoteKnowledgeSources]); - const handleRemoteKnowledgeSourceSync = useCallback(async () => { + let notionSource = remoteKnowledgeSources.find( + (source) => source.sourceType === "notion" + ); + let onedriveSource = remoteKnowledgeSources.find( + (source) => source.sourceType === "onedrive" + ); + const websiteSource = remoteKnowledgeSources.find( + (source) => source.sourceType === "website" + ); + + const onClickNotion = async () => { + // For notion, we need to ensure the remote knowledge source is created so that client can fetch a list of pages + if (!notionSource) { + await KnowledgeService.createRemoteKnowledgeSource(agentId, { + sourceType: "notion", + }); + const intervalId = setInterval(() => { + getRemoteKnowledgeSources.mutate(); + notionSource = remoteKnowledgeSources.find( + (source) => source.sourceType === "notion" + ); + if (notionSource?.runID) { + clearInterval(intervalId); + } + }, 1000); + setTimeout(() => { + clearInterval(intervalId); + }, 10000); + } + setIsNotionModalOpen(true); + }; + + const onClickOnedrive = async () => { + if (!onedriveSource) { + await KnowledgeService.createRemoteKnowledgeSource(agentId, { + sourceType: "onedrive", + }); + const intervalId = setInterval(() => { + getRemoteKnowledgeSources.mutate(); + onedriveSource = remoteKnowledgeSources.find( + (source) => source.sourceType === "onedrive" + ); + if (onedriveSource?.runID) { + clearInterval(intervalId); + } + }, 1000); + setTimeout(() => { + clearInterval(intervalId); + }, 10000); + } + setIsOnedriveModalOpen(true); + }; + + const onClickWebsite = async () => { + if (!websiteSource) { + await KnowledgeService.createRemoteKnowledgeSource(agentId, { + sourceType: "website", + }); + getRemoteKnowledgeSources.mutate(); + } + setIsWebsiteModalOpen(true); + }; + + const startPolling = () => { + getRemoteKnowledgeSources.mutate(); + getKnowledgeFiles.mutate(); + setBlockPolling(false); + }; + + const handleRemoteKnowledgeSourceSync = async ( + knowledgeSourceType: RemoteKnowledgeSourceType + ) => { try { - for (const source of remoteKnowledgeSources!) { + const source = remoteKnowledgeSources?.find( + (source) => source.sourceType === knowledgeSourceType + ); + if (source) { await KnowledgeService.resyncRemoteKnowledgeSource( agentId, source.id ); } - setTimeout(() => { + const intervalId = setInterval(() => { getRemoteKnowledgeSources.mutate(); + const updatedSource = remoteKnowledgeSources?.find( + (source) => source.sourceType === knowledgeSourceType + ); + if (updatedSource?.runID) { + clearInterval(intervalId); + } }, 1000); + // this is a failsafe to clear the interval as source should be updated with runID in 10 seconds once the source is resynced + setTimeout(() => { + clearInterval(intervalId); + startPolling(); + }, 10000); } catch (error) { console.error("Failed to resync remote knowledge source:", error); - } finally { - setBlockPolling(false); } - }, [agentId, getRemoteKnowledgeSources, remoteKnowledgeSources]); - - return ( -
- - {uploadingFiles.length > 0 && ( -
- {uploadingFiles.map((state, index) => ( - - setIgnoredFiles((prev) => [ - ...prev, - state.params[0].name, - ]) - } - fileName={state.params[0].name} - /> - ))} - -
-
- )} + }; -
- {knowledge.map((item) => { - if (item.remoteKnowledgeSourceType) { - return ( - - deleteKnowledge.execute(item) - } - statusIcon={renderStatusIcon( - item.ingestionStatus - )} - isLoading={ - deleteKnowledge.isLoading && - deleteKnowledge.lastCallParams?.[0] - .fileName === item.fileName - } - remoteKnowledgeSourceType={ - item.remoteKnowledgeSourceType - } - /> - ); - } - return ( - deleteKnowledge.execute(item)} - statusIcon={renderStatusIcon( - item.ingestionStatus - )} - isLoading={ - deleteKnowledge.isLoading && - deleteKnowledge.lastCallParams?.[0] - .fileName === item.fileName - } - fileName={item.fileName} - /> - ); - })} -
- -
-
-
- {(() => { - const ingestingCount = knowledge.filter( - (item) => - item.ingestionStatus?.status === - IngestionStatus.Starting || - item.ingestionStatus?.status === - IngestionStatus.Completed - ).length; - const queuedCount = knowledge.filter( - (item) => - item.ingestionStatus?.status === - IngestionStatus.Queued - ).length; - const notSupportedCount = knowledge.filter( - (item) => - item.ingestionStatus?.status === - IngestionStatus.Unsupported - ).length; - const ingestedCount = knowledge.filter( - (item) => - item.ingestionStatus?.status === - IngestionStatus.Finished || - item.ingestionStatus?.status === - IngestionStatus.Skipped - ).length; - const totalCount = knowledge.length; + const notionFiles = knowledge.filter( + (item) => item.remoteKnowledgeSourceType === "notion" + ); + const onedriveFiles = knowledge.filter( + (item) => item.remoteKnowledgeSourceType === "onedrive" + ); + const websiteFiles = knowledge.filter( + (item) => item.remoteKnowledgeSourceType === "website" + ); + const localFiles = knowledge.filter( + (item) => !item.remoteKnowledgeSourceType + ); - if (ingestingCount > 0 || queuedCount > 0) { - return ( - <> - - - -
- - - Ingesting... - -
-
- -

- Ingestion Status: -

-

- Files ingesting:{" "} - {ingestingCount} -

-

- Files ingested:{" "} - {ingestedCount} -

-

- Files queued:{" "} - {queuedCount} -

-

- Files not supported:{" "} - {notSupportedCount} -

-
-
-
- - ); - } else if ( - totalCount > 0 && - queuedCount === 0 && - ingestingCount === 0 - ) { - return ( - <> - - - {ingestedCount} file - {ingestedCount !== 1 - ? "s" - : ""}{" "} - ingested - - - ); - } - return null; - })()} -
- {remoteKnowledgeSources?.map((source) => { - if (source.runID) { - return ( - - ); - } - })} + return ( +
+
+
+ + Files
-
- {remoteKnowledgeSources && - remoteKnowledgeSources.length > 0 && ( - +
+
+ {getIngestedFilesCount(localFiles) > 0 && ( + + {getIngestedFilesCount(localFiles)}{" "} + {getIngestedFilesCount(localFiles) === 1 + ? "file" + : "files"}{" "} + ingested + )} +
- - { - if (!e.target.files) return; - startUpload(e.target.files); - }} - />
-
- +
+
+ + Notion logo + + Notion +
+
+ {getIngestedFilesCount(notionFiles) > 0 && ( + + {getIngestedFilesCount(notionFiles)}{" "} + {getIngestedFilesCount(notionFiles) === 1 + ? "file" + : "files"}{" "} + ingested + + )} + +
+
+
+
+ + OneDrive logo + + OneDrive +
+
+ {getIngestedFilesCount(onedriveFiles) > 0 && ( + + {getIngestedFilesCount(onedriveFiles)}{" "} + {getIngestedFilesCount(onedriveFiles) === 1 + ? "file" + : "files"}{" "} + ingested + + )} + +
+
+
+
+ + Website +
+
+ {getIngestedFilesCount(websiteFiles) > 0 && ( + + {getIngestedFilesCount(websiteFiles)}{" "} + {getIngestedFilesCount(websiteFiles) === 1 + ? "file" + : "files"}{" "} + ingested + + )} + +
+
+ { - setBlockPolling(false); - }} + startPolling={startPolling} + knowledge={localFiles} + getKnowledgeFiles={getKnowledgeFiles} + /> + + +
); } - -function renderStatusIcon(status?: KnowledgeIngestionStatus) { - if (!status || !status.status) return null; - const [Icon, className] = ingestionIcons[status.status]; - - return ( - - - -
- {Icon === LoadingSpinner ? ( - - ) : ( - - )} -
-
- - {getMessage(status.status, status.msg, status.error)} - -
-
- ); -} - -const ingestionIcons = { - [IngestionStatus.Queued]: [LoadingSpinner, ""], - [IngestionStatus.Finished]: [CheckIcon, "text-green-500"], - [IngestionStatus.Completed]: [LoadingSpinner, ""], - [IngestionStatus.Skipped]: [CheckIcon, "text-green-500"], - [IngestionStatus.Starting]: [LoadingSpinner, ""], - [IngestionStatus.Failed]: [XCircleIcon, "text-destructive"], - [IngestionStatus.Unsupported]: [Info, "text-yellow-500"], -} as const; diff --git a/ui/admin/app/components/knowledge/FileItem.tsx b/ui/admin/app/components/knowledge/FileItem.tsx index de5df0542..f8aad5845 100644 --- a/ui/admin/app/components/knowledge/FileItem.tsx +++ b/ui/admin/app/components/knowledge/FileItem.tsx @@ -1,5 +1,7 @@ -import { FileIcon, XIcon } from "lucide-react"; +import { FileIcon, PlusIcon, XIcon } from "lucide-react"; +import { useState } from "react"; +import { KnowledgeFile } from "~/lib/model/knowledge"; import { cn } from "~/lib/utils"; import { TypographyP } from "~/components/Typography"; @@ -12,26 +14,28 @@ import { } from "~/components/ui/tooltip"; import { LoadingSpinner } from "../ui/LoadingSpinner"; +import FileStatusIcon from "./FileStatusIcon"; type FileItemProps = { - fileName: string; + file: KnowledgeFile; onAction?: () => void; actionIcon?: React.ReactNode; isLoading?: boolean; error?: string; - statusIcon?: React.ReactNode; + approveFile: (file: KnowledgeFile, approved: boolean) => void; } & React.HTMLAttributes; function FileItem({ - fileName, className, + file, onAction, actionIcon, isLoading, error, - statusIcon, + approveFile, ...props }: FileItemProps) { + const [isApproved, setIsApproved] = useState(file.approved); return ( @@ -42,9 +46,10 @@ function FileItem({ className={cn( "flex justify-between flex-nowrap items-center gap-4 rounded-lg px-2 border w-full", { - "bg-destructive-background border-destructive text-foreground cursor-pointer": + "bg-destructive-background border-destructive text-foreground": error, - "grayscale opacity-60": isLoading, + "grayscale opacity-60": + isLoading || !isApproved, }, className )} @@ -54,11 +59,33 @@ function FileItem({
- {fileName} + {file?.fileName}
- {statusIcon} + {isApproved ? ( + + ) : ( + + )} {isLoading ? ( - ) : ( - onAction && ( +
+ {file.approved ? ( - ) - )} + ) : ( + + )} +
diff --git a/ui/admin/app/components/knowledge/RemoteKnowledgeSourceStatus.tsx b/ui/admin/app/components/knowledge/RemoteKnowledgeSourceStatus.tsx index 1d78b58b1..870cec364 100644 --- a/ui/admin/app/components/knowledge/RemoteKnowledgeSourceStatus.tsx +++ b/ui/admin/app/components/knowledge/RemoteKnowledgeSourceStatus.tsx @@ -8,21 +8,24 @@ import RemoteFileAvatar from "./RemoteFileAvatar"; interface RemoteKnowledgeSourceStatusProps { source: RemoteKnowledgeSource; + includeAvatar?: boolean; } const RemoteKnowledgeSourceStatus: React.FC< RemoteKnowledgeSourceStatusProps -> = ({ source }) => { - if (!source) return null; +> = ({ source, includeAvatar = true }) => { + if (!source || !source.runID) return null; + + if (source.sourceType === "onedrive" && !source.onedriveConfig) return null; + return ( -
+
- + {includeAvatar && ( + + )} {source?.status || "Syncing Files..."} diff --git a/ui/admin/app/components/knowledge/RemoteSourceSettingModal.tsx b/ui/admin/app/components/knowledge/RemoteSourceSettingModal.tsx new file mode 100644 index 000000000..59a37d31c --- /dev/null +++ b/ui/admin/app/components/knowledge/RemoteSourceSettingModal.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useState } from "react"; + +import { RemoteKnowledgeSource } from "~/lib/model/knowledge"; +import { KnowledgeService } from "~/lib/service/api/knowledgeService"; + +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { Switch } from "~/components/ui/switch"; +import { useAsync } from "~/hooks/useAsync"; + +type RemoteSourceSettingModalProps = { + agentId: string; + isOpen: boolean; + onOpenChange: (open: boolean) => void; + remoteKnowledgeSource: RemoteKnowledgeSource; +}; + +const RemoteSourceSettingModal: React.FC = ({ + agentId, + isOpen, + onOpenChange, + remoteKnowledgeSource, +}) => { + const [autoApprove, setAutoApprove] = useState(false); + + useEffect(() => { + setAutoApprove(remoteKnowledgeSource?.autoApprove || false); + }, [remoteKnowledgeSource]); + + const [syncSchedule, setSyncSchedule] = useState(""); + + useEffect(() => { + setSyncSchedule(remoteKnowledgeSource?.syncSchedule || ""); + }, [remoteKnowledgeSource]); + + const updateRemoteKnowledgeSource = async () => { + await KnowledgeService.updateRemoteKnowledgeSource( + agentId, + remoteKnowledgeSource.id, + { + ...remoteKnowledgeSource, + syncSchedule, + autoApprove, + } + ); + onOpenChange(false); + }; + + const handleSave = useAsync(updateRemoteKnowledgeSource); + + return ( + + + + Update Source Settings + +
+ + setSyncSchedule(e.target.value)} + placeholder="Enter cron syntax" + className="w-full mt-2 mb-4" + /> +
+

+ You can use a cron syntax to define the sync + schedule. For example, "0 0 * * *" means + every day at midnight. +

+
+
+
+
+
+ setAutoApprove((prev) => !prev)} + /> + Include new pages +
+

+ If enabled, new pages will be added to the knowledge + base automatically. +

+
+ + + +
+
+ ); +}; + +export default RemoteSourceSettingModal; diff --git a/ui/admin/app/components/knowledge/file/FileModal.tsx b/ui/admin/app/components/knowledge/file/FileModal.tsx new file mode 100644 index 000000000..d55bfe5e3 --- /dev/null +++ b/ui/admin/app/components/knowledge/file/FileModal.tsx @@ -0,0 +1,177 @@ +import { UploadIcon } from "lucide-react"; +import { useCallback, useRef } from "react"; +import { SWRResponse } from "swr"; + +import { IngestionStatus, KnowledgeFile } from "~/lib/model/knowledge"; +import { KnowledgeService } from "~/lib/service/api/knowledgeService"; +import { cn } from "~/lib/utils"; + +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import { useAsync } from "~/hooks/useAsync"; +import { useMultiAsync } from "~/hooks/useMultiAsync"; + +import { FileChip } from "../FileItem"; +import IngestionStatusComponent from "../IngestionStatus"; + +interface FileModalProps { + agentId: string; + getKnowledgeFiles: SWRResponse; + isOpen: boolean; + onOpenChange: (open: boolean) => void; + startPolling: () => void; + knowledge: KnowledgeFile[]; +} + +function FileModal({ + agentId, + getKnowledgeFiles, + startPolling, + knowledge, + isOpen, + onOpenChange, +}: FileModalProps) { + const fileInputRef = useRef(null); + + const handleAddKnowledge = useCallback( + async (_index: number, file: File) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + await KnowledgeService.addKnowledgeToAgent(agentId, file); + + // once added, we can immediately mutate the cache value + // without revalidating. + // Revalidating here would cause knowledge to be refreshed + // for each file being uploaded, which is not desirable. + const newItem: KnowledgeFile = { + id: "", + fileName: file.name, + agentID: agentId, + // set ingestion status to starting to ensure polling is enabled + ingestionStatus: { status: IngestionStatus.Queued }, + fileDetails: {}, + approved: true, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getKnowledgeFiles.mutate( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (prev: any) => { + const existingItemIndex = prev?.findIndex( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (item: any) => item.fileName === newItem.fileName + ); + if (existingItemIndex !== -1 && prev) { + const updatedPrev = [...prev]; + updatedPrev[existingItemIndex!] = newItem; + return updatedPrev; + } else { + return [newItem, ...(prev || [])]; + } + }, + { + revalidate: false, + } + ); + startPolling(); + }, + [agentId, getKnowledgeFiles, startPolling] + ); + + // use multi async to handle uploading multiple files at once + const uploadKnowledge = useMultiAsync(handleAddKnowledge); + + const startUpload = (files: FileList) => { + if (!files.length) return; + + uploadKnowledge.execute( + Array.from(files).map((file) => [file] as const) + ); + + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const deleteKnowledge = useAsync(async (item: KnowledgeFile) => { + await KnowledgeService.deleteKnowledgeFromAgent(agentId, item.fileName); + + // optomistic update without cache revalidation + getKnowledgeFiles.mutate((prev: KnowledgeFile[] | undefined) => + prev?.filter((prevItem) => prevItem.fileName !== item.fileName) + ); + }); + + return ( + + + + Manage Files + + + +
+ {knowledge?.map((item) => ( + { + await KnowledgeService.approveKnowledgeFile( + agentId, + file.id!, + approved + ); + startPolling(); + }} + onAction={() => deleteKnowledge.execute(item)} + isLoading={ + deleteKnowledge.isLoading && + deleteKnowledge.lastCallParams?.[0] + .fileName === item.fileName + } + /> + ))} +
+
+ {knowledge.some((item) => item.approved) && ( + + )} + + { + if (!e.target.files) return; + startUpload(e.target.files); + }} + /> + + +
+
+ ); +} + +export default FileModal; diff --git a/ui/admin/app/components/knowledge/notion/NotionModal.tsx b/ui/admin/app/components/knowledge/notion/NotionModal.tsx index cb5f26d0c..662a60dd8 100644 --- a/ui/admin/app/components/knowledge/notion/NotionModal.tsx +++ b/ui/admin/app/components/knowledge/notion/NotionModal.tsx @@ -1,70 +1,65 @@ -import { FC, useEffect, useState } from "react"; +import { RefreshCcwIcon, SettingsIcon } from "lucide-react"; +import { FC, useState } from "react"; -import { RemoteKnowledgeSource } from "~/lib/model/knowledge"; +import { + KnowledgeFile, + RemoteKnowledgeSource, + RemoteKnowledgeSourceType, +} from "~/lib/model/knowledge"; import { KnowledgeService } from "~/lib/service/api/knowledgeService"; +import { assetUrl } from "~/lib/utils"; +import RemoteFileItemChip from "~/components/knowledge/RemoteFileItemChip"; import RemoteKnowledgeSourceStatus from "~/components/knowledge/RemoteKnowledgeSourceStatus"; +import RemoteSourceSettingModal from "~/components/knowledge/RemoteSourceSettingModal"; +import { LoadingSpinner } from "~/components/ui/LoadingSpinner"; +import { Avatar } from "~/components/ui/avatar"; import { Button } from "~/components/ui/button"; import { Dialog, DialogContent, - DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog"; -import { Input } from "~/components/ui/input"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "~/components/ui/table"; +import { ScrollArea } from "~/components/ui/scroll-area"; + +import IngestionStatusComponent from "../IngestionStatus"; type NotionModalProps = { agentId: string; isOpen: boolean; onOpenChange: (open: boolean) => void; - startPolling: () => void; remoteKnowledgeSources: RemoteKnowledgeSource[]; + knowledgeFiles: KnowledgeFile[]; + startPolling: () => void; + handleRemoteKnowledgeSourceSync: ( + knowledgeSourceType: RemoteKnowledgeSourceType + ) => void; }; export const NotionModal: FC = ({ agentId, isOpen, onOpenChange, - startPolling, remoteKnowledgeSources, + knowledgeFiles, + startPolling, + handleRemoteKnowledgeSourceSync, }) => { - const [selectedPages, setSelectedPages] = useState([]); + const [loading, setLoading] = useState(false); + const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); const notionSource = remoteKnowledgeSources.find( (source) => source.sourceType === "notion" ); - useEffect(() => { - setSelectedPages(notionSource?.notionConfig?.pages || []); - }, [notionSource]); - - const handleSave = async () => { - if (!notionSource) { - return; + const handleApproveAll = async () => { + for (const file of knowledgeFiles) { + await KnowledgeService.approveKnowledgeFile(agentId, file.id, true); } - await KnowledgeService.updateRemoteKnowledgeSource( - agentId, - notionSource.id, - { - sourceType: "notion", - notionConfig: { - pages: selectedPages, - }, - exclude: notionSource.exclude, - } - ); startPolling(); - onOpenChange(false); }; + return ( = ({ className="bd-secondary data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[900px] translate-x-[-50%] translate-y-[-50%] rounded-[6px] bg-white dark:bg-secondary p-[25px] shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] focus:outline-none" > - Select Notion Pages + +
+ + Notion logo + + Notion +
+ +
+ + +
+
-
- {notionSource?.state?.notionState?.pages && - !notionSource.runID ? ( - - - - - Pages - - - - - {Object.entries( - notionSource?.state?.notionState?.pages || - {} - ) - .sort(([, pageA], [, pageB]) => - (pageA?.folderPath || "").localeCompare( - pageB?.folderPath || "" - ) - ) - .map(([id, page]) => ( - - - setSelectedPages( - (prevSelectedPages) => - prevSelectedPages.includes( - id - ) - ? prevSelectedPages.filter( - ( - pageId - ) => - pageId !== - id - ) - : [ - ...prevSelectedPages, - id, - ] - ) - } - > - - setSelectedPages( - ( - prevSelectedPages - ) => - prevSelectedPages.includes( - id - ) - ? prevSelectedPages.filter( - ( - pageId - ) => - pageId !== - id - ) - : [ - ...prevSelectedPages, - id, - ] - ) - } - className="mr-3 h-4 w-4" - onClick={(e) => - e.stopPropagation() - } - /> - - - - ))} - -
- ) : ( - - )} + +
+ {knowledgeFiles.map((item) => ( + { + await KnowledgeService.approveKnowledgeFile( + agentId, + file.id, + approved + ); + startPolling(); + }} + /> + ))} +
+
+ {knowledgeFiles?.some((item) => item.approved) && ( + + )} + {notionSource?.runID && ( + + )} +
+ +
- - - + {notionSource && ( + <> + + + )}
); }; diff --git a/ui/admin/app/components/knowledge/onedrive/AddLinkModal.tsx b/ui/admin/app/components/knowledge/onedrive/AddLinkModal.tsx new file mode 100644 index 000000000..e83db93f6 --- /dev/null +++ b/ui/admin/app/components/knowledge/onedrive/AddLinkModal.tsx @@ -0,0 +1,87 @@ +import { Plus } from "lucide-react"; +import { FC, useState } from "react"; + +import { RemoteKnowledgeSource } from "~/lib/model/knowledge"; +import { KnowledgeService } from "~/lib/service/api/knowledgeService"; + +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; + +type AddLinkModalProps = { + agentId: string; + onedriveSource: RemoteKnowledgeSource; + startPolling: () => void; + isOpen: boolean; + onOpenChange: (open: boolean) => void; +}; + +const AddLinkModal: FC = ({ + agentId, + onedriveSource, + startPolling, + isOpen, + onOpenChange, +}) => { + const [newLink, setNewLink] = useState(""); + + const handleSave = async () => { + if (!onedriveSource) return; + + await KnowledgeService.updateRemoteKnowledgeSource( + agentId, + onedriveSource!.id, + { + ...onedriveSource, + onedriveConfig: { + sharedLinks: [ + ...(onedriveSource.onedriveConfig?.sharedLinks || []), + newLink, + ], + }, + } + ); + startPolling(); + onOpenChange(false); + }; + + return ( + + + + + Add OneDrive Link + + +
+ setNewLink(e.target.value)} + placeholder="Enter OneDrive link" + className="w-full mb-4" + /> + +
+ + + +
+
+ ); +}; + +export default AddLinkModal; diff --git a/ui/admin/app/components/knowledge/onedrive/OneDriveModal.tsx b/ui/admin/app/components/knowledge/onedrive/OneDriveModal.tsx index 435bfd95a..9f45b2733 100644 --- a/ui/admin/app/components/knowledge/onedrive/OneDriveModal.tsx +++ b/ui/admin/app/components/knowledge/onedrive/OneDriveModal.tsx @@ -1,40 +1,62 @@ -import { Plus, X } from "lucide-react"; +import { + ChevronDown, + ChevronUp, + FileIcon, + FolderIcon, + RefreshCcwIcon, + SettingsIcon, + Trash, + UploadIcon, +} from "lucide-react"; import { FC, useEffect, useState } from "react"; -import { RemoteKnowledgeSource } from "~/lib/model/knowledge"; +import { + KnowledgeFile, + RemoteKnowledgeSource, + RemoteKnowledgeSourceType, +} from "~/lib/model/knowledge"; import { KnowledgeService } from "~/lib/service/api/knowledgeService"; +import { assetUrl } from "~/lib/utils"; import RemoteKnowledgeSourceStatus from "~/components/knowledge/RemoteKnowledgeSourceStatus"; +import { LoadingSpinner } from "~/components/ui/LoadingSpinner"; +import { Avatar } from "~/components/ui/avatar"; import { Button } from "~/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "~/components/ui/dialog"; -import { Input } from "~/components/ui/input"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "~/components/ui/table"; +import { ScrollArea } from "~/components/ui/scroll-area"; + +import IngestionStatusComponent from "../IngestionStatus"; +import RemoteFileItemChip from "../RemoteFileItemChip"; +import RemoteSourceSettingModal from "../RemoteSourceSettingModal"; +import AddLinkModal from "./AddLinkModal"; interface OnedriveModalProps { agentId: string; isOpen: boolean; onOpenChange: (open: boolean) => void; - startPolling: () => void; remoteKnowledgeSources: RemoteKnowledgeSource[]; + startPolling: () => void; + knowledgeFiles: KnowledgeFile[]; + handleRemoteKnowledgeSourceSync: ( + sourceType: RemoteKnowledgeSourceType + ) => void; } export const OnedriveModal: FC = ({ agentId, isOpen, onOpenChange, - startPolling, remoteKnowledgeSources, + startPolling, + knowledgeFiles, + handleRemoteKnowledgeSourceSync, }) => { + const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); + const [isAddLinkModalOpen, setIsAddLinkModalOpen] = useState(false); + const [loading, setLoading] = useState(false); const [links, setLinks] = useState([]); - const [newLink, setNewLink] = useState(""); - const [exclude, setExclude] = useState([]); + const [showTable, setShowTable] = useState<{ [key: number]: boolean }>({}); + const onedriveSource = remoteKnowledgeSources.find( (source) => source.sourceType === "onedrive" ); @@ -43,214 +65,306 @@ export const OnedriveModal: FC = ({ setLinks(onedriveSource?.onedriveConfig?.sharedLinks || []); }, [onedriveSource]); - const handleAddLink = () => { - if (newLink) { - handleSave([...links, newLink], false); - setLinks([...links, newLink]); - setNewLink(""); - } - }; - const handleRemoveLink = (index: number) => { setLinks(links.filter((_, i) => i !== index)); - handleSave( - links.filter((_, i) => i !== index), - false - ); + handleSave(links.filter((_, i) => i !== index)); }; - const handleSave = async (links: string[], ingest: boolean) => { - const remoteKnowledgeSources = - await KnowledgeService.getRemoteKnowledgeSource(agentId); - const onedriveSource = remoteKnowledgeSources.find( - (source) => source.sourceType === "onedrive" - ); - if (!onedriveSource) { - await KnowledgeService.createRemoteKnowledgeSource(agentId, { - sourceType: "onedrive", + const handleSave = async (links: string[]) => { + await KnowledgeService.updateRemoteKnowledgeSource( + agentId, + onedriveSource!.id!, + { + ...onedriveSource, onedriveConfig: { sharedLinks: links, }, - disableIngestionAfterSync: !ingest, - }); - } else { - const knowledge = - await KnowledgeService.getKnowledgeForAgent(agentId); - for (const file of knowledge) { - if (file.uploadID && exclude.includes(file.uploadID)) { - await KnowledgeService.deleteKnowledgeFromAgent( - agentId, - file.fileName - ); - } } - await KnowledgeService.updateRemoteKnowledgeSource( - agentId, - onedriveSource.id, - { - sourceType: "onedrive", - onedriveConfig: { - sharedLinks: links, - }, - exclude: exclude, - disableIngestionAfterSync: !ingest, - } - ); - } + ); startPolling(); - if (ingest) { - await KnowledgeService.triggerKnowledgeIngestion(agentId); - onOpenChange(false); - } }; - const handleTogglePageSelection = (url: string) => { - if (exclude.includes(url)) { - setExclude(exclude.filter((u) => u !== url)); - } else { - setExclude([...exclude, url]); - } - }; - - const handleClose = async (open: boolean) => { - if (!open && onedriveSource) { - await KnowledgeService.updateRemoteKnowledgeSource( + const handleApproveAll = async () => { + for (const file of knowledgeFiles) { + await KnowledgeService.approveKnowledgeFile( agentId, - onedriveSource.id, - { - sourceType: "onedrive", - onedriveConfig: { - sharedLinks: onedriveSource.onedriveConfig?.sharedLinks, - }, - exclude: onedriveSource.exclude, - } + file.id!, + true ); - await KnowledgeService.triggerKnowledgeIngestion(agentId); } - onOpenChange(open); + startPolling(); }; return ( - + - - Add OneDrive Links - -
- setNewLink(e.target.value)} - placeholder="Enter OneDrive link" - className="w-full mb-2" - /> - -
-
- {links.map((link, index) => ( -
+
+ + OneDrive logo + + OneDrive +
+
+ -
- ))} -
-
- {links.length > 0 && - Object.keys( - onedriveSource?.state?.onedriveState?.files || {} - ).length > 0 && - !onedriveSource?.runID ? ( - <> - - - - - Files - - - - - {Object.entries( - onedriveSource?.state?.onedriveState - ?.files || {} - ).map(([fileID, file], index: number) => ( - - handleTogglePageSelection( - fileID - ) - } - > - - - handleTogglePageSelection( - fileID - ) - } - onClick={(e) => - e.stopPropagation() - } + + + + + + + +
+ {links.map((link, index) => ( +
+ {/* eslint-disable-next-line */} +
{ + if ( + showTable[index] === undefined || + showTable[index] === false + ) { + setShowTable((prev) => ({ + ...prev, + [index]: true, + })); + } else { + setShowTable((prev) => ({ + ...prev, + [index]: false, + })); + } + }} + > + + {onedriveSource?.state?.onedriveState + ?.links?.[link]?.name ? ( + onedriveSource?.state?.onedriveState + ?.links?.[link]?.isFolder ? ( + + ) : ( + + ) + ) : ( + + OneDrive logo - - - - e.stopPropagation() - } - > - {file.fileName} - - {file.folderPath && ( - <> -
- - {file.folderPath} - - - )} -
- - ))} - -
- - ) : ( - - )} -
-
- + {onedriveSource?.state?.onedriveState + ?.links?.[link]?.isFolder && + (showTable[index] ? ( + + ) : ( + + ))} +
+ {showTable[index] && ( + +
+ {knowledgeFiles + + .filter((item) => + onedriveSource?.state?.onedriveState?.files?.[ + item.uploadID! + ]?.folderPath?.startsWith( + // eslint-disable-next-line + onedriveSource?.state + ?.onedriveState + ?.links?.[link] + ?.name! + ) + ) + .map((item) => ( + { + await KnowledgeService.approveKnowledgeFile( + agentId, + file.id!, + approved + ); + startPolling(); + }} + /> + ))} +
+
+ )} +
+ ))} +
+ {knowledgeFiles + .filter( + (item) => + !links.some( + (link) => + onedriveSource?.state?.onedriveState?.files?.[ + item.uploadID! + ]?.folderPath?.startsWith( + onedriveSource?.state + ?.onedriveState + ?.links?.[link]?.name ?? + "" + ) ?? false + ) + ) + .map((item) => ( + { + await KnowledgeService.approveKnowledgeFile( + agentId, + file.id!, + approved + ); + startPolling(); + }} + /> + ))} +
+
+ + {knowledgeFiles?.some((item) => item.approved) && ( + + )} + {onedriveSource?.runID && ( + + )} + +
+ +
+ {onedriveSource && ( + <> + + + + )} ); diff --git a/ui/admin/app/components/knowledge/website/AddWebsiteModal.tsx b/ui/admin/app/components/knowledge/website/AddWebsiteModal.tsx new file mode 100644 index 000000000..3a3cd416d --- /dev/null +++ b/ui/admin/app/components/knowledge/website/AddWebsiteModal.tsx @@ -0,0 +1,78 @@ +import { Plus } from "lucide-react"; +import { FC, useState } from "react"; + +import { RemoteKnowledgeSource } from "~/lib/model/knowledge"; +import { KnowledgeService } from "~/lib/service/api/knowledgeService"; + +import { Button } from "~/components/ui/button"; +import { Dialog, DialogContent, DialogTitle } from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; + +interface AddWebsiteModalProps { + agentId: string; + websiteSource: RemoteKnowledgeSource; + startPolling: () => void; + isOpen: boolean; + onOpenChange: (open: boolean) => void; +} + +const AddWebsiteModal: FC = ({ + agentId, + websiteSource, + startPolling, + isOpen, + onOpenChange, +}) => { + const [newWebsite, setNewWebsite] = useState(""); + + const handleAddWebsite = async () => { + if (newWebsite) { + const formattedWebsite = + newWebsite.startsWith("http://") || + newWebsite.startsWith("https://") + ? newWebsite + : `https://${newWebsite}`; + await KnowledgeService.updateRemoteKnowledgeSource( + agentId, + websiteSource.id!, + { + sourceType: "website", + websiteCrawlingConfig: { + urls: [ + ...(websiteSource.websiteCrawlingConfig?.urls || + []), + formattedWebsite, + ], + }, + } + ); + startPolling(); + setNewWebsite(""); + onOpenChange(false); + } + }; + + return ( + + + + Add Website + +
+ setNewWebsite(e.target.value)} + placeholder="Enter website URL" + className="w-full mb-2 dark:bg-secondary" + /> + +
+
+
+ ); +}; + +export default AddWebsiteModal; diff --git a/ui/admin/app/components/knowledge/website/WebsiteModal.tsx b/ui/admin/app/components/knowledge/website/WebsiteModal.tsx index ba29801c8..872f858e5 100644 --- a/ui/admin/app/components/knowledge/website/WebsiteModal.tsx +++ b/ui/admin/app/components/knowledge/website/WebsiteModal.tsx @@ -1,113 +1,80 @@ -import { Plus, X } from "lucide-react"; +import { + ChevronDown, + ChevronUp, + Globe, + RefreshCcwIcon, + SettingsIcon, + Trash, + UploadIcon, +} from "lucide-react"; import { FC, useEffect, useState } from "react"; -import { RemoteKnowledgeSource } from "~/lib/model/knowledge"; +import { + KnowledgeFile, + RemoteKnowledgeSource, + RemoteKnowledgeSourceType, +} from "~/lib/model/knowledge"; import { KnowledgeService } from "~/lib/service/api/knowledgeService"; +import { LoadingSpinner } from "~/components/ui/LoadingSpinner"; +import { Avatar } from "~/components/ui/avatar"; import { Button } from "~/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "~/components/ui/dialog"; -import { Input } from "~/components/ui/input"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "~/components/ui/table"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import IngestionStatusComponent from "../IngestionStatus"; +import RemoteFileItemChip from "../RemoteFileItemChip"; import RemoteKnowledgeSourceStatus from "../RemoteKnowledgeSourceStatus"; +import RemoteSourceSettingModal from "../RemoteSourceSettingModal"; +import AddWebsiteModal from "./AddWebsiteModal"; interface WebsiteModalProps { agentId: string; isOpen: boolean; onOpenChange: (open: boolean) => void; - startPolling: () => void; remoteKnowledgeSources: RemoteKnowledgeSource[]; + startPolling: () => void; + knowledgeFiles: KnowledgeFile[]; + handleRemoteKnowledgeSourceSync: ( + sourceType: RemoteKnowledgeSourceType + ) => void; } export const WebsiteModal: FC = ({ agentId, isOpen, onOpenChange, - startPolling, remoteKnowledgeSources, + startPolling, + knowledgeFiles, + handleRemoteKnowledgeSourceSync, }) => { + const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); + const [isAddWebsiteModalOpen, setIsAddWebsiteModalOpen] = useState(false); + const [loading, setLoading] = useState(false); const [websites, setWebsites] = useState([]); - const [newWebsite, setNewWebsite] = useState(""); - const [exclude, setExclude] = useState([]); + const [showTable, setShowTable] = useState<{ [key: number]: boolean }>({}); const websiteSource = remoteKnowledgeSources.find( (source) => source.sourceType === "website" ); useEffect(() => { - setExclude(websiteSource?.exclude || []); - setWebsites(websiteSource?.websiteCrawlingConfig?.urls || []); - }, [websiteSource]); + setWebsites(websiteSource?.websiteCrawlingConfig?.urls ?? []); + }, [websiteSource?.websiteCrawlingConfig]); - const handleSave = async (websites: string[], ingest: boolean = false) => { - const remoteKnowledgeSources = - await KnowledgeService.getRemoteKnowledgeSource(agentId); - let websiteSource = remoteKnowledgeSources.find( - (source) => source.sourceType === "website" - ); - if (!websiteSource) { - websiteSource = await KnowledgeService.createRemoteKnowledgeSource( - agentId, - { - sourceType: "website", - websiteCrawlingConfig: { - urls: websites, - }, - disableIngestionAfterSync: !ingest, - } - ); - } else { - const knowledge = - await KnowledgeService.getKnowledgeForAgent(agentId); - for (const file of knowledge) { - if (file.uploadID && exclude.includes(file.uploadID)) { - await KnowledgeService.deleteKnowledgeFromAgent( - agentId, - file.fileName - ); - } + const handleSave = async (websites: string[]) => { + await KnowledgeService.updateRemoteKnowledgeSource( + agentId, + websiteSource!.id!, + { + ...websiteSource, + websiteCrawlingConfig: { + urls: websites, + }, } - await KnowledgeService.updateRemoteKnowledgeSource( - agentId, - websiteSource.id, - { - sourceType: "website", - websiteCrawlingConfig: { - urls: websites, - }, - exclude: exclude, - disableIngestionAfterSync: !ingest, - } - ); - } + ); startPolling(); - if (ingest) { - await KnowledgeService.triggerKnowledgeIngestion(agentId); - onOpenChange(false); - } - }; - - const handleAddWebsite = async () => { - if (newWebsite) { - const formattedWebsite = - newWebsite.startsWith("http://") || - newWebsite.startsWith("https://") - ? newWebsite - : `https://${newWebsite}`; - setWebsites((prevWebsites) => { - const updatedWebsites = [...prevWebsites, formattedWebsite]; - handleSave(updatedWebsites); - return updatedWebsites; - }); - setNewWebsite(""); - } }; const handleRemoveWebsite = async (index: number) => { @@ -115,155 +82,202 @@ export const WebsiteModal: FC = ({ await handleSave(websites.filter((_, i) => i !== index)); }; - useEffect(() => { - const fetchWebsites = async () => { - const remoteKnowledgeSources = - await KnowledgeService.getRemoteKnowledgeSource(agentId); - const websiteSource = remoteKnowledgeSources.find( - (source) => source.sourceType === "website" - ); - setWebsites(websiteSource?.websiteCrawlingConfig?.urls || []); - }; - - fetchWebsites(); - }, [agentId]); - - const handleTogglePageSelection = (url: string) => { - setExclude((prev) => - prev.includes(url) - ? prev.filter((item) => item !== url) - : [...prev, url] - ); - }; - - const handleClose = async (open: boolean) => { - if (!open && websiteSource) { - await KnowledgeService.updateRemoteKnowledgeSource( + const handleApproveAll = async () => { + for (const file of knowledgeFiles) { + await KnowledgeService.approveKnowledgeFile( agentId, - websiteSource.id, - { - sourceType: "website", - websiteCrawlingConfig: { - urls: websiteSource.websiteCrawlingConfig?.urls, - }, - exclude: websiteSource.exclude, - disableIngestionAfterSync: false, - } + file.id!, + true ); - await KnowledgeService.triggerKnowledgeIngestion(agentId); } - onOpenChange(open); + startPolling(); }; return ( - + - - Add Website URLs - -
- setNewWebsite(e.target.value)} - placeholder="Enter website URL" - className="w-full mb-2 dark:bg-secondary" - /> - -
-
- {websites.map((website, index) => ( -
+
+ + + + Website +
+
+ + + +
+ + +
+ {websites.map((website, index) => ( + - - -
- ))} -
-
- {websites.length > 0 && - Object.keys( - websiteSource?.state?.websiteCrawlingState?.pages || {} - ).length > 0 && - !websiteSource?.runID ? ( - <> - - - - - Pages - - - - - {Object.keys( - websiteSource?.state - ?.websiteCrawlingState?.pages || {} - ).map((url, index: number) => ( - - handleTogglePageSelection(url) - } - > - - - handleTogglePageSelection( - url - ) - } - onClick={(e) => - e.stopPropagation() + {/* eslint-disable-next-line */} +
{ + if ( + showTable[index] === undefined || + showTable[index] === false + ) { + setShowTable((prev) => ({ + ...prev, + [index]: true, + })); + } else { + setShowTable((prev) => ({ + ...prev, + [index]: false, + })); + } + }} + > + + + + + {showTable[index] ? ( + + ) : ( + + )} +
+ {showTable[index] && ( +
+ {knowledgeFiles + .filter( + (item) => + websiteSource?.state + ?.websiteCrawlingState + ?.pages?.[ + item.uploadID! + ]?.parentUrl === website + ) + .map((item) => ( + { + await KnowledgeService.approveKnowledgeFile( + agentId, + file.id!, + approved + ); + startPolling(); + }} /> - - - - e.stopPropagation() - } - > - {url} - - - - ))} - -
- - ) : ( - - )} -
-
-
+ )} + + ))} +
+ + + {knowledgeFiles?.some((item) => item.approved) && ( + + )} + {websiteSource?.runID && ( + + )} +
+ +
+ {websiteSource && ( + <> + + + + )}
); diff --git a/ui/admin/app/components/ui/checkbox.tsx b/ui/admin/app/components/ui/checkbox.tsx new file mode 100644 index 000000000..cd913aa39 --- /dev/null +++ b/ui/admin/app/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { CheckIcon } from "@radix-ui/react-icons"; +import * as React from "react"; + +import { cn } from "~/lib/utils"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/ui/admin/app/lib/model/knowledge.ts b/ui/admin/app/lib/model/knowledge.ts index 7a36f2a28..914f39667 100644 --- a/ui/admin/app/lib/model/knowledge.ts +++ b/ui/admin/app/lib/model/knowledge.ts @@ -48,9 +48,9 @@ export type RemoteKnowledgeSource = { } & RemoteKnowledgeSourceInput; export type RemoteKnowledgeSourceInput = { + syncSchedule?: string; sourceType?: RemoteKnowledgeSourceType; - exclude?: string[]; - disableIngestionAfterSync?: boolean; + autoApprove?: boolean; onedriveConfig?: OneDriveConfig; notionConfig?: NotionConfig; websiteCrawlingConfig?: WebsiteCrawlingConfig; @@ -77,6 +77,12 @@ type RemoteKnowledgeSourceState = { type OneDriveLinksConnectorState = { folders?: FolderSet; files?: Record; + links?: Record; +}; + +type LinkState = { + name?: string; + isFolder?: boolean; }; type FileState = { @@ -96,11 +102,15 @@ type NotionPage = { }; type WebsiteCrawlingConnectorState = { - pages?: Record; + pages?: Record; scrapeJobIds?: Record; folders?: FolderSet; }; +type PageDetails = { + parentUrl?: string; +}; + type FolderSet = { [key: string]: undefined; }; @@ -112,6 +122,7 @@ type FileDetails = { }; export type KnowledgeFile = { + id: string; deleted?: string; fileName: string; agentID?: string; @@ -122,6 +133,7 @@ export type KnowledgeFile = { ingestionStatus: KnowledgeIngestionStatus; fileDetails: FileDetails; uploadID?: string; + approved?: boolean; }; export function getRemoteFileDisplayName(item: KnowledgeFile) { @@ -165,7 +177,7 @@ export function getMessage( status === IngestionStatus.Finished || status === IngestionStatus.Skipped ) { - return "Completed"; + return "Exclude file from ingestion"; } if (status === IngestionStatus.Failed) { @@ -178,3 +190,11 @@ export function getMessage( return msg || "Queued"; } + +export function getIngestedFilesCount(knowledge: KnowledgeFile[]) { + return knowledge.filter( + (item) => + item.ingestionStatus?.status === IngestionStatus.Finished || + item.ingestionStatus?.status === IngestionStatus.Skipped + ).length; +} diff --git a/ui/admin/app/lib/routers/apiRoutes.ts b/ui/admin/app/lib/routers/apiRoutes.ts index 5b32bfe25..9e388b60a 100644 --- a/ui/admin/app/lib/routers/apiRoutes.ts +++ b/ui/admin/app/lib/routers/apiRoutes.ts @@ -59,6 +59,8 @@ export const ApiRoutes = { buildUrl( `/agents/${agentId}/remote-knowledge-sources/${remoteKnowledgeSourceId}` ), + approveKnowledgeFile: (agentId: string, fileID: string) => + buildUrl(`/agents/${agentId}/knowledge/${fileID}/approve`), }, workflows: { base: () => buildUrl("/workflows"), diff --git a/ui/admin/app/lib/service/api/knowledgeService.ts b/ui/admin/app/lib/service/api/knowledgeService.ts index 7ba1a2c72..04182aa9c 100644 --- a/ui/admin/app/lib/service/api/knowledgeService.ts +++ b/ui/admin/app/lib/service/api/knowledgeService.ts @@ -92,6 +92,19 @@ async function resyncRemoteKnowledgeSource( }); } +async function approveKnowledgeFile( + agentId: string, + fileID: string, + approve: boolean +) { + await request({ + url: ApiRoutes.agents.approveKnowledgeFile(agentId, fileID).url, + method: "PUT", + data: JSON.stringify({ approve }), + errorMessage: "Failed to approve knowledge file", + }); +} + async function getRemoteKnowledgeSource(agentId: string) { const res = await request<{ items: RemoteKnowledgeSource[]; @@ -112,6 +125,7 @@ getRemoteKnowledgeSource.key = (agentId?: Nullish) => { }; export const KnowledgeService = { + approveKnowledgeFile, getKnowledgeForAgent, addKnowledgeToAgent, deleteKnowledgeFromAgent, diff --git a/ui/admin/package-lock.json b/ui/admin/package-lock.json index b10a195ea..0ae21ebb0 100644 --- a/ui/admin/package-lock.json +++ b/ui/admin/package-lock.json @@ -11,6 +11,7 @@ "@hookform/resolvers": "^3.9.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-icons": "^1.3.0", @@ -1804,6 +1805,35 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.2.tgz", + "integrity": "sha512-/i0fl686zaJbDQLNKrkCbMyDm6FQMt4jg323k7HuqitoANm9sE23Ql8yOK3Wusk34HSLKDChhMux05FnP6KUkw==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.1.tgz", diff --git a/ui/admin/package.json b/ui/admin/package.json index e640519f8..3fc6ba9ef 100644 --- a/ui/admin/package.json +++ b/ui/admin/package.json @@ -16,6 +16,7 @@ "@hookform/resolvers": "^3.9.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-icons": "^1.3.0",