diff --git a/internal/ai/rag/index_document_helpers.go b/internal/ai/rag/index_document_helpers.go index 2a8cab8..9e55060 100644 --- a/internal/ai/rag/index_document_helpers.go +++ b/internal/ai/rag/index_document_helpers.go @@ -47,6 +47,7 @@ func (s *index) prepareDocumentVectors(ctx context.Context, knowledgeBase models vectors := make([]vectordb.Vector, 0, len(chunks)) chunkModels := make([]models.KnowledgeChunk, 0, len(chunks)) dimension := 0 + directoryPath := loadKnowledgeDirectoryPath(document.DirectoryID) for i, chunk := range chunks { embeddingResult, err := ai.Embedding.GenerateEmbedding(ctx, chunk.Content) @@ -65,6 +66,7 @@ func (s *index) prepareDocumentVectors(ctx context.Context, knowledgeBase models providerName = value } } + sectionPath := joinKnowledgeSectionPath(directoryPath, chunk.SectionPath) now := time.Now() chunkModels = append(chunkModels, models.KnowledgeChunk{ KnowledgeBaseID: knowledgeBase.ID, @@ -76,7 +78,7 @@ func (s *index) prepareDocumentVectors(ctx context.Context, knowledgeBase models CharCount: chunk.CharCount, TokenCount: chunk.TokenCount, ChunkType: string(chunk.ChunkType), - SectionPath: chunk.SectionPath, + SectionPath: sectionPath, Provider: providerName, VectorID: chunkID, Status: enums.StatusOk, @@ -93,7 +95,7 @@ func (s *index) prepareDocumentVectors(ctx context.Context, knowledgeBase models DocumentTitle: document.Title, ChunkNo: chunk.ChunkNo, ChunkType: string(chunk.ChunkType), - SectionPath: chunk.SectionPath, + SectionPath: sectionPath, Content: chunk.Content, Title: chunk.Title, Provider: providerName, diff --git a/internal/ai/rag/index_faq_helpers.go b/internal/ai/rag/index_faq_helpers.go index 991fed6..7bb58db 100644 --- a/internal/ai/rag/index_faq_helpers.go +++ b/internal/ai/rag/index_faq_helpers.go @@ -14,6 +14,7 @@ import ( func buildFAQChunkModel(knowledgeBase models.KnowledgeBase, faq models.KnowledgeFAQ, content string) (models.KnowledgeChunk, string) { chunkID := buildKnowledgeFAQChunkVectorID(knowledgeBase.ID, faq.ID, 0) now := time.Now() + sectionPath := loadKnowledgeDirectoryPath(faq.DirectoryID) return models.KnowledgeChunk{ KnowledgeBaseID: knowledgeBase.ID, FaqID: faq.ID, @@ -24,6 +25,7 @@ func buildFAQChunkModel(knowledgeBase models.KnowledgeBase, faq models.Knowledge CharCount: len([]rune(content)), TokenCount: len([]rune(content)) / 2, ChunkType: string(enums.KnowledgeChunkTypeFAQ), + SectionPath: sectionPath, Provider: string(enums.KnowledgeChunkProviderFAQ), VectorID: chunkID, Status: enums.StatusOk, @@ -39,6 +41,7 @@ func (s *index) prepareFAQVector(ctx context.Context, knowledgeBase models.Knowl } chunkModel, chunkID := buildFAQChunkModel(knowledgeBase, faq, content) + sectionPath := chunkModel.SectionPath vector := vectordb.Vector{ ID: chunkID, Vector: embeddingResult.Vector, @@ -48,6 +51,7 @@ func (s *index) prepareFAQVector(ctx context.Context, knowledgeBase models.Knowl FaqQuestion: faq.Question, ChunkNo: 0, ChunkType: string(enums.KnowledgeChunkTypeFAQ), + SectionPath: sectionPath, Content: content, Title: faq.Question, Provider: string(enums.KnowledgeChunkProviderFAQ), diff --git a/internal/ai/rag/index_flow.go b/internal/ai/rag/index_flow.go index f63e00e..8e97384 100644 --- a/internal/ai/rag/index_flow.go +++ b/internal/ai/rag/index_flow.go @@ -2,6 +2,7 @@ package rag import ( "fmt" + "strings" "agent-desk/internal/models" "agent-desk/internal/pkg/enums" @@ -18,6 +19,35 @@ func (s *index) loadDocumentByID(documentID int64) (*models.KnowledgeDocument, e return document, nil } +func loadKnowledgeDirectoryPath(directoryID int64) string { + if directoryID <= 0 { + return "" + } + item := repositories.KnowledgeDirectoryRepository.Get(sqls.DB(), directoryID) + if item == nil { + return "" + } + if item.ParentID <= 0 { + return item.Name + } + parent := repositories.KnowledgeDirectoryRepository.Get(sqls.DB(), item.ParentID) + if parent == nil { + return item.Name + } + return parent.Name + " / " + item.Name +} + +func joinKnowledgeSectionPath(parts ...string) string { + ret := make([]string, 0, len(parts)) + for _, item := range parts { + item = strings.TrimSpace(item) + if item != "" { + ret = append(ret, item) + } + } + return strings.Join(ret, " / ") +} + func (s *index) loadFAQByID(faqID int64) (*models.KnowledgeFAQ, error) { faq := repositories.KnowledgeFAQRepository.Get(sqls.DB(), faqID) if faq == nil { diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 4e81ef6..be5a45e 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -253,6 +253,14 @@ func registerDashboardKnowledgeBaseRoutes(group *gin.RouterGroup) { group.POST("/update_sort", dashboard.KnowledgeBasePostUpdate_sort) } +func registerDashboardKnowledgeDirectoryRoutes(group *gin.RouterGroup) { + group.GET("/list_all", dashboard.KnowledgeDirectoryGetList_all) + group.POST("/create", dashboard.KnowledgeDirectoryPostCreate) + group.POST("/delete", dashboard.KnowledgeDirectoryPostDelete) + group.POST("/update", dashboard.KnowledgeDirectoryPostUpdate) + group.POST("/update_sort", dashboard.KnowledgeDirectoryPostUpdate_sort) +} + func registerDashboardKnowledgeDocumentRoutes(group *gin.RouterGroup) { group.GET("/:id", dashboard.KnowledgeDocumentGetBy) group.POST("/create", dashboard.KnowledgeDocumentPostCreate) diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index e16238c..3071585 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -187,6 +187,7 @@ func addRouter(app *gin.Engine) { registerDashboardAIConfigRoutes(dashboardGroup.Group("/ai-config")) registerDashboardAssetRoutes(dashboardGroup.Group("/asset")) registerDashboardKnowledgeBaseRoutes(dashboardGroup.Group("/knowledge-base")) + registerDashboardKnowledgeDirectoryRoutes(dashboardGroup.Group("/knowledge-directory")) registerDashboardKnowledgeDocumentRoutes(dashboardGroup.Group("/knowledge-document")) registerDashboardKnowledgeFAQRoutes(dashboardGroup.Group("/knowledge-faq")) registerDashboardKnowledgeRetrieveRoutes(dashboardGroup.Group("/knowledge-retrieve")) diff --git a/internal/builders/knowledge_builder.go b/internal/builders/knowledge_builder.go index 4290605..f5833fc 100644 --- a/internal/builders/knowledge_builder.go +++ b/internal/builders/knowledge_builder.go @@ -37,6 +37,7 @@ func BuildKnowledgeDocument(item *models.KnowledgeDocument) response.KnowledgeDo return response.KnowledgeDocumentResponse{ ID: item.ID, KnowledgeBaseID: item.KnowledgeBaseID, + DirectoryID: item.DirectoryID, Title: item.Title, Status: item.Status, StatusName: enums.GetStatusLabel(item.Status), @@ -58,6 +59,7 @@ func BuildKnowledgeDocumentList(item *models.KnowledgeDocument) response.Knowled return response.KnowledgeDocumentListResponse{ ID: item.ID, KnowledgeBaseID: item.KnowledgeBaseID, + DirectoryID: item.DirectoryID, Title: item.Title, Status: item.Status, StatusName: enums.GetStatusLabel(item.Status), @@ -78,6 +80,7 @@ func BuildKnowledgeFAQ(item *models.KnowledgeFAQ) response.KnowledgeFAQResponse return response.KnowledgeFAQResponse{ ID: item.ID, KnowledgeBaseID: item.KnowledgeBaseID, + DirectoryID: item.DirectoryID, Question: item.Question, Answer: item.Answer, SimilarQuestions: parseSimilarQuestions(item.SimilarQuestions), @@ -95,6 +98,24 @@ func BuildKnowledgeFAQ(item *models.KnowledgeFAQ) response.KnowledgeFAQResponse } } +func BuildKnowledgeDirectory(item *models.KnowledgeDirectory) response.KnowledgeDirectoryResponse { + return response.KnowledgeDirectoryResponse{ + ID: item.ID, + KnowledgeBaseID: item.KnowledgeBaseID, + ParentID: item.ParentID, + Name: item.Name, + SortNo: item.SortNo, + Status: item.Status, + StatusName: enums.GetStatusLabel(item.Status), + Remark: item.Remark, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + CreateUserName: item.CreateUserName, + UpdateUserName: item.UpdateUserName, + Children: []response.KnowledgeDirectoryResponse{}, + } +} + func BuildKnowledgeRetrieveLog(item *models.KnowledgeRetrieveLog) response.KnowledgeRetrieveLogResponse { return response.KnowledgeRetrieveLogResponse{ ID: item.ID, diff --git a/internal/handlers/dashboard/knowledge_directory_handler.go b/internal/handlers/dashboard/knowledge_directory_handler.go new file mode 100644 index 0000000..a08b74e --- /dev/null +++ b/internal/handlers/dashboard/knowledge_directory_handler.go @@ -0,0 +1,120 @@ +package dashboard + +import ( + "agent-desk/internal/builders" + "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/httpx" + "agent-desk/internal/pkg/httpx/params" + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" +) + +func KnowledgeDirectoryGetList_all(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeBaseView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + knowledgeBaseID, ok := params.GetInt64(ctx, "knowledgeBaseId") + if !ok || knowledgeBaseID <= 0 { + httpx.WriteJSON(ctx, errorsx.InvalidParam("知识库不存在")) + return + } + list := services.KnowledgeDirectoryService.FindAllByKnowledgeBaseID(knowledgeBaseID) + httpx.WriteJSON(ctx, buildKnowledgeDirectoryTreeResponses(list)) +} + +func KnowledgeDirectoryPostCreate(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeBaseCreate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.CreateKnowledgeDirectoryRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + item, err := services.KnowledgeDirectoryService.CreateDirectory(req, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + resp := builders.BuildKnowledgeDirectory(item) + httpx.WriteJSON(ctx, &resp) +} + +func KnowledgeDirectoryPostUpdate(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeBaseUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.UpdateKnowledgeDirectoryRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.KnowledgeDirectoryService.UpdateDirectory(req, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func KnowledgeDirectoryPostDelete(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeBaseDelete); err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.DeleteKnowledgeDirectoryRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.KnowledgeDirectoryService.DeleteDirectory(req.ID); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func KnowledgeDirectoryPostUpdate_sort(ctx *gin.Context) { + var req struct { + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + ParentID int64 `json:"parentId"` + IDs []int64 `json:"ids"` + } + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.KnowledgeDirectoryService.UpdateSort(req.KnowledgeBaseID, req.ParentID, req.IDs); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func buildKnowledgeDirectoryTreeResponses(list []models.KnowledgeDirectory) []response.KnowledgeDirectoryResponse { + childrenByParent := make(map[int64][]models.KnowledgeDirectory, len(list)) + for _, item := range list { + childrenByParent[item.ParentID] = append(childrenByParent[item.ParentID], item) + } + var build func(parentID int64) []response.KnowledgeDirectoryResponse + build = func(parentID int64) []response.KnowledgeDirectoryResponse { + items := childrenByParent[parentID] + ret := make([]response.KnowledgeDirectoryResponse, 0, len(items)) + for _, item := range items { + resp := builders.BuildKnowledgeDirectory(&item) + resp.Children = build(item.ID) + ret = append(ret, resp) + } + return ret + } + return build(0) +} diff --git a/internal/handlers/dashboard/knowledge_document_handler.go b/internal/handlers/dashboard/knowledge_document_handler.go index bd8bdf9..08c215d 100644 --- a/internal/handlers/dashboard/knowledge_document_handler.go +++ b/internal/handlers/dashboard/knowledge_document_handler.go @@ -25,6 +25,10 @@ func KnowledgeDocumentAnyList(ctx *gin.Context) { params.QueryFilter{ParamName: "knowledgeBaseId"}, params.QueryFilter{ParamName: "title", Op: params.Like}, ).Desc("id") + knowledgeBaseID, _ := params.GetInt64(ctx, "knowledgeBaseId") + if directoryID, ok := params.GetInt64(ctx, "directoryId"); ok { + cnd.Where("directory_id = ?", directoryID) + } if status, ok := params.GetInt64(ctx, "status"); ok { cnd.Where("status = ?", status) @@ -40,9 +44,12 @@ func KnowledgeDocumentAnyList(ctx *gin.Context) { } list, paging := services.KnowledgeDocumentService.FindPageListByCnd(cnd) + directoryPaths := services.KnowledgeDirectoryService.PathMap(knowledgeBaseID) results := make([]response.KnowledgeDocumentListResponse, 0, len(list)) for _, item := range list { - results = append(results, builders.BuildKnowledgeDocumentList(&item)) + resp := builders.BuildKnowledgeDocumentList(&item) + fillKnowledgeDocumentDirectory(&resp, directoryPaths) + results = append(results, resp) } httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging}) } @@ -62,7 +69,20 @@ func KnowledgeDocumentGetBy(ctx *gin.Context) { httpx.WriteJSON(ctx, web.JsonErrorMsg("文档不存在")) return } - httpx.WriteJSON(ctx, builders.BuildKnowledgeDocument(item)) + resp := builders.BuildKnowledgeDocument(item) + fillKnowledgeDocumentDirectory(&resp, services.KnowledgeDirectoryService.PathMap(item.KnowledgeBaseID)) + httpx.WriteJSON(ctx, resp) +} + +func fillKnowledgeDocumentDirectory(resp any, directoryPaths map[int64]string) { + switch item := resp.(type) { + case *response.KnowledgeDocumentResponse: + item.DirectoryPath = directoryPaths[item.DirectoryID] + item.DirectoryName = item.DirectoryPath + case *response.KnowledgeDocumentListResponse: + item.DirectoryPath = directoryPaths[item.DirectoryID] + item.DirectoryName = item.DirectoryPath + } } func KnowledgeDocumentPostCreate(ctx *gin.Context) { diff --git a/internal/handlers/dashboard/knowledge_faq_handler.go b/internal/handlers/dashboard/knowledge_faq_handler.go index 81750ea..adb21d3 100644 --- a/internal/handlers/dashboard/knowledge_faq_handler.go +++ b/internal/handlers/dashboard/knowledge_faq_handler.go @@ -101,10 +101,17 @@ func KnowledgeFAQAnyList(ctx *gin.Context) { params.QueryFilter{ParamName: "question", Op: params.Like}, params.QueryFilter{ParamName: "indexStatus"}, ).Desc("id") + knowledgeBaseID, _ := params.GetInt64(ctx, "knowledgeBaseId") + if directoryID, ok := params.GetInt64(ctx, "directoryId"); ok { + cnd.Where("directory_id = ?", directoryID) + } list, paging := services.KnowledgeFAQService.FindPageByCnd(cnd) + directoryPaths := services.KnowledgeDirectoryService.PathMap(knowledgeBaseID) results := make([]response.KnowledgeFAQResponse, 0, len(list)) for _, item := range list { - results = append(results, builders.BuildKnowledgeFAQ(&item)) + resp := builders.BuildKnowledgeFAQ(&item) + fillKnowledgeFAQDirectory(&resp, directoryPaths) + results = append(results, resp) } httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging}) } @@ -124,7 +131,9 @@ func KnowledgeFAQGetBy(ctx *gin.Context) { httpx.WriteJSON(ctx, web.JsonErrorMsg("FAQ不存在")) return } - httpx.WriteJSON(ctx, builders.BuildKnowledgeFAQ(item)) + resp := builders.BuildKnowledgeFAQ(item) + fillKnowledgeFAQDirectory(&resp, services.KnowledgeDirectoryService.PathMap(item.KnowledgeBaseID)) + httpx.WriteJSON(ctx, resp) } func KnowledgeFAQPostCreate(ctx *gin.Context) { @@ -188,3 +197,8 @@ func writeKnowledgeFAQExcelFile(ctx *gin.Context, file *response.KnowledgeFAQExp ctx.Header("Cache-Control", "no-store") ctx.Data(http.StatusOK, file.ContentType, file.Data) } + +func fillKnowledgeFAQDirectory(resp *response.KnowledgeFAQResponse, directoryPaths map[int64]string) { + resp.DirectoryPath = directoryPaths[resp.DirectoryID] + resp.DirectoryName = resp.DirectoryPath +} diff --git a/internal/models/models.go b/internal/models/models.go index 837bcfa..f9b3475 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -48,6 +48,7 @@ var Models = []any{ &AgentTeamSchedule{}, &AIConfig{}, &KnowledgeBase{}, + &KnowledgeDirectory{}, &KnowledgeDocument{}, &KnowledgeFAQ{}, &KnowledgeChunk{}, @@ -681,10 +682,23 @@ type KnowledgeBase struct { AuditFields } +// KnowledgeDirectory 知识库内部目录表。 +type KnowledgeDirectory struct { + ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为目录主键。 + KnowledgeBaseID int64 `gorm:"type:bigint;not null;index:idx_kb_parent_sort"` // KnowledgeBaseID 为所属知识库 ID。 + ParentID int64 `gorm:"type:bigint;not null;default:0;index:idx_kb_parent_sort"` // ParentID 为父目录 ID,0 表示一级目录。 + Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为目录名称。 + SortNo int `gorm:"type:int;not null;default:0;index:idx_kb_parent_sort"` // SortNo 为同级排序号。 + Status enums.Status `gorm:"type:int;not null;default:0;index"` // Status 为状态。 + Remark string `gorm:"type:text"` // Remark 为备注。 + AuditFields +} + // KnowledgeDocument 知识文档主表。 type KnowledgeDocument struct { ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为文档主键。 KnowledgeBaseID int64 `gorm:"type:bigint;not null;index"` // KnowledgeBaseID 为所属知识库ID。 + DirectoryID int64 `gorm:"type:bigint;not null;default:0;index"` // DirectoryID 为所属知识库内部目录 ID,0 表示根目录。 Title string `gorm:"type:varchar(255);not null;default:'';index"` // Title 为文档标题。 ContentType enums.KnowledgeDocumentContentType `gorm:"type:varchar(20);not null;default:'html'"` // ContentType 为内容类型:html/markdown。 Content string `gorm:"type:text"` // Content 为文档内容。 @@ -700,6 +714,7 @@ type KnowledgeDocument struct { type KnowledgeFAQ struct { ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为 FAQ 主键。 KnowledgeBaseID int64 `gorm:"type:bigint;not null;index"` // KnowledgeBaseID 为所属 FAQ 知识库 ID。 + DirectoryID int64 `gorm:"type:bigint;not null;default:0;index"` // DirectoryID 为所属知识库内部目录 ID,0 表示根目录。 Question string `gorm:"type:varchar(500);not null;default:'';index"` // Question 为标准问题。 Answer string `gorm:"type:text"` // Answer 为标准答案。 SimilarQuestions string `gorm:"type:text"` // SimilarQuestions 为相似问 JSON 数组。 diff --git a/internal/pkg/dto/request/knowledge_request.go b/internal/pkg/dto/request/knowledge_request.go index 1136327..8420fa6 100644 --- a/internal/pkg/dto/request/knowledge_request.go +++ b/internal/pkg/dto/request/knowledge_request.go @@ -26,8 +26,25 @@ type UpdateKnowledgeBaseRequest struct { CreateKnowledgeBaseRequest } +type CreateKnowledgeDirectoryRequest struct { + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + ParentID int64 `json:"parentId"` + Name string `json:"name"` + Remark string `json:"remark"` +} + +type UpdateKnowledgeDirectoryRequest struct { + ID int64 `json:"id"` + CreateKnowledgeDirectoryRequest +} + +type DeleteKnowledgeDirectoryRequest struct { + ID int64 `json:"id"` +} + type CreateKnowledgeDocumentRequest struct { KnowledgeBaseID int64 `json:"knowledgeBaseId"` + DirectoryID int64 `json:"directoryId"` Title string `json:"title"` ContentType enums.KnowledgeDocumentContentType `json:"contentType"` Content string `json:"content"` @@ -40,6 +57,7 @@ type UpdateKnowledgeDocumentRequest struct { type CreateKnowledgeFAQRequest struct { KnowledgeBaseID int64 `json:"knowledgeBaseId"` + DirectoryID int64 `json:"directoryId"` Question string `json:"question"` Answer string `json:"answer"` SimilarQuestions []string `json:"similarQuestions"` diff --git a/internal/pkg/dto/response/knowledge_response.go b/internal/pkg/dto/response/knowledge_response.go index f224ac7..6f83aaf 100644 --- a/internal/pkg/dto/response/knowledge_response.go +++ b/internal/pkg/dto/response/knowledge_response.go @@ -35,6 +35,9 @@ type KnowledgeDocumentResponse struct { ID int64 `json:"id"` KnowledgeBaseID int64 `json:"knowledgeBaseId"` KnowledgeBaseName string `json:"knowledgeBaseName,omitempty"` + DirectoryID int64 `json:"directoryId"` + DirectoryName string `json:"directoryName,omitempty"` + DirectoryPath string `json:"directoryPath,omitempty"` Title string `json:"title"` ContentType enums.KnowledgeDocumentContentType `json:"contentType"` Content string `json:"content"` @@ -55,6 +58,9 @@ type KnowledgeDocumentListResponse struct { ID int64 `json:"id"` KnowledgeBaseID int64 `json:"knowledgeBaseId"` KnowledgeBaseName string `json:"knowledgeBaseName,omitempty"` + DirectoryID int64 `json:"directoryId"` + DirectoryName string `json:"directoryName,omitempty"` + DirectoryPath string `json:"directoryPath,omitempty"` Title string `json:"title"` ContentType enums.KnowledgeDocumentContentType `json:"contentType"` Status enums.Status `json:"status"` @@ -74,6 +80,9 @@ type KnowledgeFAQResponse struct { ID int64 `json:"id"` KnowledgeBaseID int64 `json:"knowledgeBaseId"` KnowledgeBaseName string `json:"knowledgeBaseName,omitempty"` + DirectoryID int64 `json:"directoryId"` + DirectoryName string `json:"directoryName,omitempty"` + DirectoryPath string `json:"directoryPath,omitempty"` Question string `json:"question"` Answer string `json:"answer"` SimilarQuestions []string `json:"similarQuestions"` @@ -90,6 +99,22 @@ type KnowledgeFAQResponse struct { UpdateUserName string `json:"updateUserName"` } +type KnowledgeDirectoryResponse struct { + ID int64 `json:"id"` + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + ParentID int64 `json:"parentId"` + Name string `json:"name"` + SortNo int `json:"sortNo"` + Status enums.Status `json:"status"` + StatusName string `json:"statusName"` + Remark string `json:"remark"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + CreateUserName string `json:"createUserName"` + UpdateUserName string `json:"updateUserName"` + Children []KnowledgeDirectoryResponse `json:"children"` +} + type KnowledgeFAQExportedFile struct { Filename string ContentType string diff --git a/internal/repositories/knowledge_directory_repository.go b/internal/repositories/knowledge_directory_repository.go new file mode 100644 index 0000000..73bea86 --- /dev/null +++ b/internal/repositories/knowledge_directory_repository.go @@ -0,0 +1,58 @@ +package repositories + +import ( + "agent-desk/internal/models" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var KnowledgeDirectoryRepository = newKnowledgeDirectoryRepository() + +func newKnowledgeDirectoryRepository() *knowledgeDirectoryRepository { + return &knowledgeDirectoryRepository{} +} + +type knowledgeDirectoryRepository struct { +} + +func (r *knowledgeDirectoryRepository) Get(db *gorm.DB, id int64) *models.KnowledgeDirectory { + ret := &models.KnowledgeDirectory{} + if err := db.First(ret, "id = ?", id).Error; err != nil { + return nil + } + return ret +} + +func (r *knowledgeDirectoryRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.KnowledgeDirectory) { + cnd.Find(db, &list) + return +} + +func (r *knowledgeDirectoryRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.KnowledgeDirectory { + ret := &models.KnowledgeDirectory{} + if err := cnd.FindOne(db, &ret); err != nil { + return nil + } + return ret +} + +func (r *knowledgeDirectoryRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { + return cnd.Count(db, &models.KnowledgeDirectory{}) +} + +func (r *knowledgeDirectoryRepository) Create(db *gorm.DB, t *models.KnowledgeDirectory) error { + return db.Create(t).Error +} + +func (r *knowledgeDirectoryRepository) Updates(db *gorm.DB, id int64, columns map[string]any) error { + return db.Model(&models.KnowledgeDirectory{}).Where("id = ?", id).Updates(columns).Error +} + +func (r *knowledgeDirectoryRepository) UpdateColumn(db *gorm.DB, id int64, name string, value any) error { + return db.Model(&models.KnowledgeDirectory{}).Where("id = ?", id).UpdateColumn(name, value).Error +} + +func (r *knowledgeDirectoryRepository) Delete(db *gorm.DB, id int64) error { + return db.Delete(&models.KnowledgeDirectory{}, "id = ?", id).Error +} diff --git a/internal/repositories/knowledge_faq_repository.go b/internal/repositories/knowledge_faq_repository.go index d94d7d9..e1bd35f 100644 --- a/internal/repositories/knowledge_faq_repository.go +++ b/internal/repositories/knowledge_faq_repository.go @@ -46,6 +46,10 @@ func (r *knowledgeFAQRepository) FindPageByParams(db *gorm.DB, queryParams *para return r.FindPageByCnd(db, &queryParams.Cnd) } +func (r *knowledgeFAQRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { + return cnd.Count(db, &models.KnowledgeFAQ{}) +} + func (r *knowledgeFAQRepository) Create(db *gorm.DB, t *models.KnowledgeFAQ) error { return db.Create(t).Error } diff --git a/internal/services/knowledge_directory_service.go b/internal/services/knowledge_directory_service.go new file mode 100644 index 0000000..8e0b999 --- /dev/null +++ b/internal/services/knowledge_directory_service.go @@ -0,0 +1,212 @@ +package services + +import ( + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +var KnowledgeDirectoryService = newKnowledgeDirectoryService() + +func newKnowledgeDirectoryService() *knowledgeDirectoryService { + return &knowledgeDirectoryService{} +} + +type knowledgeDirectoryService struct { +} + +func (s *knowledgeDirectoryService) Get(id int64) *models.KnowledgeDirectory { + return repositories.KnowledgeDirectoryRepository.Get(sqls.DB(), id) +} + +func (s *knowledgeDirectoryService) Find(cnd *sqls.Cnd) []models.KnowledgeDirectory { + return repositories.KnowledgeDirectoryRepository.Find(sqls.DB(), cnd) +} + +func (s *knowledgeDirectoryService) FindAllByKnowledgeBaseID(knowledgeBaseID int64) []models.KnowledgeDirectory { + return s.Find(sqls.NewCnd().Eq("knowledge_base_id", knowledgeBaseID).Asc("parent_id").Asc("sort_no").Asc("id")) +} + +func (s *knowledgeDirectoryService) Count(cnd *sqls.Cnd) int64 { + return repositories.KnowledgeDirectoryRepository.Count(sqls.DB(), cnd) +} + +func (s *knowledgeDirectoryService) CreateDirectory(req request.CreateKnowledgeDirectoryRequest, operator *dto.AuthPrincipal) (*models.KnowledgeDirectory, error) { + if operator == nil { + return nil, errorsx.Unauthorized("未登录或登录已过期") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errorsx.InvalidParam("目录名称不能为空") + } + if req.KnowledgeBaseID <= 0 || KnowledgeBaseService.Get(req.KnowledgeBaseID) == nil { + return nil, errorsx.InvalidParam("知识库不存在") + } + if err := s.validateParent(req.KnowledgeBaseID, req.ParentID, 0); err != nil { + return nil, err + } + if existing := s.findByName(req.KnowledgeBaseID, req.ParentID, name); existing != nil { + return nil, errorsx.InvalidParam("同级下已存在相同名称的目录") + } + item := &models.KnowledgeDirectory{ + KnowledgeBaseID: req.KnowledgeBaseID, + ParentID: req.ParentID, + Name: name, + SortNo: s.NextSortNo(req.KnowledgeBaseID, req.ParentID), + Status: enums.StatusOk, + Remark: strings.TrimSpace(req.Remark), + AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.KnowledgeDirectoryRepository.Create(sqls.DB(), item); err != nil { + return nil, err + } + return item, nil +} + +func (s *knowledgeDirectoryService) UpdateDirectory(req request.UpdateKnowledgeDirectoryRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.Unauthorized("未登录或登录已过期") + } + item := s.Get(req.ID) + if item == nil { + return errorsx.InvalidParam("目录不存在") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return errorsx.InvalidParam("目录名称不能为空") + } + if req.KnowledgeBaseID <= 0 { + req.KnowledgeBaseID = item.KnowledgeBaseID + } + if req.KnowledgeBaseID != item.KnowledgeBaseID { + return errorsx.InvalidParam("目录不能移动到其他知识库") + } + if err := s.validateParent(item.KnowledgeBaseID, req.ParentID, req.ID); err != nil { + return err + } + if req.ParentID > 0 && s.Count(sqls.NewCnd().Eq("parent_id", req.ID)) > 0 { + return errorsx.InvalidParam("存在子目录的目录不能移动到二级目录") + } + if existing := s.findByName(item.KnowledgeBaseID, req.ParentID, name); existing != nil && existing.ID != req.ID { + return errorsx.InvalidParam("同级下已存在相同名称的目录") + } + return repositories.KnowledgeDirectoryRepository.Updates(sqls.DB(), req.ID, map[string]any{ + "parent_id": req.ParentID, + "name": name, + "remark": strings.TrimSpace(req.Remark), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) +} + +func (s *knowledgeDirectoryService) DeleteDirectory(id int64) error { + item := s.Get(id) + if item == nil { + return errorsx.InvalidParam("目录不存在") + } + if s.Count(sqls.NewCnd().Eq("parent_id", id)) > 0 { + return errorsx.InvalidParam("该目录下存在子目录,无法删除") + } + if KnowledgeDocumentService.Count(sqls.NewCnd().Eq("directory_id", id)) > 0 { + return errorsx.InvalidParam("该目录下存在文档,无法删除") + } + if KnowledgeFAQService.Count(sqls.NewCnd().Eq("directory_id", id)) > 0 { + return errorsx.InvalidParam("该目录下存在FAQ,无法删除") + } + return repositories.KnowledgeDirectoryRepository.Delete(sqls.DB(), id) +} + +func (s *knowledgeDirectoryService) UpdateSort(knowledgeBaseID int64, parentID int64, ids []int64) error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + for i, id := range ids { + item := repositories.KnowledgeDirectoryRepository.Get(ctx.Tx, id) + if item == nil { + return errorsx.InvalidParam("目录不存在") + } + if item.KnowledgeBaseID != knowledgeBaseID || item.ParentID != parentID { + return errorsx.InvalidParam("只能调整同知识库同级目录排序") + } + if err := repositories.KnowledgeDirectoryRepository.UpdateColumn(ctx.Tx, id, "sort_no", i+1); err != nil { + return err + } + } + return nil + }) +} + +func (s *knowledgeDirectoryService) RequireUsableDirectory(knowledgeBaseID int64, directoryID int64) (*models.KnowledgeDirectory, error) { + if directoryID <= 0 { + return nil, nil + } + item := s.Get(directoryID) + if item == nil { + return nil, errorsx.InvalidParam("知识库目录不存在") + } + if item.KnowledgeBaseID != knowledgeBaseID { + return nil, errorsx.InvalidParam("知识库目录不属于当前知识库") + } + if item.Status != enums.StatusOk { + return nil, errorsx.InvalidParam("知识库目录不可用") + } + return item, nil +} + +func (s *knowledgeDirectoryService) NextSortNo(knowledgeBaseID int64, parentID int64) int { + if temp := repositories.KnowledgeDirectoryRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("knowledge_base_id", knowledgeBaseID).Eq("parent_id", parentID).Desc("sort_no").Desc("id")); temp != nil { + return temp.SortNo + 1 + } + return 1 +} + +func (s *knowledgeDirectoryService) PathMap(knowledgeBaseID int64) map[int64]string { + items := s.FindAllByKnowledgeBaseID(knowledgeBaseID) + byID := make(map[int64]models.KnowledgeDirectory, len(items)) + for _, item := range items { + byID[item.ID] = item + } + ret := make(map[int64]string, len(items)) + for _, item := range items { + if item.ParentID > 0 { + if parent, ok := byID[item.ParentID]; ok { + ret[item.ID] = parent.Name + " / " + item.Name + continue + } + } + ret[item.ID] = item.Name + } + return ret +} + +func (s *knowledgeDirectoryService) validateParent(knowledgeBaseID int64, parentID int64, selfID int64) error { + if parentID <= 0 { + return nil + } + if parentID == selfID { + return errorsx.InvalidParam("不能将目录设为自己的子目录") + } + parent := s.Get(parentID) + if parent == nil { + return errorsx.InvalidParam("父目录不存在") + } + if parent.KnowledgeBaseID != knowledgeBaseID { + return errorsx.InvalidParam("父目录不属于当前知识库") + } + if parent.ParentID > 0 { + return errorsx.InvalidParam("知识库目录最多支持二级") + } + return nil +} + +func (s *knowledgeDirectoryService) findByName(knowledgeBaseID int64, parentID int64, name string) *models.KnowledgeDirectory { + return repositories.KnowledgeDirectoryRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("knowledge_base_id", knowledgeBaseID).Eq("parent_id", parentID).Eq("name", name)) +} diff --git a/internal/services/knowledge_directory_service_test.go b/internal/services/knowledge_directory_service_test.go new file mode 100644 index 0000000..a7fdedd --- /dev/null +++ b/internal/services/knowledge_directory_service_test.go @@ -0,0 +1,126 @@ +package services + +import ( + "testing" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func TestKnowledgeDirectoryRejectsThirdLevel(t *testing.T) { + setupKnowledgeDirectoryTestDB(t) + operator := knowledgeDirectoryTestOperator() + kb := createKnowledgeDirectoryTestBase(t, "Document KB", string(enums.KnowledgeBaseTypeDocument)) + parent, err := KnowledgeDirectoryService.CreateDirectory(request.CreateKnowledgeDirectoryRequest{ + KnowledgeBaseID: kb.ID, + Name: "一级目录", + }, operator) + if err != nil { + t.Fatalf("create parent: %v", err) + } + child, err := KnowledgeDirectoryService.CreateDirectory(request.CreateKnowledgeDirectoryRequest{ + KnowledgeBaseID: kb.ID, + ParentID: parent.ID, + Name: "二级目录", + }, operator) + if err != nil { + t.Fatalf("create child: %v", err) + } + + if _, err := KnowledgeDirectoryService.CreateDirectory(request.CreateKnowledgeDirectoryRequest{ + KnowledgeBaseID: kb.ID, + ParentID: child.ID, + Name: "三级目录", + }, operator); err == nil { + t.Fatal("CreateDirectory() error is nil, want third-level rejection") + } +} + +func TestKnowledgeDocumentRejectsDirectoryFromOtherKnowledgeBase(t *testing.T) { + setupKnowledgeDirectoryTestDB(t) + operator := knowledgeDirectoryTestOperator() + kb := createKnowledgeDirectoryTestBase(t, "Document KB", string(enums.KnowledgeBaseTypeDocument)) + otherKB := createKnowledgeDirectoryTestBase(t, "Other KB", string(enums.KnowledgeBaseTypeDocument)) + directory, err := KnowledgeDirectoryService.CreateDirectory(request.CreateKnowledgeDirectoryRequest{ + KnowledgeBaseID: otherKB.ID, + Name: "其他目录", + }, operator) + if err != nil { + t.Fatalf("create directory: %v", err) + } + + _, err = KnowledgeDocumentService.CreateKnowledgeDocument(request.CreateKnowledgeDocumentRequest{ + KnowledgeBaseID: kb.ID, + DirectoryID: directory.ID, + Title: "Doc", + Content: "content", + }, operator) + if err == nil { + t.Fatal("CreateKnowledgeDocument() error is nil, want cross-knowledge-base directory rejection") + } +} + +func TestKnowledgeDirectoryDeleteRejectsAttachedContent(t *testing.T) { + setupKnowledgeDirectoryTestDB(t) + operator := knowledgeDirectoryTestOperator() + kb := createKnowledgeDirectoryTestBase(t, "FAQ KB", string(enums.KnowledgeBaseTypeFAQ)) + directory, err := KnowledgeDirectoryService.CreateDirectory(request.CreateKnowledgeDirectoryRequest{ + KnowledgeBaseID: kb.ID, + Name: "FAQ目录", + }, operator) + if err != nil { + t.Fatalf("create directory: %v", err) + } + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), &models.KnowledgeFAQ{ + KnowledgeBaseID: kb.ID, + DirectoryID: directory.ID, + Question: "Question", + Answer: "Answer", + Status: enums.StatusOk, + }); err != nil { + t.Fatalf("create faq: %v", err) + } + + if err := KnowledgeDirectoryService.DeleteDirectory(directory.ID); err == nil { + t.Fatal("DeleteDirectory() error is nil, want attached content rejection") + } +} + +func setupKnowledgeDirectoryTestDB(t *testing.T) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate(&models.KnowledgeBase{}, &models.KnowledgeDirectory{}, &models.KnowledgeDocument{}, &models.KnowledgeFAQ{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) +} + +func createKnowledgeDirectoryTestBase(t *testing.T, name string, knowledgeType string) *models.KnowledgeBase { + t.Helper() + item := &models.KnowledgeBase{ + Name: name, + KnowledgeType: knowledgeType, + Status: enums.StatusOk, + } + if err := repositories.KnowledgeBaseRepository.Create(sqls.DB(), item); err != nil { + t.Fatalf("create knowledge base: %v", err) + } + return item +} + +func knowledgeDirectoryTestOperator() *dto.AuthPrincipal { + return &dto.AuthPrincipal{ + UserID: 1, + Username: "tester", + } +} diff --git a/internal/services/knowledge_document_service.go b/internal/services/knowledge_document_service.go index 80d9584..f39fad2 100644 --- a/internal/services/knowledge_document_service.go +++ b/internal/services/knowledge_document_service.go @@ -94,6 +94,9 @@ func (s *knowledgeDocumentService) CreateKnowledgeDocument(req request.CreateKno if kb.KnowledgeType == string(enums.KnowledgeBaseTypeFAQ) { return nil, errorsx.InvalidParam("FAQ知识库不支持文档") } + if _, err := KnowledgeDirectoryService.RequireUsableDirectory(req.KnowledgeBaseID, req.DirectoryID); err != nil { + return nil, err + } item, err := s.buildKnowledgeDocumentModel(req) if err != nil { return nil, err @@ -133,6 +136,9 @@ func (s *knowledgeDocumentService) UpdateKnowledgeDocument(req request.UpdateKno if kb.KnowledgeType == string(enums.KnowledgeBaseTypeFAQ) { return errorsx.InvalidParam("FAQ知识库不支持文档") } + if _, err := KnowledgeDirectoryService.RequireUsableDirectory(req.KnowledgeBaseID, req.DirectoryID); err != nil { + return err + } item, err := s.buildKnowledgeDocumentModel(req.CreateKnowledgeDocumentRequest) if err != nil { return err @@ -140,6 +146,7 @@ func (s *knowledgeDocumentService) UpdateKnowledgeDocument(req request.UpdateKno oldKnowledgeBaseID := current.KnowledgeBaseID if err := repositories.KnowledgeDocumentRepository.Updates(sqls.DB(), req.ID, map[string]any{ "knowledge_base_id": item.KnowledgeBaseID, + "directory_id": item.DirectoryID, "title": item.Title, "content_type": item.ContentType, "content_hash": item.ContentHash, @@ -187,6 +194,7 @@ func (s *knowledgeDocumentService) buildKnowledgeDocumentModel(req request.Creat plainText := rag.ExtractPlainText(req.Content, req.ContentType) item := &models.KnowledgeDocument{ KnowledgeBaseID: req.KnowledgeBaseID, + DirectoryID: req.DirectoryID, Title: req.Title, ContentType: req.ContentType, Content: req.Content, diff --git a/internal/services/knowledge_faq_service.go b/internal/services/knowledge_faq_service.go index a3b5814..36e0d4a 100644 --- a/internal/services/knowledge_faq_service.go +++ b/internal/services/knowledge_faq_service.go @@ -40,6 +40,10 @@ func (s *knowledgeFAQService) FindPageByParams(queryParams *params.QueryParams) return repositories.KnowledgeFAQRepository.FindPageByParams(sqls.DB(), queryParams) } +func (s *knowledgeFAQService) Count(cnd *sqls.Cnd) int64 { + return repositories.KnowledgeFAQRepository.Count(sqls.DB(), cnd) +} + func (s *knowledgeFAQService) CreateKnowledgeFAQ(req request.CreateKnowledgeFAQRequest, operator *dto.AuthPrincipal) (*models.KnowledgeFAQ, error) { if operator == nil { return nil, errorsx.Unauthorized("未登录或登录已过期") @@ -48,6 +52,9 @@ func (s *knowledgeFAQService) CreateKnowledgeFAQ(req request.CreateKnowledgeFAQR if err != nil { return nil, err } + if _, err := KnowledgeDirectoryService.RequireUsableDirectory(req.KnowledgeBaseID, req.DirectoryID); err != nil { + return nil, err + } item, err := s.buildKnowledgeFAQModel(req) if err != nil { return nil, err @@ -78,12 +85,16 @@ func (s *knowledgeFAQService) UpdateKnowledgeFAQ(req request.UpdateKnowledgeFAQR if _, err := s.requireFAQKnowledgeBase(req.KnowledgeBaseID); err != nil { return err } + if _, err := KnowledgeDirectoryService.RequireUsableDirectory(req.KnowledgeBaseID, req.DirectoryID); err != nil { + return err + } item, err := s.buildKnowledgeFAQModel(req.CreateKnowledgeFAQRequest) if err != nil { return err } if err := repositories.KnowledgeFAQRepository.Updates(sqls.DB(), req.ID, map[string]any{ "knowledge_base_id": item.KnowledgeBaseID, + "directory_id": item.DirectoryID, "question": item.Question, "answer": item.Answer, "similar_questions": item.SimilarQuestions, @@ -127,6 +138,7 @@ func (s *knowledgeFAQService) buildKnowledgeFAQModel(req request.CreateKnowledge } return &models.KnowledgeFAQ{ KnowledgeBaseID: req.KnowledgeBaseID, + DirectoryID: req.DirectoryID, Question: req.Question, Answer: req.Answer, SimilarQuestions: string(similarQuestions), diff --git a/web/app/dashboard/knowledge/_components/document-edit.tsx b/web/app/dashboard/knowledge/_components/document-edit.tsx index 6eca428..2a01f91 100644 --- a/web/app/dashboard/knowledge/_components/document-edit.tsx +++ b/web/app/dashboard/knowledge/_components/document-edit.tsx @@ -7,6 +7,7 @@ import { z } from "zod/v4" import { ProjectDialog } from "@/components/project-dialog" import { ContentEditor } from "@/components/content-editor" +import { OptionCombobox } from "@/components/option-combobox" import { Button } from "@/components/ui/button" import { Field, @@ -18,7 +19,9 @@ import { Input } from "@/components/ui/input" import { type KnowledgeDocument, type CreateKnowledgeDocumentPayload, + type KnowledgeDirectory, fetchKnowledgeDocument, + fetchKnowledgeDirectories, } from "@/lib/api/admin" import { KnowledgeDocumentContentType, @@ -30,11 +33,13 @@ type DocumentEditDialogProps = { saving: boolean itemId: number | null knowledgeBaseId: number | null + initialDirectoryId?: number onOpenChange: (open: boolean) => void onSubmit: (payload: CreateKnowledgeDocumentPayload) => Promise } const emptyForm: EditForm = { + directoryId: "0", title: "", contentType: KnowledgeDocumentContentType.Markdown, content: "", @@ -44,6 +49,7 @@ type TFunction = (key: string, values?: Record) => stri function createKnowledgeDocumentFormSchema(t: TFunction) { return z.object({ + directoryId: z.string().trim(), title: z.string().trim().min(1, t("knowledge.documentTitleRequired")).max(255, t("knowledge.documentTitleMax")), contentType: z.string().trim().min(1, t("knowledge.contentTypeRequired")), content: z.string().trim().min(1, t("knowledge.contentRequired")), @@ -51,17 +57,28 @@ function createKnowledgeDocumentFormSchema(t: TFunction) { } type EditForm = { + directoryId: string title: string contentType: string content: string } -function buildForm(item: KnowledgeDocument | null): EditForm { +type DirectoryOption = { value: string; label: string } + +function flattenDirectoryOptions(items: KnowledgeDirectory[], depth = 0): DirectoryOption[] { + return items.flatMap((item) => [ + { value: String(item.id), label: `${depth > 0 ? " " : ""}${item.name}` }, + ...flattenDirectoryOptions(item.children || [], depth + 1), + ]) +} + +function buildForm(item: KnowledgeDocument | null, initialDirectoryId = 0): EditForm { if (!item) { - return emptyForm + return { ...emptyForm, directoryId: String(initialDirectoryId) } } return { + directoryId: String(item.directoryId || 0), title: item.title, contentType: item.contentType || KnowledgeDocumentContentType.Markdown, content: item.content || "", @@ -71,6 +88,7 @@ function buildForm(item: KnowledgeDocument | null): EditForm { function buildPayload(form: EditForm, knowledgeBaseId: number): CreateKnowledgeDocumentPayload { return { knowledgeBaseId, + directoryId: Number(form.directoryId), title: form.title.trim(), contentType: form.contentType, content: form.content.trim(), @@ -82,6 +100,7 @@ export function DocumentEditDialog({ saving, itemId, knowledgeBaseId, + initialDirectoryId = 0, onOpenChange, onSubmit, }: DocumentEditDialogProps) { @@ -94,6 +113,7 @@ export function DocumentEditDialog({ key={itemId ? `edit-${itemId}` : "create"} itemId={itemId} knowledgeBaseId={knowledgeBaseId} + initialDirectoryId={initialDirectoryId} saving={saving} onOpenChange={onOpenChange} onSubmit={onSubmit} @@ -105,6 +125,7 @@ type DocumentFormDialogBodyProps = { saving: boolean itemId: number | null knowledgeBaseId: number + initialDirectoryId: number onOpenChange: (open: boolean) => void onSubmit: (payload: CreateKnowledgeDocumentPayload) => Promise } @@ -113,12 +134,14 @@ function DocumentFormDialogBody({ saving, itemId, knowledgeBaseId, + initialDirectoryId, onOpenChange, onSubmit, }: DocumentFormDialogBodyProps) { const t = useI18n() const formId = "knowledge-document-edit-form" const [loading, setLoading] = useState(false) + const [directories, setDirectories] = useState([]) const knowledgeDocumentFormSchema = useMemo(() => createKnowledgeDocumentFormSchema(t), [t]) const editFormResolver = useMemo( () => zodResolver(knowledgeDocumentFormSchema) as Resolver, @@ -140,11 +163,18 @@ function DocumentFormDialogBody({ const contentType = watch("contentType") const content = watch("content") + const directoryOptions = useMemo( + () => [ + { value: "0", label: t("knowledge.rootContent") }, + ...flattenDirectoryOptions(directories), + ], + [directories, t], + ) useEffect(() => { async function loadDetail() { if (!itemId) { - reset(emptyForm) + reset(buildForm(null, initialDirectoryId)) return } setLoading(true) @@ -158,7 +188,25 @@ function DocumentFormDialogBody({ } } void loadDetail() - }, [itemId, reset]) + }, [itemId, initialDirectoryId, reset]) + + useEffect(() => { + let cancelled = false + async function loadDirectories() { + try { + const data = await fetchKnowledgeDirectories(knowledgeBaseId) + if (!cancelled) { + setDirectories(data) + } + } catch (error) { + console.error("Failed to load knowledge directories:", error) + } + } + void loadDirectories() + return () => { + cancelled = true + } + }, [knowledgeBaseId]) async function onFormSubmit(values: EditForm) { const payload = buildPayload({ ...values, contentType, content }, knowledgeBaseId) @@ -194,6 +242,27 @@ function DocumentFormDialogBody({ ) : (
+ + {t("knowledge.directory")} + + ( + field.onChange(value ?? "0")} + options={directoryOptions} + placeholder={t("knowledge.selectDirectory")} + searchPlaceholder={t("knowledge.searchDirectory")} + emptyText={t("knowledge.emptyDirectory")} + /> + )} + /> + + + + {t("knowledge.documentTitle")} diff --git a/web/app/dashboard/knowledge/_components/document-list.tsx b/web/app/dashboard/knowledge/_components/document-list.tsx index 0a7992e..7831a7a 100644 --- a/web/app/dashboard/knowledge/_components/document-list.tsx +++ b/web/app/dashboard/knowledge/_components/document-list.tsx @@ -55,6 +55,7 @@ import { } from "@/lib/generated/enums"; import { cn, formatDateTime } from "@/lib/utils"; import { DocumentEditDialog } from "./document-edit"; +import { KnowledgeDirectoryPanel } from "./knowledge-directory-panel"; type DocumentListProps = { knowledgeBaseId: number | null; @@ -140,6 +141,7 @@ const VIEW_MODE_STORAGE_KEY = "knowledge-document-view-mode"; export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentListProps) { const t = useI18n(); const [saving, setSaving] = useState(false); + const [selectedDirectoryId, setSelectedDirectoryId] = useState(null); const [actionLoadingMap, setActionLoadingMap] = useState>({}); const [dialogOpen, setDialogOpen] = useState(false); const [editingItem, setEditingItem] = useState( @@ -153,6 +155,10 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL const statusOptions = useMemo(() => getStatusOptions(t), [t]); const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]); + useEffect(() => { + setSelectedDirectoryId(null); + }, [knowledgeBaseId]); + const filters = useMemo(() => [ { name: "title", @@ -177,10 +183,11 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL status: typeof query.status === "string" ? query.status : undefined, indexStatus: typeof query.indexStatus === "string" ? query.indexStatus : undefined, knowledgeBaseId: knowledgeBaseId ?? 0, + directoryId: selectedDirectoryId === null ? undefined : selectedDirectoryId, page: typeof query.page === "number" ? query.page : Number(query.page ?? 1), limit: typeof query.limit === "number" ? query.limit : Number(query.limit ?? 20), }); - }, [knowledgeBaseId]); + }, [knowledgeBaseId, selectedDirectoryId]); const { draftFilters, @@ -196,7 +203,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL filters, fetchList, enabled: Boolean(knowledgeBaseId), - reloadKey: knowledgeBaseId, + reloadKey: `${knowledgeBaseId ?? 0}-${selectedDirectoryId ?? "all"}`, loadFailed: t("knowledge.loadDocumentsFailed"), }); @@ -322,7 +329,14 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL return ( <> -
+
+ void loadData()} + /> +
@@ -528,12 +542,14 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL onLimitChange={handleLimitChange} />
+
diff --git a/web/app/dashboard/knowledge/_components/faq-edit.tsx b/web/app/dashboard/knowledge/_components/faq-edit.tsx index c4983dd..3bac65e 100644 --- a/web/app/dashboard/knowledge/_components/faq-edit.tsx +++ b/web/app/dashboard/knowledge/_components/faq-edit.tsx @@ -6,13 +6,16 @@ import { useForm, type Resolver } from "react-hook-form"; import { z } from "zod/v4"; import { ProjectDialog } from "@/components/project-dialog"; +import { OptionCombobox } from "@/components/option-combobox"; import { Button } from "@/components/ui/button"; import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { fetchKnowledgeFAQ, + fetchKnowledgeDirectories, type CreateKnowledgeFAQPayload, + type KnowledgeDirectory, type KnowledgeFAQ, } from "@/lib/api/admin"; import { useI18n } from "@/i18n/provider"; @@ -22,6 +25,7 @@ type FAQEditDialogProps = { saving: boolean; itemId: number | null; knowledgeBaseId: number | null; + initialDirectoryId?: number; onOpenChange: (open: boolean) => void; onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise; }; @@ -30,6 +34,7 @@ type TFunction = (key: string, values?: Record) => stri function createFormSchema(t: TFunction) { return z.object({ + directoryId: z.string().trim(), question: z.string().trim().min(1, t("knowledge.faqQuestionRequired")).max(500, t("knowledge.faqQuestionMax")), answer: z.string().trim().min(1, t("knowledge.faqAnswerRequired")), similarQuestionsText: z.string(), @@ -38,6 +43,7 @@ function createFormSchema(t: TFunction) { } type EditForm = { + directoryId: string; question: string; answer: string; similarQuestionsText: string; @@ -45,17 +51,28 @@ type EditForm = { }; const emptyForm: EditForm = { + directoryId: "0", question: "", answer: "", similarQuestionsText: "", remark: "", }; -function buildForm(item: KnowledgeFAQ | null): EditForm { +type DirectoryOption = { value: string; label: string }; + +function flattenDirectoryOptions(items: KnowledgeDirectory[], depth = 0): DirectoryOption[] { + return items.flatMap((item) => [ + { value: String(item.id), label: `${depth > 0 ? " " : ""}${item.name}` }, + ...flattenDirectoryOptions(item.children || [], depth + 1), + ]); +} + +function buildForm(item: KnowledgeFAQ | null, initialDirectoryId = 0): EditForm { if (!item) { - return emptyForm; + return { ...emptyForm, directoryId: String(initialDirectoryId) }; } return { + directoryId: String(item.directoryId || 0), question: item.question, answer: item.answer, similarQuestionsText: (item.similarQuestions ?? []).join("\n"), @@ -66,6 +83,7 @@ function buildForm(item: KnowledgeFAQ | null): EditForm { function buildPayload(form: EditForm, knowledgeBaseId: number): CreateKnowledgeFAQPayload { return { knowledgeBaseId, + directoryId: Number(form.directoryId), question: form.question.trim(), answer: form.answer.trim(), similarQuestions: form.similarQuestionsText @@ -81,6 +99,7 @@ export function FAQEditDialog({ saving, itemId, knowledgeBaseId, + initialDirectoryId = 0, onOpenChange, onSubmit, }: FAQEditDialogProps) { @@ -94,6 +113,7 @@ export function FAQEditDialog({ saving={saving} itemId={itemId} knowledgeBaseId={knowledgeBaseId} + initialDirectoryId={initialDirectoryId} onOpenChange={onOpenChange} onSubmit={onSubmit} /> @@ -105,6 +125,7 @@ type FAQEditDialogBodyProps = { saving: boolean; itemId: number | null; knowledgeBaseId: number; + initialDirectoryId: number; onOpenChange: (open: boolean) => void; onSubmit: (payload: CreateKnowledgeFAQPayload) => Promise; }; @@ -114,11 +135,13 @@ function FAQEditDialogBody({ saving, itemId, knowledgeBaseId, + initialDirectoryId, onOpenChange, onSubmit, }: FAQEditDialogBodyProps) { const t = useI18n(); const [loading, setLoading] = useState(false); + const [directories, setDirectories] = useState([]); const formId = "knowledge-faq-edit-form"; const formSchema = useMemo(() => createFormSchema(t), [t]); const resolver = useMemo( @@ -135,11 +158,18 @@ function FAQEditDialogBody({ register, formState: { errors }, } = form; + const directoryOptions = useMemo( + () => [ + { value: "0", label: t("knowledge.rootContent") }, + ...flattenDirectoryOptions(directories), + ], + [directories, t], + ); useEffect(() => { async function loadDetail() { if (!itemId) { - reset(emptyForm); + reset(buildForm(null, initialDirectoryId)); return; } setLoading(true); @@ -153,7 +183,28 @@ function FAQEditDialogBody({ if (open) { void loadDetail(); } - }, [itemId, open, reset]); + }, [itemId, initialDirectoryId, open, reset]); + + useEffect(() => { + if (!open) { + return; + } + let cancelled = false; + async function loadDirectories() { + try { + const data = await fetchKnowledgeDirectories(knowledgeBaseId); + if (!cancelled) { + setDirectories(data); + } + } catch (error) { + console.error("Failed to load knowledge directories:", error); + } + } + void loadDirectories(); + return () => { + cancelled = true; + }; + }, [knowledgeBaseId, open]); async function onFormSubmit(values: EditForm) { await onSubmit(buildPayload(values, knowledgeBaseId)); @@ -181,6 +232,21 @@ function FAQEditDialogBody({
{t("knowledge.loading")}
) : ( + + {t("knowledge.directory")} + + form.setValue("directoryId", value ?? "0", { shouldDirty: true })} + options={directoryOptions} + placeholder={t("knowledge.selectDirectory")} + searchPlaceholder={t("knowledge.searchDirectory")} + emptyText={t("knowledge.emptyDirectory")} + /> + + + + {t("knowledge.standardQuestion")} diff --git a/web/app/dashboard/knowledge/_components/faq-list.tsx b/web/app/dashboard/knowledge/_components/faq-list.tsx index 5030b8f..26f6345 100644 --- a/web/app/dashboard/knowledge/_components/faq-list.tsx +++ b/web/app/dashboard/knowledge/_components/faq-list.tsx @@ -31,6 +31,7 @@ import { useI18n } from "@/i18n/provider"; import { formatDateTime } from "@/lib/utils"; import { FAQEditDialog } from "./faq-edit"; import { FAQImportDialog } from "./faq-import-dialog"; +import { KnowledgeDirectoryPanel } from "./knowledge-directory-panel"; type FAQListProps = { knowledgeBaseId: number | null; @@ -121,10 +122,15 @@ export function FAQList({ const t = useI18n(); const [importing, setImporting] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false); + const [selectedDirectoryId, setSelectedDirectoryId] = useState(null); const [crudActionState, setCrudActionState] = useState(null); const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]); + useEffect(() => { + setSelectedDirectoryId(null); + }, [knowledgeBaseId]); + useEffect(() => { if (!crudActionState) { return; @@ -206,9 +212,16 @@ export function FAQList({ return ( <> -
+
+ crudActionState?.onRefresh()} + /> +
- key={knowledgeBaseId} + key={`${knowledgeBaseId}-${selectedDirectoryId ?? "all"}`} layout="fragment" showToolbarActions={false} filters={filters} @@ -216,6 +229,8 @@ export function FAQList({ fetchList={(query) => fetchKnowledgeFAQs({ knowledgeBaseId, + directoryId: + selectedDirectoryId === null ? undefined : selectedDirectoryId, question: typeof query.question === "string" ? query.question : undefined, indexStatus: @@ -264,6 +279,7 @@ export function FAQList({ saving={saving} itemId={itemId} knowledgeBaseId={knowledgeBaseId} + initialDirectoryId={selectedDirectoryId ?? 0} onOpenChange={onOpenChange} onSubmit={onSubmit} /> @@ -289,6 +305,7 @@ export function FAQList({ deleted: () => t("knowledge.faqDeleted"), }} /> +
void; + onChanged?: () => void; +}; + +type DirectoryDialogState = { + open: boolean; + id: number | null; + parentId: number; + name: string; + remark: string; +}; + +type DirectoryOption = { value: string; label: string }; + +function rootDirectoryOptions(items: KnowledgeDirectory[]): DirectoryOption[] { + return items.map((item) => ({ value: String(item.id), label: item.name })); +} + +function collectExpandedIds(items: KnowledgeDirectory[]) { + const ids = new Set(); + const walk = (nodes: KnowledgeDirectory[]) => { + for (const node of nodes) { + ids.add(node.id); + walk(node.children || []); + } + }; + walk(items); + return ids; +} + +export function KnowledgeDirectoryPanel({ + knowledgeBaseId, + selectedDirectoryId, + onSelectDirectory, + onChanged, +}: KnowledgeDirectoryPanelProps) { + const t = useI18n(); + const [directories, setDirectories] = useState([]); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [dialog, setDialog] = useState({ + open: false, + id: null, + parentId: 0, + name: "", + remark: "", + }); + const parentOptions = useMemo( + () => [ + { value: "0", label: t("knowledge.rootDirectory") }, + ...rootDirectoryOptions(directories).filter((item) => item.value !== String(dialog.id ?? "")), + ], + [directories, dialog.id, t], + ); + + const loadDirectories = useCallback(async () => { + setLoading(true); + try { + const data = await fetchKnowledgeDirectories(knowledgeBaseId); + setDirectories(data); + setExpandedIds(collectExpandedIds(data)); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("knowledge.loadDirectoriesFailed")); + } finally { + setLoading(false); + } + }, [knowledgeBaseId, t]); + + useEffect(() => { + void loadDirectories(); + }, [loadDirectories]); + + function toggleDirectory(id: number) { + setExpandedIds((current) => { + const next = new Set(current); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + } + + function openCreate(parentId = 0) { + setDialog({ open: true, id: null, parentId, name: "", remark: "" }); + } + + function openEdit(item: KnowledgeDirectory) { + setDialog({ + open: true, + id: item.id, + parentId: item.parentId, + name: item.name, + remark: item.remark || "", + }); + } + + async function handleSubmit() { + const name = dialog.name.trim(); + if (!name) { + toast.error(t("knowledge.directoryNameRequired")); + return; + } + setSaving(true); + try { + if (dialog.id) { + await updateKnowledgeDirectory({ + id: dialog.id, + knowledgeBaseId, + parentId: dialog.parentId, + name, + remark: dialog.remark.trim(), + }); + toast.success(t("knowledge.directoryUpdated", { name })); + } else { + await createKnowledgeDirectory({ + knowledgeBaseId, + parentId: dialog.parentId, + name, + remark: dialog.remark.trim(), + }); + toast.success(t("knowledge.directoryCreated", { name })); + } + setDialog((current) => ({ ...current, open: false })); + await loadDirectories(); + onChanged?.(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("knowledge.directorySaveFailed")); + } finally { + setSaving(false); + } + } + + async function handleDelete(item: KnowledgeDirectory) { + setSaving(true); + try { + await deleteKnowledgeDirectory(item.id); + if (selectedDirectoryId === item.id) { + onSelectDirectory(null); + } + toast.success(t("knowledge.directoryDeleted", { name: item.name })); + await loadDirectories(); + onChanged?.(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("knowledge.directoryDeleteFailed")); + } finally { + setSaving(false); + } + } + + return ( + <> +
+
+
{t("knowledge.directory")}
+ +
+ +
+ } + label={t("knowledge.allContent")} + selected={selectedDirectoryId === null} + onClick={() => onSelectDirectory(null)} + /> + } + label={t("knowledge.rootContent")} + selected={selectedDirectoryId === 0} + onClick={() => onSelectDirectory(0)} + /> + {directories.map((item) => ( + void handleDelete(directory)} + t={t} + /> + ))} +
+
+
+ setDialog((current) => ({ ...current, open }))} + title={dialog.id ? t("knowledge.editDirectory") : t("knowledge.createDirectory")} + size="sm" + footer={ + <> + + + + } + > +
+ + {t("knowledge.parentDirectory")} + + + setDialog((current) => ({ ...current, parentId: Number(value ?? 0) })) + } + options={parentOptions} + placeholder={t("knowledge.selectDirectory")} + searchPlaceholder={t("knowledge.searchDirectory")} + emptyText={t("knowledge.emptyDirectory")} + /> + + + + {t("knowledge.directoryName")} + + + setDialog((current) => ({ ...current, name: event.target.value })) + } + placeholder={t("knowledge.directoryNamePlaceholder")} + /> + + + + {t("knowledge.remark")} + +