diff --git a/go.mod b/go.mod index dc3fec0..a9f67ac 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/silenceper/wechat/v2 v2.1.12 github.com/spf13/cast v1.6.0 github.com/wk8/go-ordered-map/v2 v2.1.8 + github.com/xuri/excelize/v2 v2.10.1 github.com/yuin/goldmark v1.4.13 golang.org/x/crypto v0.49.0 golang.org/x/net v0.51.0 @@ -86,6 +87,8 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/sirupsen/logrus v1.9.4 // indirect @@ -94,8 +97,11 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect github.com/yargevad/filepathx v1.0.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect diff --git a/go.sum b/go.sum index a8741c2..532b993 100644 --- a/go.sum +++ b/go.sum @@ -239,6 +239,10 @@ github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRC github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= @@ -289,6 +293,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= @@ -297,6 +303,12 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= +github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= @@ -335,6 +347,8 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 853a6dc..4e81ef6 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -262,9 +262,12 @@ func registerDashboardKnowledgeDocumentRoutes(group *gin.RouterGroup) { } func registerDashboardKnowledgeFAQRoutes(group *gin.RouterGroup) { + group.GET("/import_template", dashboard.KnowledgeFAQGetImport_template) + group.GET("/export", dashboard.KnowledgeFAQGetExport) group.GET("/:id", dashboard.KnowledgeFAQGetBy) group.POST("/create", dashboard.KnowledgeFAQPostCreate) group.POST("/delete", dashboard.KnowledgeFAQPostDelete) + group.POST("/import", dashboard.KnowledgeFAQPostImport) group.Any("/list", dashboard.KnowledgeFAQAnyList) group.POST("/update", dashboard.KnowledgeFAQPostUpdate) } diff --git a/internal/handlers/dashboard/knowledge_faq_handler.go b/internal/handlers/dashboard/knowledge_faq_handler.go index d922c1a..81750ea 100644 --- a/internal/handlers/dashboard/knowledge_faq_handler.go +++ b/internal/handlers/dashboard/knowledge_faq_handler.go @@ -5,8 +5,11 @@ import ( "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/services" + "fmt" + "net/http" "agent-desk/internal/pkg/httpx/params" @@ -14,6 +17,79 @@ import ( "github.com/mlogclub/simple/web" ) +func KnowledgeFAQGetImport_template(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + file, err := services.KnowledgeFAQService.BuildKnowledgeFAQImportTemplate() + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + writeKnowledgeFAQExcelFile(ctx, file) +} + +func KnowledgeFAQGetExport(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + knowledgeBaseID, ok := params.GetInt64(ctx, "knowledgeBaseId") + if !ok || knowledgeBaseID <= 0 { + httpx.WriteJSON(ctx, errorsx.InvalidParam("知识库不存在")) + return + } + file, err := services.KnowledgeFAQService.ExportKnowledgeFAQs(knowledgeBaseID) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + writeKnowledgeFAQExcelFile(ctx, file) +} + +func KnowledgeFAQPostImport(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQCreate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + mode := request.KnowledgeFAQImportMode(ctx.PostForm("mode")) + if mode == request.KnowledgeFAQImportModeOverwrite { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQUpdate); err != nil { + httpx.WriteJSON(ctx, err) + return + } + } + knowledgeBaseID, ok := params.GetInt64(ctx, "knowledgeBaseId") + if !ok || knowledgeBaseID <= 0 { + httpx.WriteJSON(ctx, errorsx.InvalidParam("知识库不存在")) + return + } + header, err := ctx.FormFile("file") + if err != nil { + httpx.WriteJSON(ctx, errorsx.InvalidParam("请选择导入文件")) + return + } + file, err := header.Open() + if err != nil { + httpx.WriteJSON(ctx, errorsx.InvalidParam("导入文件读取失败")) + return + } + defer file.Close() + result, err := services.KnowledgeFAQService.ImportKnowledgeFAQs(request.ImportKnowledgeFAQRequest{ + KnowledgeBaseID: knowledgeBaseID, + Mode: mode, + Filename: header.Filename, + Reader: file, + }, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, result) +} + func KnowledgeFAQAnyList(ctx *gin.Context) { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQView); err != nil { httpx.WriteJSON(ctx, err) @@ -106,3 +182,9 @@ func KnowledgeFAQPostDelete(ctx *gin.Context) { } httpx.WriteJSON(ctx, nil) } + +func writeKnowledgeFAQExcelFile(ctx *gin.Context, file *response.KnowledgeFAQExportedFile) { + ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, file.Filename)) + ctx.Header("Cache-Control", "no-store") + ctx.Data(http.StatusOK, file.ContentType, file.Data) +} diff --git a/internal/pkg/dto/request/knowledge_request.go b/internal/pkg/dto/request/knowledge_request.go index b1f100d..1136327 100644 --- a/internal/pkg/dto/request/knowledge_request.go +++ b/internal/pkg/dto/request/knowledge_request.go @@ -1,6 +1,10 @@ package request -import "agent-desk/internal/pkg/enums" +import ( + "io" + + "agent-desk/internal/pkg/enums" +) type CreateKnowledgeBaseRequest struct { Name string `json:"name"` @@ -47,6 +51,20 @@ type UpdateKnowledgeFAQRequest struct { CreateKnowledgeFAQRequest } +type KnowledgeFAQImportMode string + +const ( + KnowledgeFAQImportModeAppend KnowledgeFAQImportMode = "append" + KnowledgeFAQImportModeOverwrite KnowledgeFAQImportMode = "overwrite" +) + +type ImportKnowledgeFAQRequest struct { + KnowledgeBaseID int64 + Mode KnowledgeFAQImportMode + Filename string + Reader io.Reader +} + type KnowledgeSearchRequest struct { KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"` Question string `json:"question"` diff --git a/internal/pkg/dto/response/knowledge_response.go b/internal/pkg/dto/response/knowledge_response.go index dd63103..f224ac7 100644 --- a/internal/pkg/dto/response/knowledge_response.go +++ b/internal/pkg/dto/response/knowledge_response.go @@ -90,6 +90,26 @@ type KnowledgeFAQResponse struct { UpdateUserName string `json:"updateUserName"` } +type KnowledgeFAQExportedFile struct { + Filename string + ContentType string + Data []byte +} + +type KnowledgeFAQImportError struct { + Row int `json:"row"` + Message string `json:"message"` +} + +type KnowledgeFAQImportResult struct { + Total int `json:"total"` + Created int `json:"created"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + Errors []KnowledgeFAQImportError `json:"errors"` +} + type KnowledgeSearchResult struct { KnowledgeBaseID int64 `json:"knowledgeBaseId"` ChunkID int64 `json:"chunkId"` diff --git a/internal/repositories/knowledge_faq_repository.go b/internal/repositories/knowledge_faq_repository.go index b036435..b97513b 100644 --- a/internal/repositories/knowledge_faq_repository.go +++ b/internal/repositories/knowledge_faq_repository.go @@ -2,6 +2,7 @@ package repositories import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/httpx/params" @@ -65,6 +66,19 @@ func (r *knowledgeFAQRepository) FindByIDs(db *gorm.DB, ids []int64) (list []mod return } +func (r *knowledgeFAQRepository) FindAllByKnowledgeBaseID(db *gorm.DB, knowledgeBaseID int64) (list []models.KnowledgeFAQ) { + db.Where("knowledge_base_id = ? AND status <> ?", knowledgeBaseID, enums.StatusDeleted).Order("id DESC").Find(&list) + return +} + +func (r *knowledgeFAQRepository) FindByKnowledgeBaseIDAndQuestions(db *gorm.DB, knowledgeBaseID int64, questions []string) (list []models.KnowledgeFAQ) { + if len(questions) == 0 { + return nil + } + db.Where("knowledge_base_id = ? AND question IN ?", knowledgeBaseID, questions).Find(&list) + return +} + func (r *knowledgeFAQRepository) CountByKnowledgeBaseID(db *gorm.DB, knowledgeBaseID int64) int64 { var count int64 db.Model(&models.KnowledgeFAQ{}).Where("knowledge_base_id = ?", knowledgeBaseID).Count(&count) diff --git a/internal/services/knowledge_faq_excel.go b/internal/services/knowledge_faq_excel.go new file mode 100644 index 0000000..d1ff464 --- /dev/null +++ b/internal/services/knowledge_faq_excel.go @@ -0,0 +1,370 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "path/filepath" + "strings" + "time" + + "agent-desk/internal/ai/rag" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" + "github.com/xuri/excelize/v2" +) + +const ( + knowledgeFAQExcelSheetName = "FAQ" + knowledgeFAQExcelContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +) + +var indexKnowledgeFAQAfterImport = func(faqID int64) error { + return rag.Index.IndexFAQByID(context.Background(), faqID) +} + +type knowledgeFAQImportRow struct { + RowNo int + Question string + Answer string + SimilarQuestions []string + Remark string +} + +func (s *knowledgeFAQService) BuildKnowledgeFAQImportTemplate() (*response.KnowledgeFAQExportedFile, error) { + workbook := excelize.NewFile() + defer workbook.Close() + if err := writeKnowledgeFAQWorkbookHeader(workbook); err != nil { + return nil, err + } + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "A2", "如何重置密码?") + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "B2", "进入个人设置后点击重置密码。") + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "C2", "忘记密码怎么办\n重置密码在哪里") + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, "D2", "账号") + return buildKnowledgeFAQExcelFile("knowledge-faq-import-template.xlsx", workbook) +} + +func (s *knowledgeFAQService) ExportKnowledgeFAQs(knowledgeBaseID int64) (*response.KnowledgeFAQExportedFile, error) { + if _, err := s.requireFAQKnowledgeBase(knowledgeBaseID); err != nil { + return nil, err + } + list := repositories.KnowledgeFAQRepository.FindAllByKnowledgeBaseID(sqls.DB(), knowledgeBaseID) + workbook := excelize.NewFile() + defer workbook.Close() + if err := writeKnowledgeFAQWorkbookHeader(workbook); err != nil { + return nil, err + } + for index, item := range list { + rowNo := index + 2 + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, fmt.Sprintf("A%d", rowNo), item.Question) + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, fmt.Sprintf("B%d", rowNo), item.Answer) + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, fmt.Sprintf("C%d", rowNo), strings.Join(decodeKnowledgeFAQSimilarQuestions(item.SimilarQuestions), "\n")) + _ = workbook.SetCellValue(knowledgeFAQExcelSheetName, fmt.Sprintf("D%d", rowNo), item.Remark) + } + filename := fmt.Sprintf("knowledge-faq-%d-%s.xlsx", knowledgeBaseID, time.Now().Format("20060102150405")) + return buildKnowledgeFAQExcelFile(filename, workbook) +} + +func (s *knowledgeFAQService) ImportKnowledgeFAQs(req request.ImportKnowledgeFAQRequest, operator *dto.AuthPrincipal) (*response.KnowledgeFAQImportResult, error) { + if operator == nil { + return nil, errorsx.Unauthorized("未登录或登录已过期") + } + if req.Mode != request.KnowledgeFAQImportModeAppend && req.Mode != request.KnowledgeFAQImportModeOverwrite { + return nil, errorsx.InvalidParam("导入模式不合法") + } + if req.Reader == nil { + return nil, errorsx.InvalidParam("请选择导入文件") + } + if strings.ToLower(filepath.Ext(req.Filename)) != ".xlsx" { + return nil, errorsx.InvalidParam("仅支持.xlsx文件") + } + kb, err := s.requireFAQKnowledgeBase(req.KnowledgeBaseID) + if err != nil { + return nil, err + } + rows, result, err := parseKnowledgeFAQImportRows(req.Reader) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return result, nil + } + + questions := make([]string, 0, len(rows)) + for _, row := range rows { + questions = append(questions, row.Question) + } + existingList := repositories.KnowledgeFAQRepository.FindByKnowledgeBaseIDAndQuestions(sqls.DB(), req.KnowledgeBaseID, questions) + existingMap := make(map[string]models.KnowledgeFAQ, len(existingList)) + for _, item := range existingList { + if _, ok := existingMap[item.Question]; !ok { + existingMap[item.Question] = item + } + } + + indexFAQIDs := make([]int64, 0, len(rows)) + err = sqls.WithTransaction(func(ctx *sqls.TxContext) error { + for _, row := range rows { + existing, exists := existingMap[row.Question] + if exists && req.Mode == request.KnowledgeFAQImportModeAppend { + result.Skipped++ + result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: row.RowNo, Message: "标准问题已存在,已跳过"}) + continue + } + + similarQuestions, marshalErr := json.Marshal(row.SimilarQuestions) + if marshalErr != nil { + result.Failed++ + result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: row.RowNo, Message: "相似问格式不合法"}) + continue + } + + if exists { + if updateErr := repositories.KnowledgeFAQRepository.Updates(ctx.Tx, existing.ID, map[string]any{ + "answer": row.Answer, + "similar_questions": string(similarQuestions), + "index_status": enums.KnowledgeDocumentIndexStatusPending, + "indexed_at": nil, + "index_error": "", + "remark": row.Remark, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }); updateErr != nil { + return updateErr + } + result.Updated++ + indexFAQIDs = append(indexFAQIDs, existing.ID) + continue + } + + item := &models.KnowledgeFAQ{ + KnowledgeBaseID: req.KnowledgeBaseID, + Question: row.Question, + Answer: row.Answer, + SimilarQuestions: string(similarQuestions), + Status: kb.Status, + IndexStatus: enums.KnowledgeDocumentIndexStatusPending, + IndexError: "", + IndexedAt: nil, + Remark: row.Remark, + AuditFields: utils.BuildAuditFields(operator), + } + if createErr := repositories.KnowledgeFAQRepository.Create(ctx.Tx, item); createErr != nil { + return createErr + } + result.Created++ + indexFAQIDs = append(indexFAQIDs, item.ID) + } + return nil + }) + if err != nil { + return nil, err + } + + for _, faqID := range indexFAQIDs { + if indexErr := indexKnowledgeFAQAfterImport(faqID); indexErr != nil { + slog.Error("failed to index imported knowledge faq", "faq_id", faqID, "error", indexErr) + } + } + return result, nil +} + +func writeKnowledgeFAQWorkbookHeader(workbook *excelize.File) error { + sheet := workbook.GetSheetName(0) + if sheet == "" { + sheet = knowledgeFAQExcelSheetName + workbook.NewSheet(sheet) + } else if sheet != knowledgeFAQExcelSheetName { + if err := workbook.SetSheetName(sheet, knowledgeFAQExcelSheetName); err != nil { + return err + } + } + headers := []string{"标准问题", "答案", "相似问", "备注"} + for index, header := range headers { + cell, err := excelize.CoordinatesToCellName(index+1, 1) + if err != nil { + return err + } + if err := workbook.SetCellValue(knowledgeFAQExcelSheetName, cell, header); err != nil { + return err + } + } + style, err := workbook.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true}, + Alignment: &excelize.Alignment{WrapText: true, Vertical: "top"}, + }) + if err == nil { + _ = workbook.SetCellStyle(knowledgeFAQExcelSheetName, "A1", "D1", style) + } + _ = workbook.SetColWidth(knowledgeFAQExcelSheetName, "A", "A", 36) + _ = workbook.SetColWidth(knowledgeFAQExcelSheetName, "B", "B", 60) + _ = workbook.SetColWidth(knowledgeFAQExcelSheetName, "C", "C", 40) + _ = workbook.SetColWidth(knowledgeFAQExcelSheetName, "D", "D", 24) + return nil +} + +func buildKnowledgeFAQExcelFile(filename string, workbook *excelize.File) (*response.KnowledgeFAQExportedFile, error) { + var buf bytes.Buffer + if err := workbook.Write(&buf); err != nil { + return nil, err + } + return &response.KnowledgeFAQExportedFile{ + Filename: filename, + ContentType: knowledgeFAQExcelContentType, + Data: buf.Bytes(), + }, nil +} + +func parseKnowledgeFAQImportRows(reader io.Reader) ([]knowledgeFAQImportRow, *response.KnowledgeFAQImportResult, error) { + workbook, err := excelize.OpenReader(reader) + if err != nil { + return nil, nil, errorsx.InvalidParam("Excel文件解析失败") + } + defer workbook.Close() + sheet := knowledgeFAQExcelSheetName + if index, _ := workbook.GetSheetIndex(sheet); index < 0 { + sheets := workbook.GetSheetList() + if len(sheets) == 0 { + return nil, nil, errorsx.InvalidParam("Excel文件为空") + } + sheet = sheets[0] + } + table, err := workbook.GetRows(sheet) + if err != nil { + return nil, nil, errorsx.InvalidParam("Excel文件读取失败") + } + if len(table) == 0 { + return nil, nil, errorsx.InvalidParam("Excel文件为空") + } + headerMap := buildKnowledgeFAQHeaderMap(table[0]) + if _, ok := headerMap["question"]; !ok { + return nil, nil, errorsx.InvalidParam("缺少标准问题列") + } + if _, ok := headerMap["answer"]; !ok { + return nil, nil, errorsx.InvalidParam("缺少答案列") + } + + result := &response.KnowledgeFAQImportResult{Errors: make([]response.KnowledgeFAQImportError, 0)} + rows := make([]knowledgeFAQImportRow, 0, len(table)-1) + seen := make(map[string]int) + for index := 1; index < len(table); index++ { + current := table[index] + if isKnowledgeFAQExcelRowEmpty(current) { + continue + } + result.Total++ + rowNo := index + 1 + row := knowledgeFAQImportRow{ + RowNo: rowNo, + Question: getKnowledgeFAQExcelCell(current, headerMap["question"]), + Answer: getKnowledgeFAQExcelCell(current, headerMap["answer"]), + SimilarQuestions: parseKnowledgeFAQSimilarQuestions(getKnowledgeFAQExcelCell(current, headerMap["similarQuestions"])), + Remark: getKnowledgeFAQExcelCell(current, headerMap["remark"]), + } + if row.Question == "" { + result.Failed++ + result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: "问题不能为空"}) + continue + } + if len([]rune(row.Question)) > 500 { + result.Failed++ + result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: "问题不能超过500字"}) + continue + } + if row.Answer == "" { + result.Failed++ + result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: "答案不能为空"}) + continue + } + if firstRow, exists := seen[row.Question]; exists { + result.Failed++ + result.Errors = append(result.Errors, response.KnowledgeFAQImportError{Row: rowNo, Message: fmt.Sprintf("同一文件中标准问题重复,首次出现于第%d行", firstRow)}) + continue + } + seen[row.Question] = rowNo + rows = append(rows, row) + } + return rows, result, nil +} + +func buildKnowledgeFAQHeaderMap(headerRow []string) map[string]int { + accepted := map[string]string{ + "标准问题": "question", + "问题": "question", + "question": "question", + "答案": "answer", + "answer": "answer", + "相似问": "similarQuestions", + "相似问题": "similarQuestions", + "similarquestions": "similarQuestions", + "备注": "remark", + "remark": "remark", + } + result := make(map[string]int) + for index, header := range headerRow { + key := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(header, "\uFEFF"))) + if field, ok := accepted[key]; ok { + if _, exists := result[field]; !exists { + result[field] = index + } + } + } + return result +} + +func getKnowledgeFAQExcelCell(row []string, index int) string { + if index < 0 || index >= len(row) { + return "" + } + return strings.TrimSpace(row[index]) +} + +func isKnowledgeFAQExcelRowEmpty(row []string) bool { + for _, cell := range row { + if strings.TrimSpace(cell) != "" { + return false + } + } + return true +} + +func parseKnowledgeFAQSimilarQuestions(value string) []string { + lines := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n") + items := make([]string, 0, len(lines)) + seen := make(map[string]struct{}, len(lines)) + for _, line := range lines { + item := strings.TrimSpace(strings.ReplaceAll(line, "\r", "")) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + items = append(items, item) + } + return items +} + +func decodeKnowledgeFAQSimilarQuestions(value string) []string { + var items []string + if strings.TrimSpace(value) == "" { + return nil + } + if err := json.Unmarshal([]byte(value), &items); err != nil { + return nil + } + return items +} diff --git a/internal/services/knowledge_faq_excel_test.go b/internal/services/knowledge_faq_excel_test.go new file mode 100644 index 0000000..922d4eb --- /dev/null +++ b/internal/services/knowledge_faq_excel_test.go @@ -0,0 +1,249 @@ +package services + +import ( + "bytes" + "encoding/json" + "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" + "github.com/xuri/excelize/v2" + "gorm.io/gorm" +) + +func TestKnowledgeFAQExcelTemplateContainsExpectedHeaders(t *testing.T) { + file, err := KnowledgeFAQService.BuildKnowledgeFAQImportTemplate() + if err != nil { + t.Fatalf("BuildKnowledgeFAQImportTemplate() error = %v", err) + } + + workbook, err := excelize.OpenReader(bytes.NewReader(file.Data)) + if err != nil { + t.Fatalf("open template workbook: %v", err) + } + defer workbook.Close() + + rows, err := workbook.GetRows("FAQ") + if err != nil { + t.Fatalf("get FAQ rows: %v", err) + } + if len(rows) == 0 { + t.Fatal("template has no rows") + } + want := []string{"标准问题", "答案", "相似问", "备注"} + for i, value := range want { + if rows[0][i] != value { + t.Fatalf("header %d = %q, want %q", i, rows[0][i], value) + } + } +} + +func TestKnowledgeFAQExcelExportWritesSimilarQuestionsWithLineBreaks(t *testing.T) { + setupKnowledgeFAQExcelTestDB(t) + kb := createKnowledgeFAQExcelTestBase(t, enums.StatusOk) + similarQuestions, _ := json.Marshal([]string{"怎么重置密码", "忘记密码怎么办"}) + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), &models.KnowledgeFAQ{ + KnowledgeBaseID: kb.ID, + Question: "如何重置密码?", + Answer: "进入个人设置后点击重置密码。", + SimilarQuestions: string(similarQuestions), + Status: enums.StatusOk, + IndexStatus: enums.KnowledgeDocumentIndexStatusPending, + Remark: "账号", + }); err != nil { + t.Fatalf("create faq: %v", err) + } + + file, err := KnowledgeFAQService.ExportKnowledgeFAQs(kb.ID) + if err != nil { + t.Fatalf("ExportKnowledgeFAQs() error = %v", err) + } + workbook, err := excelize.OpenReader(bytes.NewReader(file.Data)) + if err != nil { + t.Fatalf("open export workbook: %v", err) + } + defer workbook.Close() + + cell, err := workbook.GetCellValue("FAQ", "C2") + if err != nil { + t.Fatalf("get C2: %v", err) + } + if cell != "怎么重置密码\n忘记密码怎么办" { + t.Fatalf("similar questions cell = %q", cell) + } +} + +func TestImportKnowledgeFAQsAppendSkipsExistingQuestion(t *testing.T) { + setupKnowledgeFAQExcelTestDB(t) + restore := stubKnowledgeFAQImportIndex(t) + defer restore() + kb := createKnowledgeFAQExcelTestBase(t, enums.StatusOk) + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), &models.KnowledgeFAQ{ + KnowledgeBaseID: kb.ID, + Question: "如何重置密码?", + Answer: "旧答案", + Status: enums.StatusOk, + IndexStatus: enums.KnowledgeDocumentIndexStatusIndexed, + }); err != nil { + t.Fatalf("create faq: %v", err) + } + + result, err := KnowledgeFAQService.ImportKnowledgeFAQs(request.ImportKnowledgeFAQRequest{ + KnowledgeBaseID: kb.ID, + Mode: request.KnowledgeFAQImportModeAppend, + Filename: "faq.xlsx", + Reader: bytes.NewReader(buildKnowledgeFAQExcelWorkbook(t, [][]string{ + {"标准问题", "答案", "相似问", "备注"}, + {"如何重置密码?", "新答案", "重置密码在哪里\n忘记密码", "账号"}, + {"支持哪些渠道?", "支持网页和企业微信。", "", "渠道"}, + })), + }, knowledgeFAQExcelTestOperator()) + if err != nil { + t.Fatalf("ImportKnowledgeFAQs() error = %v", err) + } + if result.Created != 1 || result.Updated != 0 || result.Skipped != 1 || result.Failed != 0 { + t.Fatalf("result = %+v", result) + } + + list := repositories.KnowledgeFAQRepository.Find(sqls.DB(), sqls.NewCnd().Eq("knowledge_base_id", kb.ID)) + if len(list) != 2 { + t.Fatalf("faq count = %d, want 2", len(list)) + } + existing := repositories.KnowledgeFAQRepository.Find(sqls.DB(), sqls.NewCnd().Eq("question", "如何重置密码?")) + if len(existing) != 1 || existing[0].Answer != "旧答案" { + t.Fatalf("existing faq changed: %+v", existing) + } +} + +func TestImportKnowledgeFAQsOverwriteUpdatesExistingQuestion(t *testing.T) { + setupKnowledgeFAQExcelTestDB(t) + restore := stubKnowledgeFAQImportIndex(t) + defer restore() + kb := createKnowledgeFAQExcelTestBase(t, enums.StatusDisabled) + if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), &models.KnowledgeFAQ{ + KnowledgeBaseID: kb.ID, + Question: "如何重置密码?", + Answer: "旧答案", + Status: enums.StatusOk, + IndexStatus: enums.KnowledgeDocumentIndexStatusIndexed, + }); err != nil { + t.Fatalf("create faq: %v", err) + } + + result, err := KnowledgeFAQService.ImportKnowledgeFAQs(request.ImportKnowledgeFAQRequest{ + KnowledgeBaseID: kb.ID, + Mode: request.KnowledgeFAQImportModeOverwrite, + Filename: "faq.xlsx", + Reader: bytes.NewReader(buildKnowledgeFAQExcelWorkbook(t, [][]string{ + {"标准问题", "答案", "相似问", "备注"}, + {"如何重置密码?", "新答案", "重置密码在哪里\n重置密码在哪里\n忘记密码", "账号"}, + {"支持哪些渠道?", "支持网页和企业微信。", "", "渠道"}, + })), + }, knowledgeFAQExcelTestOperator()) + if err != nil { + t.Fatalf("ImportKnowledgeFAQs() error = %v", err) + } + if result.Created != 1 || result.Updated != 1 || result.Skipped != 0 || result.Failed != 0 { + t.Fatalf("result = %+v", result) + } + + updated := repositories.KnowledgeFAQRepository.Find(sqls.DB(), sqls.NewCnd().Eq("question", "如何重置密码?")) + if len(updated) != 1 { + t.Fatalf("updated faq count = %d", len(updated)) + } + if updated[0].Answer != "新答案" { + t.Fatalf("updated answer = %q", updated[0].Answer) + } + if updated[0].IndexStatus != enums.KnowledgeDocumentIndexStatusPending { + t.Fatalf("index status = %q", updated[0].IndexStatus) + } + var similar []string + if err := json.Unmarshal([]byte(updated[0].SimilarQuestions), &similar); err != nil { + t.Fatalf("unmarshal similar questions: %v", err) + } + if len(similar) != 2 || similar[0] != "重置密码在哪里" || similar[1] != "忘记密码" { + t.Fatalf("similar questions = %#v", similar) + } + + created := repositories.KnowledgeFAQRepository.Find(sqls.DB(), sqls.NewCnd().Eq("question", "支持哪些渠道?")) + if len(created) != 1 || created[0].Status != enums.StatusDisabled { + t.Fatalf("created faq = %+v", created) + } +} + +func setupKnowledgeFAQExcelTestDB(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.KnowledgeFAQ{}, &models.KnowledgeChunk{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) +} + +func createKnowledgeFAQExcelTestBase(t *testing.T, status enums.Status) *models.KnowledgeBase { + t.Helper() + item := &models.KnowledgeBase{ + Name: "FAQ", + KnowledgeType: string(enums.KnowledgeBaseTypeFAQ), + Status: status, + } + if err := repositories.KnowledgeBaseRepository.Create(sqls.DB(), item); err != nil { + t.Fatalf("create knowledge base: %v", err) + } + return item +} + +func buildKnowledgeFAQExcelWorkbook(t *testing.T, rows [][]string) []byte { + t.Helper() + workbook := excelize.NewFile() + defer workbook.Close() + sheet := workbook.GetSheetName(0) + if err := workbook.SetSheetName(sheet, "FAQ"); err != nil { + t.Fatalf("set sheet name: %v", err) + } + for rowIndex, row := range rows { + for colIndex, value := range row { + cell, err := excelize.CoordinatesToCellName(colIndex+1, rowIndex+1) + if err != nil { + t.Fatalf("cell name: %v", err) + } + if err := workbook.SetCellValue("FAQ", cell, value); err != nil { + t.Fatalf("set cell value: %v", err) + } + } + } + var buf bytes.Buffer + if err := workbook.Write(&buf); err != nil { + t.Fatalf("write workbook: %v", err) + } + return buf.Bytes() +} + +func knowledgeFAQExcelTestOperator() *dto.AuthPrincipal { + return &dto.AuthPrincipal{UserID: 1, Username: "admin"} +} + +func stubKnowledgeFAQImportIndex(t *testing.T) func() { + t.Helper() + original := indexKnowledgeFAQAfterImport + indexed := make([]int64, 0) + indexKnowledgeFAQAfterImport = func(faqID int64) error { + indexed = append(indexed, faqID) + return nil + } + return func() { + if len(indexed) == 0 { + t.Fatalf("expected imported FAQs to be indexed") + } + indexKnowledgeFAQAfterImport = original + } +} diff --git a/web/app/dashboard/knowledge/_components/faq-import-dialog.tsx b/web/app/dashboard/knowledge/_components/faq-import-dialog.tsx index b86e955..d11dc41 100644 --- a/web/app/dashboard/knowledge/_components/faq-import-dialog.tsx +++ b/web/app/dashboard/knowledge/_components/faq-import-dialog.tsx @@ -1,209 +1,36 @@ -"use client"; +"use client" -import { DownloadIcon, FileUpIcon, InfoIcon } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; +import { DownloadIcon, FileUpIcon, InfoIcon } from "lucide-react" +import { useMemo, useRef, useState } from "react" +import { toast } from "sonner" -import { ProjectDialog } from "@/components/project-dialog"; -import { Button } from "@/components/ui/button"; +import { OptionCombobox } from "@/components/option-combobox" +import { ProjectDialog } from "@/components/project-dialog" +import { Button } from "@/components/ui/button" import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, -} from "@/components/ui/field"; -import { Input } from "@/components/ui/input"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { createKnowledgeFAQ, type CreateKnowledgeFAQPayload } from "@/lib/api/admin"; -import { useI18n } from "@/i18n/provider"; +} from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + downloadKnowledgeFAQImportTemplate, + importKnowledgeFAQs, + type KnowledgeFAQImportMode, + type KnowledgeFAQImportResult, +} from "@/lib/api/admin" +import { useI18n } from "@/i18n/provider" type FAQImportDialogProps = { - open: boolean; - knowledgeBaseId: number | null; - importing: boolean; - onOpenChange: (open: boolean) => void; - onImportingChange: (importing: boolean) => void; - onImported: () => Promise; -}; - -type ParsedFAQRow = { - rowNo: number; - question: string; - answer: string; - similarQuestions: string[]; - remark: string; -}; - -type ParseResult = { - rows: ParsedFAQRow[]; - warnings: string[]; -}; - -type TFunction = (key: string, values?: Record) => string; - -const acceptedHeaderMap: Record> = { - question: "question", - answer: "answer", - similarquestions: "similarQuestions", - "similarQuestions": "similarQuestions", - remark: "remark", - "\u6807\u51c6\u95ee\u9898": "question", - "\u95ee\u9898": "question", - "\u7b54\u6848": "answer", - "\u76f8\u4f3c\u95ee": "similarQuestions", - "\u76f8\u4f3c\u95ee\u9898": "similarQuestions", - "\u5907\u6ce8": "remark", -}; - -function normalizeHeader(value: string) { - return value.trim().replace(/^\uFEFF/, "").toLowerCase(); -} - -function parseDelimitedText(input: string): string[][] { - const text = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - const rows: string[][] = []; - let row: string[] = []; - let cell = ""; - let inQuotes = false; - let delimiter = ","; - - function pushCell() { - row.push(cell.trim()); - cell = ""; - } - - function pushRow() { - if (row.length === 1 && row[0] === "" && rows.length === 0) { - row = []; - return; - } - if (row.some((item) => item !== "")) { - rows.push(row); - } - row = []; - } - - for (let i = 0; i < text.length; i += 1) { - const char = text[i]; - const next = text[i + 1]; - - if (!inQuotes && rows.length === 0 && row.length === 0 && cell.length > 0 && char === "\t") { - delimiter = "\t"; - } - - if (char === '"') { - if (inQuotes && next === '"') { - cell += '"'; - i += 1; - continue; - } - inQuotes = !inQuotes; - continue; - } - - if (!inQuotes && char === delimiter) { - pushCell(); - continue; - } - - if (!inQuotes && char === "\n") { - pushCell(); - pushRow(); - continue; - } - - cell += char; - } - - if (cell.length > 0 || row.length > 0) { - pushCell(); - pushRow(); - } - - return rows; -} - -function parseSimilarQuestions(value: string) { - return value - .split(/\r?\n|\|/g) - .map((item) => item.trim()) - .filter(Boolean); -} - -function parseFAQFileContent(input: string, t: TFunction): ParseResult { - const table = parseDelimitedText(input); - if (table.length === 0) { - throw new Error(t("knowledge.fileEmpty")); - } - - const headerRow = table[0]; - const headerMap = new Map, number>(); - for (let index = 0; index < headerRow.length; index += 1) { - const header = acceptedHeaderMap[normalizeHeader(headerRow[index])]; - if (header && !headerMap.has(header)) { - headerMap.set(header, index); - } - } - - if (!headerMap.has("question") || !headerMap.has("answer")) { - throw new Error(t("knowledge.missingFAQColumns")); - } - - const rows: ParsedFAQRow[] = []; - const warnings: string[] = []; - - for (let index = 1; index < table.length; index += 1) { - const current = table[index]; - const rowNo = index + 1; - const question = current[headerMap.get("question") ?? -1]?.trim() ?? ""; - const answer = current[headerMap.get("answer") ?? -1]?.trim() ?? ""; - const similarQuestionsRaw = current[headerMap.get("similarQuestions") ?? -1]?.trim() ?? ""; - const remark = current[headerMap.get("remark") ?? -1]?.trim() ?? ""; - - if (!question && !answer && !similarQuestionsRaw && !remark) { - continue; - } - if (!question || !answer) { - warnings.push(t("knowledge.skipRowMissingFAQ", { row: rowNo })); - continue; - } - - rows.push({ - rowNo, - question, - answer, - similarQuestions: parseSimilarQuestions(similarQuestionsRaw), - remark, - }); - } - - return { rows, warnings }; -} - -function downloadTemplate() { - const templateContent = [ - "question,answer,similarQuestions,remark", - '"How do I reset my password?","Open profile settings and choose Reset Password.","forgot password|where is reset password","Account FAQ"', - '"Which channels are supported?","Web chat and WeCom customer service channels are currently supported.","available channels|supported channels","Channel guide"', - ].join("\n"); - const blob = new Blob([templateContent], { type: "text/csv;charset=utf-8;" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = "knowledge-faq-import-template.csv"; - link.click(); - URL.revokeObjectURL(url); -} - -function buildPayload(row: ParsedFAQRow, knowledgeBaseId: number): CreateKnowledgeFAQPayload { - return { - knowledgeBaseId, - question: row.question, - answer: row.answer, - similarQuestions: row.similarQuestions, - remark: row.remark, - }; + open: boolean + knowledgeBaseId: number | null + importing: boolean + onOpenChange: (open: boolean) => void + onImportingChange: (importing: boolean) => void + onImported: () => Promise } export function FAQImportDialog({ @@ -214,85 +41,86 @@ export function FAQImportDialog({ onImportingChange, onImported, }: FAQImportDialogProps) { - const t = useI18n(); - const fileInputRef = useRef(null); - const [fileName, setFileName] = useState(""); - const [rows, setRows] = useState([]); - const [warnings, setWarnings] = useState([]); + const t = useI18n() + const fileInputRef = useRef(null) + const [file, setFile] = useState(null) + const [mode, setMode] = useState("append") + const [result, setResult] = useState(null) - const previewRows = useMemo(() => rows.slice(0, 5), [rows]); + const modeOptions = useMemo( + () => [ + { value: "append", label: t("knowledge.importModeAppend") }, + { value: "overwrite", label: t("knowledge.importModeOverwrite") }, + ], + [t], + ) function resetState() { - setFileName(""); - setRows([]); - setWarnings([]); + setFile(null) + setMode("append") + setResult(null) if (fileInputRef.current) { - fileInputRef.current.value = ""; + fileInputRef.current.value = "" } } - async function handleFileChange(event: React.ChangeEvent) { - const file = event.target.files?.[0]; - if (!file) { - return; + function handleFileChange(event: React.ChangeEvent) { + const nextFile = event.target.files?.[0] ?? null + setResult(null) + if (!nextFile) { + setFile(null) + return } - - try { - const content = await file.text(); - const parsed = parseFAQFileContent(content, t); - setFileName(file.name); - setRows(parsed.rows); - setWarnings(parsed.warnings); - if (parsed.rows.length === 0) { - toast.error(t("knowledge.importNoRows")); - } else { - toast.success(t("knowledge.parsedFAQRows", { count: parsed.rows.length })); + if (!nextFile.name.toLowerCase().endsWith(".xlsx")) { + setFile(null) + if (fileInputRef.current) { + fileInputRef.current.value = "" } + toast.error(t("knowledge.importXlsxOnly")) + return + } + setFile(nextFile) + } + + async function handleDownloadTemplate() { + try { + await downloadKnowledgeFAQImportTemplate() } catch (error) { - resetState(); - toast.error(error instanceof Error ? error.message : t("knowledge.parseImportFailed")); + toast.error(error instanceof Error ? error.message : t("knowledge.downloadTemplateFailed")) } } async function handleImport() { - if (!knowledgeBaseId || rows.length === 0 || importing) { - return; + if (!knowledgeBaseId || !file || importing) { + return } - onImportingChange(true); - let successCount = 0; - const failedRows: string[] = []; - + onImportingChange(true) + setResult(null) try { - for (const row of rows) { - try { - await createKnowledgeFAQ(buildPayload(row, knowledgeBaseId)); - successCount += 1; - } catch (error) { - failedRows.push( - t("knowledge.importRowFailed", { - row: row.rowNo, - message: error instanceof Error ? error.message : t("knowledge.importFailed"), - }) - ); - } + const data = await importKnowledgeFAQs({ + knowledgeBaseId, + mode, + file, + }) + setResult(data) + await onImported() + toast.success( + t("knowledge.importFAQResultToast", { + created: data.created, + updated: data.updated, + skipped: data.skipped, + failed: data.failed, + }), + ) + if (data.failed === 0) { + resetState() + onOpenChange(false) } - - await onImported(); - - if (successCount > 0) { - toast.success(t("knowledge.importSuccess", { count: successCount })); - } - if (failedRows.length > 0) { - toast.error(t("knowledge.importSomeFailed", { count: failedRows.length })); - setWarnings((current) => [...current, ...failedRows]); - return; - } - - resetState(); - onOpenChange(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("knowledge.importFailed")) } finally { - onImportingChange(false); + onImportingChange(false) } } @@ -301,33 +129,49 @@ export function FAQImportDialog({ open={open} onOpenChange={(nextOpen) => { if (!nextOpen && !importing) { - resetState(); + resetState() } - onOpenChange(nextOpen); + onOpenChange(nextOpen) }} title={t("knowledge.importFAQTitle")} description={t("knowledge.importFAQDescription")} size="lg" footer={ <> - - } > + + {t("knowledge.importMode")} + + setMode(value as KnowledgeFAQImportMode)} + /> + + {mode === "overwrite" + ? t("knowledge.importModeOverwriteDescription") + : t("knowledge.importModeAppendDescription")} + + + + {t("knowledge.importFile")} @@ -336,79 +180,76 @@ export function FAQImportDialog({ id="faq-import-file" ref={fileInputRef} type="file" - accept=".csv,text/csv,.txt" - onChange={(event) => void handleFileChange(event)} + accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + disabled={importing} + onChange={handleFileChange} /> - - {t("knowledge.importFileDescription")} - + {t("knowledge.importFileDescription")} - {fileName ? ( + {file ? (
- {t("knowledge.currentFile", { name: fileName })} + {t("knowledge.currentFile", { name: file.name })}
) : null} - {warnings.length > 0 ? ( -
-
- - {t("knowledge.importHint")} -
-
    - {warnings.map((item, index) => ( -
  • {item}
  • - ))} -
+
+
+ + {t("knowledge.importHint")}
- ) : null} - -
-
- {t("knowledge.importPreview")} -
- {previewRows.length > 0 ? ( - -
- {previewRows.map((row) => ( -
-
- {t("knowledge.rowNumber", { row: row.rowNo })} -
-
-
{row.question}
-
- {row.answer} -
-
-
- {t("knowledge.similarQuestionShort", { - value: row.similarQuestions.length > 0 ? row.similarQuestions.join(" / ") : t("knowledge.none"), - })} -
-
{t("knowledge.remark")}:{row.remark || t("knowledge.none")}
-
- ))} -
-
- ) : ( -
- {t("knowledge.previewAfterUpload")} -
- )} +
{t("knowledge.importXlsxHint")}
+ + {result ? ( +
+
+ {t("knowledge.importResult")} +
+
+ + + + + +
+ {result.errors.length > 0 ? ( + +
    + {result.errors.map((item, index) => ( +
  • + {t("knowledge.importRowFailed", { + row: item.row, + message: item.message, + })} +
  • + ))} +
+
+ ) : null} +
+ ) : null} - ); + ) +} + +function ImportMetric({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{value}
+
+ ) } diff --git a/web/app/dashboard/knowledge/page.tsx b/web/app/dashboard/knowledge/page.tsx index e988d3f..34c9b74 100644 --- a/web/app/dashboard/knowledge/page.tsx +++ b/web/app/dashboard/knowledge/page.tsx @@ -9,6 +9,7 @@ import { } from "@/components/ui/sheet" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import type { KnowledgeBase } from "@/lib/api/admin" +import { exportKnowledgeFAQs } from "@/lib/api/admin" import { useI18n } from "@/i18n/provider" import { Bug, @@ -18,9 +19,11 @@ import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, - RefreshCwIcon + RefreshCwIcon, + UploadIcon, } from "lucide-react" import { useState } from "react" +import { toast } from "sonner" import { DebugPanel } from "./_components/debug-panel" import { DocumentList, type DocumentListActionState } from "./_components/document-list" import { FAQList, type FAQListActionState } from "./_components/faq-list" @@ -35,8 +38,24 @@ export default function DashboardKnowledgeDocumentsPage() { const [activeTab, setActiveTab] = useState("documents") const [documentActionState, setDocumentActionState] = useState(null) const [faqActionState, setFAQActionState] = useState(null) + const [exportingFAQ, setExportingFAQ] = useState(false) const isFAQKnowledgeBase = selectedKnowledgeBase?.knowledgeType === "faq" + async function handleExportFAQ() { + if (!selectedKnowledgeBase || exportingFAQ) { + return + } + setExportingFAQ(true) + try { + await exportKnowledgeFAQs(selectedKnowledgeBase.id) + toast.success(t("knowledge.exportFAQSuccess")) + } catch (error) { + toast.error(error instanceof Error ? error.message : t("knowledge.exportFAQFailed")) + } finally { + setExportingFAQ(false) + } + } + return (
- + + +