feat(knowledge): add FAQ Excel import export

This commit is contained in:
mlogclub
2026-06-01 13:57:49 +08:00
parent d8692b45cd
commit 8933e963f5
15 changed files with 1133 additions and 363 deletions
+6
View File
@@ -28,6 +28,7 @@ require (
github.com/silenceper/wechat/v2 v2.1.12 github.com/silenceper/wechat/v2 v2.1.12
github.com/spf13/cast v1.6.0 github.com/spf13/cast v1.6.0
github.com/wk8/go-ordered-map/v2 v2.1.8 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 github.com/yuin/goldmark v1.4.13
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
golang.org/x/net v0.51.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/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.4 // indirect github.com/segmentio/encoding v0.5.4 // indirect
github.com/sirupsen/logrus v1.9.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/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.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/yargevad/filepathx v1.0.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
+14
View File
@@ -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-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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 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= 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/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 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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= 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/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 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= 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 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= 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/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 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= 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.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
+3
View File
@@ -262,9 +262,12 @@ func registerDashboardKnowledgeDocumentRoutes(group *gin.RouterGroup) {
} }
func registerDashboardKnowledgeFAQRoutes(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.GET("/:id", dashboard.KnowledgeFAQGetBy)
group.POST("/create", dashboard.KnowledgeFAQPostCreate) group.POST("/create", dashboard.KnowledgeFAQPostCreate)
group.POST("/delete", dashboard.KnowledgeFAQPostDelete) group.POST("/delete", dashboard.KnowledgeFAQPostDelete)
group.POST("/import", dashboard.KnowledgeFAQPostImport)
group.Any("/list", dashboard.KnowledgeFAQAnyList) group.Any("/list", dashboard.KnowledgeFAQAnyList)
group.POST("/update", dashboard.KnowledgeFAQPostUpdate) group.POST("/update", dashboard.KnowledgeFAQPostUpdate)
} }
@@ -5,8 +5,11 @@ import (
"agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx" "agent-desk/internal/pkg/httpx"
"agent-desk/internal/services" "agent-desk/internal/services"
"fmt"
"net/http"
"agent-desk/internal/pkg/httpx/params" "agent-desk/internal/pkg/httpx/params"
@@ -14,6 +17,79 @@ import (
"github.com/mlogclub/simple/web" "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) { func KnowledgeFAQAnyList(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQView); err != nil { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQView); err != nil {
httpx.WriteJSON(ctx, err) httpx.WriteJSON(ctx, err)
@@ -106,3 +182,9 @@ func KnowledgeFAQPostDelete(ctx *gin.Context) {
} }
httpx.WriteJSON(ctx, nil) 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)
}
+19 -1
View File
@@ -1,6 +1,10 @@
package request package request
import "agent-desk/internal/pkg/enums" import (
"io"
"agent-desk/internal/pkg/enums"
)
type CreateKnowledgeBaseRequest struct { type CreateKnowledgeBaseRequest struct {
Name string `json:"name"` Name string `json:"name"`
@@ -47,6 +51,20 @@ type UpdateKnowledgeFAQRequest struct {
CreateKnowledgeFAQRequest 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 { type KnowledgeSearchRequest struct {
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"` KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
Question string `json:"question"` Question string `json:"question"`
@@ -90,6 +90,26 @@ type KnowledgeFAQResponse struct {
UpdateUserName string `json:"updateUserName"` 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 { type KnowledgeSearchResult struct {
KnowledgeBaseID int64 `json:"knowledgeBaseId"` KnowledgeBaseID int64 `json:"knowledgeBaseId"`
ChunkID int64 `json:"chunkId"` ChunkID int64 `json:"chunkId"`
@@ -2,6 +2,7 @@ package repositories
import ( import (
"agent-desk/internal/models" "agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/httpx/params" "agent-desk/internal/pkg/httpx/params"
@@ -65,6 +66,19 @@ func (r *knowledgeFAQRepository) FindByIDs(db *gorm.DB, ids []int64) (list []mod
return 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 { func (r *knowledgeFAQRepository) CountByKnowledgeBaseID(db *gorm.DB, knowledgeBaseID int64) int64 {
var count int64 var count int64
db.Model(&models.KnowledgeFAQ{}).Where("knowledge_base_id = ?", knowledgeBaseID).Count(&count) db.Model(&models.KnowledgeFAQ{}).Where("knowledge_base_id = ?", knowledgeBaseID).Count(&count)
+370
View File
@@ -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
}
@@ -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
}
}
@@ -1,209 +1,36 @@
"use client"; "use client"
import { DownloadIcon, FileUpIcon, InfoIcon } from "lucide-react"; import { DownloadIcon, FileUpIcon, InfoIcon } from "lucide-react"
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react"
import { toast } from "sonner"; import { toast } from "sonner"
import { ProjectDialog } from "@/components/project-dialog"; import { OptionCombobox } from "@/components/option-combobox"
import { Button } from "@/components/ui/button"; import { ProjectDialog } from "@/components/project-dialog"
import { Button } from "@/components/ui/button"
import { import {
Field, Field,
FieldContent, FieldContent,
FieldDescription, FieldDescription,
FieldGroup, FieldGroup,
FieldLabel, FieldLabel,
} from "@/components/ui/field"; } from "@/components/ui/field"
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area"
import { createKnowledgeFAQ, type CreateKnowledgeFAQPayload } from "@/lib/api/admin"; import {
import { useI18n } from "@/i18n/provider"; downloadKnowledgeFAQImportTemplate,
importKnowledgeFAQs,
type KnowledgeFAQImportMode,
type KnowledgeFAQImportResult,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
type FAQImportDialogProps = { type FAQImportDialogProps = {
open: boolean; open: boolean
knowledgeBaseId: number | null; knowledgeBaseId: number | null
importing: boolean; importing: boolean
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void
onImportingChange: (importing: boolean) => void; onImportingChange: (importing: boolean) => void
onImported: () => Promise<void>; onImported: () => Promise<void>
};
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, string | number>) => string;
const acceptedHeaderMap: Record<string, keyof Omit<ParsedFAQRow, "rowNo">> = {
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<keyof Omit<ParsedFAQRow, "rowNo">, 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,
};
} }
export function FAQImportDialog({ export function FAQImportDialog({
@@ -214,85 +41,86 @@ export function FAQImportDialog({
onImportingChange, onImportingChange,
onImported, onImported,
}: FAQImportDialogProps) { }: FAQImportDialogProps) {
const t = useI18n(); const t = useI18n()
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null)
const [fileName, setFileName] = useState(""); const [file, setFile] = useState<File | null>(null)
const [rows, setRows] = useState<ParsedFAQRow[]>([]); const [mode, setMode] = useState<KnowledgeFAQImportMode>("append")
const [warnings, setWarnings] = useState<string[]>([]); const [result, setResult] = useState<KnowledgeFAQImportResult | null>(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() { function resetState() {
setFileName(""); setFile(null)
setRows([]); setMode("append")
setWarnings([]); setResult(null)
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ""; fileInputRef.current.value = ""
} }
} }
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) { function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]; const nextFile = event.target.files?.[0] ?? null
if (!file) { setResult(null)
return; if (!nextFile) {
setFile(null)
return
} }
if (!nextFile.name.toLowerCase().endsWith(".xlsx")) {
try { setFile(null)
const content = await file.text(); if (fileInputRef.current) {
const parsed = parseFAQFileContent(content, t); fileInputRef.current.value = ""
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 }));
} }
toast.error(t("knowledge.importXlsxOnly"))
return
}
setFile(nextFile)
}
async function handleDownloadTemplate() {
try {
await downloadKnowledgeFAQImportTemplate()
} catch (error) { } catch (error) {
resetState(); toast.error(error instanceof Error ? error.message : t("knowledge.downloadTemplateFailed"))
toast.error(error instanceof Error ? error.message : t("knowledge.parseImportFailed"));
} }
} }
async function handleImport() { async function handleImport() {
if (!knowledgeBaseId || rows.length === 0 || importing) { if (!knowledgeBaseId || !file || importing) {
return; return
} }
onImportingChange(true); onImportingChange(true)
let successCount = 0; setResult(null)
const failedRows: string[] = [];
try { try {
for (const row of rows) { const data = await importKnowledgeFAQs({
try { knowledgeBaseId,
await createKnowledgeFAQ(buildPayload(row, knowledgeBaseId)); mode,
successCount += 1; file,
} catch (error) { })
failedRows.push( setResult(data)
t("knowledge.importRowFailed", { await onImported()
row: row.rowNo, toast.success(
message: error instanceof Error ? error.message : t("knowledge.importFailed"), t("knowledge.importFAQResultToast", {
}) created: data.created,
); updated: data.updated,
} skipped: data.skipped,
failed: data.failed,
}),
)
if (data.failed === 0) {
resetState()
onOpenChange(false)
} }
} catch (error) {
await onImported(); toast.error(error instanceof Error ? error.message : t("knowledge.importFailed"))
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);
} finally { } finally {
onImportingChange(false); onImportingChange(false)
} }
} }
@@ -301,33 +129,49 @@ export function FAQImportDialog({
open={open} open={open}
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {
if (!nextOpen && !importing) { if (!nextOpen && !importing) {
resetState(); resetState()
} }
onOpenChange(nextOpen); onOpenChange(nextOpen)
}} }}
title={t("knowledge.importFAQTitle")} title={t("knowledge.importFAQTitle")}
description={t("knowledge.importFAQDescription")} description={t("knowledge.importFAQDescription")}
size="lg" size="lg"
footer={ footer={
<> <>
<Button type="button" variant="outline" onClick={() => downloadTemplate()}> <Button type="button" variant="outline" onClick={() => void handleDownloadTemplate()}>
<DownloadIcon className="size-4" /> <DownloadIcon className="size-4" />
{t("knowledge.downloadTemplate")} {t("knowledge.downloadTemplate")}
</Button> </Button>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={importing}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
{t("knowledge.cancel")} {t("knowledge.cancel")}
</Button> </Button>
<Button type="button" onClick={() => void handleImport()} disabled={importing || rows.length === 0}> <Button type="button" onClick={() => void handleImport()} disabled={importing || !file}>
{importing {importing ? t("knowledge.importing") : t("knowledge.startImport")}
? t("knowledge.importing")
: rows.length > 0
? t("knowledge.startImportWithCount", { count: rows.length })
: t("knowledge.startImport")}
</Button> </Button>
</> </>
} }
> >
<FieldGroup> <FieldGroup>
<Field>
<FieldLabel>{t("knowledge.importMode")}</FieldLabel>
<FieldContent>
<OptionCombobox
value={mode}
options={modeOptions}
placeholder={t("knowledge.selectImportMode")}
searchPlaceholder={t("knowledge.searchImportMode")}
emptyText={t("knowledge.emptyImportMode")}
disabled={importing}
onChange={(value) => setMode(value as KnowledgeFAQImportMode)}
/>
<FieldDescription>
{mode === "overwrite"
? t("knowledge.importModeOverwriteDescription")
: t("knowledge.importModeAppendDescription")}
</FieldDescription>
</FieldContent>
</Field>
<Field> <Field>
<FieldLabel htmlFor="faq-import-file">{t("knowledge.importFile")}</FieldLabel> <FieldLabel htmlFor="faq-import-file">{t("knowledge.importFile")}</FieldLabel>
<FieldContent> <FieldContent>
@@ -336,79 +180,76 @@ export function FAQImportDialog({
id="faq-import-file" id="faq-import-file"
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept=".csv,text/csv,.txt" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onChange={(event) => void handleFileChange(event)} disabled={importing}
onChange={handleFileChange}
/> />
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
disabled={importing}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
> >
<FileUpIcon className="size-4" /> <FileUpIcon className="size-4" />
{t("knowledge.chooseFile")} {t("knowledge.chooseFile")}
</Button> </Button>
</div> </div>
<FieldDescription> <FieldDescription>{t("knowledge.importFileDescription")}</FieldDescription>
{t("knowledge.importFileDescription")}
</FieldDescription>
</FieldContent> </FieldContent>
</Field> </Field>
{fileName ? ( {file ? (
<div className="rounded-md border bg-muted/20 px-3 py-2 text-sm"> <div className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
{t("knowledge.currentFile", { name: fileName })} {t("knowledge.currentFile", { name: file.name })}
</div> </div>
) : null} ) : null}
{warnings.length > 0 ? ( <div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"> <div className="mb-2 flex items-center gap-2 font-medium">
<div className="mb-2 flex items-center gap-2 font-medium"> <InfoIcon className="size-4" />
<InfoIcon className="size-4" /> {t("knowledge.importHint")}
{t("knowledge.importHint")}
</div>
<ul className="space-y-1">
{warnings.map((item, index) => (
<li key={`${item}-${index}`}>{item}</li>
))}
</ul>
</div> </div>
) : null} <div>{t("knowledge.importXlsxHint")}</div>
<div className="rounded-md border">
<div className="border-b px-4 py-3 text-sm font-medium">
{t("knowledge.importPreview")}
</div>
{previewRows.length > 0 ? (
<ScrollArea className="max-h-80">
<div className="divide-y">
{previewRows.map((row) => (
<div key={row.rowNo} className="space-y-2 px-4 py-3 text-sm">
<div>
<span className="text-muted-foreground">{t("knowledge.rowNumber", { row: row.rowNo })}</span>
</div>
<div>
<div className="font-medium">{row.question}</div>
<div className="mt-1 whitespace-pre-wrap text-muted-foreground">
{row.answer}
</div>
</div>
<div className="text-muted-foreground">
{t("knowledge.similarQuestionShort", {
value: row.similarQuestions.length > 0 ? row.similarQuestions.join(" / ") : t("knowledge.none"),
})}
</div>
<div className="text-muted-foreground">{t("knowledge.remark")}{row.remark || t("knowledge.none")}</div>
</div>
))}
</div>
</ScrollArea>
) : (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
{t("knowledge.previewAfterUpload")}
</div>
)}
</div> </div>
{result ? (
<div className="rounded-md border">
<div className="border-b px-4 py-3 text-sm font-medium">
{t("knowledge.importResult")}
</div>
<div className="grid grid-cols-2 gap-3 p-4 text-sm sm:grid-cols-5">
<ImportMetric label={t("knowledge.importTotal")} value={result.total} />
<ImportMetric label={t("knowledge.importCreated")} value={result.created} />
<ImportMetric label={t("knowledge.importUpdated")} value={result.updated} />
<ImportMetric label={t("knowledge.importSkipped")} value={result.skipped} />
<ImportMetric label={t("knowledge.importFailedCount")} value={result.failed} />
</div>
{result.errors.length > 0 ? (
<ScrollArea className="max-h-64 border-t">
<ul className="divide-y text-sm">
{result.errors.map((item, index) => (
<li key={`${item.row}-${index}`} className="px-4 py-2">
{t("knowledge.importRowFailed", {
row: item.row,
message: item.message,
})}
</li>
))}
</ul>
</ScrollArea>
) : null}
</div>
) : null}
</FieldGroup> </FieldGroup>
</ProjectDialog> </ProjectDialog>
); )
}
function ImportMetric({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-md bg-muted/30 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-lg font-semibold">{value}</div>
</div>
)
} }
+31 -2
View File
@@ -9,6 +9,7 @@ import {
} from "@/components/ui/sheet" } from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import type { KnowledgeBase } from "@/lib/api/admin" import type { KnowledgeBase } from "@/lib/api/admin"
import { exportKnowledgeFAQs } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider" import { useI18n } from "@/i18n/provider"
import { import {
Bug, Bug,
@@ -18,9 +19,11 @@ import {
PanelLeftCloseIcon, PanelLeftCloseIcon,
PanelLeftOpenIcon, PanelLeftOpenIcon,
PlusIcon, PlusIcon,
RefreshCwIcon RefreshCwIcon,
UploadIcon,
} from "lucide-react" } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { toast } from "sonner"
import { DebugPanel } from "./_components/debug-panel" import { DebugPanel } from "./_components/debug-panel"
import { DocumentList, type DocumentListActionState } from "./_components/document-list" import { DocumentList, type DocumentListActionState } from "./_components/document-list"
import { FAQList, type FAQListActionState } from "./_components/faq-list" import { FAQList, type FAQListActionState } from "./_components/faq-list"
@@ -35,8 +38,24 @@ export default function DashboardKnowledgeDocumentsPage() {
const [activeTab, setActiveTab] = useState("documents") const [activeTab, setActiveTab] = useState("documents")
const [documentActionState, setDocumentActionState] = useState<DocumentListActionState | null>(null) const [documentActionState, setDocumentActionState] = useState<DocumentListActionState | null>(null)
const [faqActionState, setFAQActionState] = useState<FAQListActionState | null>(null) const [faqActionState, setFAQActionState] = useState<FAQListActionState | null>(null)
const [exportingFAQ, setExportingFAQ] = useState(false)
const isFAQKnowledgeBase = selectedKnowledgeBase?.knowledgeType === "faq" 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 ( return (
<div className="flex h-[calc(100vh-4rem)]"> <div className="flex h-[calc(100vh-4rem)]">
<div <div
@@ -142,7 +161,17 @@ export default function DashboardKnowledgeDocumentsPage() {
disabled={faqActionState.importing} disabled={faqActionState.importing}
aria-label={t("knowledge.importFAQ")} aria-label={t("knowledge.importFAQ")}
> >
<DownloadIcon className="size-4" /> <UploadIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => void handleExportFAQ()}
disabled={exportingFAQ}
aria-label={t("knowledge.exportFAQ")}
>
<DownloadIcon className={exportingFAQ ? "size-4 animate-pulse" : "size-4"} />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
+53 -1
View File
@@ -1,5 +1,5 @@
import { readSession } from "@/lib/auth" import { readSession } from "@/lib/auth"
import { request } from "@/lib/api/client" import { request, requestBlob } from "@/lib/api/client"
import { createWebSocketBaseUrl } from "@/lib/api/websocket" import { createWebSocketBaseUrl } from "@/lib/api/websocket"
import { translateCurrentMessage } from "@/i18n/messages" import { translateCurrentMessage } from "@/i18n/messages"
@@ -1524,6 +1524,22 @@ export type UpdateKnowledgeFAQPayload = CreateKnowledgeFAQPayload & {
id: number id: number
} }
export type KnowledgeFAQImportMode = "append" | "overwrite"
export type KnowledgeFAQImportError = {
row: number
message: string
}
export type KnowledgeFAQImportResult = {
total: number
created: number
updated: number
skipped: number
failed: number
errors: KnowledgeFAQImportError[]
}
export function fetchKnowledgeBases( export function fetchKnowledgeBases(
query?: Record<string, string | number | undefined> query?: Record<string, string | number | undefined>
) { ) {
@@ -1652,6 +1668,42 @@ export function deleteKnowledgeFAQ(id: number) {
}) })
} }
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
export async function downloadKnowledgeFAQImportTemplate() {
const result = await requestBlob("/api/dashboard/knowledge-faq/import_template")
downloadBlob(result.blob, result.filename || "knowledge-faq-import-template.xlsx")
}
export async function exportKnowledgeFAQs(knowledgeBaseId: number) {
const result = await requestBlob(
`/api/dashboard/knowledge-faq/export${toQueryString({ knowledgeBaseId })}`
)
downloadBlob(result.blob, result.filename || `knowledge-faq-${knowledgeBaseId}.xlsx`)
}
export function importKnowledgeFAQs(payload: {
knowledgeBaseId: number
mode: KnowledgeFAQImportMode
file: File
}) {
const formData = new FormData()
formData.set("knowledgeBaseId", String(payload.knowledgeBaseId))
formData.set("mode", payload.mode)
formData.set("file", payload.file)
return request<KnowledgeFAQImportResult>("/api/dashboard/knowledge-faq/import", {
method: "POST",
body: formData,
})
}
export function buildKnowledgeFAQIndex(faqId: number) { export function buildKnowledgeFAQIndex(faqId: number) {
return request<void>("/api/dashboard/knowledge-retrieve/build", { return request<void>("/api/dashboard/knowledge-retrieve/build", {
method: "POST", method: "POST",
+65 -9
View File
@@ -18,6 +18,11 @@ type RequestOptions = RequestInit & {
onResponse?: (response: Response) => void onResponse?: (response: Response) => void
} }
export type BlobResponse = {
blob: Blob
filename: string
}
async function parseResult<T>(response: Response) { async function parseResult<T>(response: Response) {
const payload = (await response.json()) as JsonResult<T> const payload = (await response.json()) as JsonResult<T>
if (!response.ok || !payload.success) { if (!response.ok || !payload.success) {
@@ -31,13 +36,7 @@ async function parseResult<T>(response: Response) {
return payload.data return payload.data
} }
export async function request<T>( function buildRequestHeaders(headers: HeadersInit | undefined, skipAuth?: boolean, body?: BodyInit | null) {
path: string,
options: RequestOptions = {}
): Promise<T> {
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
delete (rest as RequestOptions).baseUrl
delete (rest as RequestOptions).onResponse
const session = readSession() const session = readSession()
const authHeaders = new Headers(headers) const authHeaders = new Headers(headers)
@@ -46,14 +45,37 @@ export async function request<T>(
} }
if ( if (
!authHeaders.has("Content-Type") && !authHeaders.has("Content-Type") &&
rest.body && body &&
!(typeof FormData !== "undefined" && rest.body instanceof FormData) !(typeof FormData !== "undefined" && body instanceof FormData)
) { ) {
authHeaders.set("Content-Type", "application/json") authHeaders.set("Content-Type", "application/json")
} }
const locale = readStoredLocale() const locale = readStoredLocale()
authHeaders.set("Accept-Language", locale) authHeaders.set("Accept-Language", locale)
authHeaders.set("X-Locale", locale) authHeaders.set("X-Locale", locale)
return authHeaders
}
function parseFilename(contentDisposition: string | null) {
if (!contentDisposition) {
return ""
}
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match?.[1]) {
return decodeURIComponent(utf8Match[1])
}
const match = contentDisposition.match(/filename="?([^";]+)"?/i)
return match?.[1] ? decodeURIComponent(match[1]) : ""
}
export async function request<T>(
path: string,
options: RequestOptions = {}
): Promise<T> {
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
delete (rest as RequestOptions).baseUrl
delete (rest as RequestOptions).onResponse
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
const response = await fetch(`${requestBaseUrl}${path}`, { const response = await fetch(`${requestBaseUrl}${path}`, {
@@ -65,3 +87,37 @@ export async function request<T>(
return parseResult<T>(response) return parseResult<T>(response)
} }
export async function requestBlob(
path: string,
options: RequestOptions = {}
): Promise<BlobResponse> {
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
delete (rest as RequestOptions).baseUrl
delete (rest as RequestOptions).onResponse
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
const response = await fetch(`${requestBaseUrl}${path}`, {
...rest,
headers: authHeaders,
cache: "no-store",
})
onResponse?.(response)
const contentType = response.headers.get("Content-Type") ?? ""
if (contentType.includes("application/json")) {
try {
await parseResult<never>(response)
} catch (error) {
throw error
}
throw new Error(translateCurrentMessage("api.requestFailed"))
}
if (!response.ok) {
throw new Error(response.statusText || translateCurrentMessage("api.requestFailed"))
}
return {
blob: await response.blob(),
filename: parseFilename(response.headers.get("Content-Disposition")),
}
}
+23 -15
View File
@@ -1894,32 +1894,40 @@
"answer": "Answer", "answer": "Answer",
"answerPlaceholder": "Enter the FAQ answer", "answerPlaceholder": "Enter the FAQ answer",
"similarQuestionsPlaceholder": "One similar question per line", "similarQuestionsPlaceholder": "One similar question per line",
"fileEmpty": "The file is empty.",
"missingFAQColumns": "The import template must include question and answer columns.",
"skipRowMissingFAQ": "Row {row} is missing a question or answer and was skipped.",
"importNoRows": "No FAQ rows to import.",
"parsedFAQRows": "{count} FAQ rows parsed.",
"parseImportFailed": "Could not parse the import file.",
"importRowFailed": "Row {row}: {message}", "importRowFailed": "Row {row}: {message}",
"importFailed": "Import failed.", "importFailed": "Import failed.",
"importSuccess": "{count} FAQs imported.",
"importSomeFailed": "{count} FAQs failed to import.",
"importFAQTitle": "Import FAQ", "importFAQTitle": "Import FAQ",
"importFAQDescription": "Upload a CSV file to import FAQs in bulk. Required columns: question and answer. Separate similarQuestions with | or line breaks.", "importFAQDescription": "Upload an Excel file to import FAQs in bulk. Required columns: primary question and answer. Put one similar question per line in the same cell.",
"downloadTemplate": "Download Template", "downloadTemplate": "Download Template",
"downloadTemplateFailed": "Could not download the template.",
"importing": "Importing...", "importing": "Importing...",
"startImport": "Start Import", "startImport": "Start Import",
"startImportWithCount": "Start Import ({count})",
"importFile": "Import File", "importFile": "Import File",
"chooseFile": "Choose File", "chooseFile": "Choose File",
"importFileDescription": "Supports UTF-8 CSV or tab-delimited text files.", "importFileDescription": "Only .xlsx files are supported.",
"currentFile": "Current file: {name}", "currentFile": "Current file: {name}",
"importHint": "Import Notes", "importHint": "Import Notes",
"importPreview": "Import Preview", "importXlsxHint": "The template columns are primary question, answer, similar questions, and remark. Put one similar question per line inside the cell.",
"rowNumber": "Row {row}", "importXlsxOnly": "Only .xlsx files are supported.",
"similarQuestionShort": "Similar: {value}", "importMode": "Import Mode",
"selectImportMode": "Select import mode",
"searchImportMode": "Search import mode",
"emptyImportMode": "No import modes found",
"importModeAppend": "Append Import",
"importModeOverwrite": "Overwrite Import",
"importModeAppendDescription": "Only creates primary questions that do not already exist. Existing primary questions are skipped.",
"importModeOverwriteDescription": "Matches by primary question and updates existing FAQs. Missing primary questions are created.",
"importResult": "Import Result",
"importTotal": "Total",
"importCreated": "Created",
"importUpdated": "Updated",
"importSkipped": "Skipped",
"importFailedCount": "Failed",
"importFAQResultToast": "Created {created}, updated {updated}, skipped {skipped}, failed {failed}",
"exportFAQ": "Export FAQ",
"exportFAQSuccess": "FAQ export download started.",
"exportFAQFailed": "Could not export FAQs.",
"none": "None", "none": "None",
"previewAfterUpload": "Upload a file to preview the first 5 FAQs.",
"allChannels": "All channels", "allChannels": "All channels",
"channelIM": "Support chat", "channelIM": "Support chat",
"channelAgentAssist": "Agent assist", "channelAgentAssist": "Agent assist",
+24 -16
View File
@@ -1893,33 +1893,41 @@
"questionPlaceholder": "请输入标准问题", "questionPlaceholder": "请输入标准问题",
"answer": "答案", "answer": "答案",
"answerPlaceholder": "请输入FAQ答案", "answerPlaceholder": "请输入FAQ答案",
"similarQuestionsPlaceholder": "一行一个相似问题" "similarQuestionsPlaceholder": "一行一个相似问题",
,"fileEmpty": "文件内容为空",
"missingFAQColumns": "导入模板缺少 question/answer 列",
"skipRowMissingFAQ": "第 {row} 行缺少问题或答案,已跳过",
"importNoRows": "没有可导入的FAQ记录",
"parsedFAQRows": "已解析 {count} 条FAQ",
"parseImportFailed": "解析导入文件失败",
"importRowFailed": "第 {row} 行:{message}", "importRowFailed": "第 {row} 行:{message}",
"importFailed": "导入失败", "importFailed": "导入失败",
"importSuccess": "成功导入 {count} 条FAQ",
"importSomeFailed": "有 {count} 条FAQ导入失败",
"importFAQTitle": "导入FAQ", "importFAQTitle": "导入FAQ",
"importFAQDescription": "上传 CSV 文件批量导入 FAQ。必填列为 question、answersimilarQuestions 使用 | 或换行分隔。", "importFAQDescription": "上传 Excel 文件批量导入 FAQ。必填列为标准问题和答案;相似问在同一个单元格内一行一个。",
"downloadTemplate": "下载模板", "downloadTemplate": "下载模板",
"downloadTemplateFailed": "下载模板失败",
"importing": "导入中...", "importing": "导入中...",
"startImport": "开始导入", "startImport": "开始导入",
"startImportWithCount": "开始导入 ({count})",
"importFile": "导入文件", "importFile": "导入文件",
"chooseFile": "选择文件", "chooseFile": "选择文件",
"importFileDescription": "支持 UTF-8 编码的 CSV 或制表符文本文件。", "importFileDescription": "支持 .xlsx 文件。",
"currentFile": "当前文件:{name}", "currentFile": "当前文件:{name}",
"importHint": "导入提示", "importHint": "导入提示",
"importPreview": "导入预览", "importXlsxHint": "模板列为标准问题、答案、相似问、备注;相似问请在单元格内一行填写一个。",
"rowNumber": "第 {row} 行", "importXlsxOnly": "仅支持 .xlsx 文件",
"similarQuestionShort": "相似问:{value}", "importMode": "导入模式",
"selectImportMode": "请选择导入模式",
"searchImportMode": "搜索导入模式",
"emptyImportMode": "没有可用的导入模式",
"importModeAppend": "追加导入",
"importModeOverwrite": "覆盖导入",
"importModeAppendDescription": "只新增不存在的标准问题;已存在的标准问题会跳过。",
"importModeOverwriteDescription": "按标准问题匹配并更新已存在 FAQ;不存在的标准问题会新增。",
"importResult": "导入结果",
"importTotal": "总行数",
"importCreated": "新增",
"importUpdated": "更新",
"importSkipped": "跳过",
"importFailedCount": "失败",
"importFAQResultToast": "新增 {created},更新 {updated},跳过 {skipped},失败 {failed}",
"exportFAQ": "导出FAQ",
"exportFAQSuccess": "FAQ 导出已开始下载",
"exportFAQFailed": "导出 FAQ 失败",
"none": "无", "none": "无",
"previewAfterUpload": "上传文件后可预览前 5 条FAQ",
"allChannels": "全部渠道", "allChannels": "全部渠道",
"channelIM": "客服会话", "channelIM": "客服会话",
"channelAgentAssist": "客服助手", "channelAgentAssist": "客服助手",