From 032d0ccdf0b99a7d98b68b98915ea3729ea3b7bc Mon Sep 17 00:00:00 2001 From: Echo <1711788888@qq.com> Date: Fri, 27 Feb 2026 23:15:30 +0800 Subject: [PATCH] =?UTF-8?q?:art:=20=E6=96=B0=E5=A2=9E=E4=B8=96=E7=95=8C?= =?UTF-8?q?=E4=B9=A6=E6=A8=A1=E5=9D=97=EF=BC=8C=E5=B0=86=E5=8E=9F=E6=9C=89?= =?UTF-8?q?=E7=9A=84=E4=B8=96=E7=95=8C=E4=B9=A6=E5=88=86=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Echo <1711788888@qq.com> --- server/api/v1/app/ai_config.go | 7 +- server/api/v1/app/character.go | 7 +- server/api/v1/app/conversation.go | 14 +- server/api/v1/app/enter.go | 1 + server/api/v1/app/preset.go | 6 +- server/api/v1/app/worldbook.go | 296 +++++++++++ server/initialize/gorm.go | 2 + server/initialize/router.go | 1 + server/model/app/request/worldbook.go | 64 +++ server/model/app/response/worldbook.go | 114 ++++ server/model/app/worldbook.go | 68 +++ server/router/app/enter.go | 1 + server/router/app/worldbook.go | 29 + server/service/app/enter.go | 1 + server/service/app/worldbook.go | 498 ++++++++++++++++++ web-app/src/App.tsx | 2 + web-app/src/api/worldbook.ts | 164 ++++++ web-app/src/pages/WorldbookManagePage.tsx | 613 ++++++++++++++++++++++ 18 files changed, 1880 insertions(+), 8 deletions(-) create mode 100644 server/api/v1/app/worldbook.go create mode 100644 server/model/app/request/worldbook.go create mode 100644 server/model/app/response/worldbook.go create mode 100644 server/model/app/worldbook.go create mode 100644 server/router/app/worldbook.go create mode 100644 server/service/app/worldbook.go create mode 100644 web-app/src/api/worldbook.ts create mode 100644 web-app/src/pages/WorldbookManagePage.tsx diff --git a/server/api/v1/app/ai_config.go b/server/api/v1/app/ai_config.go index 7362182..d383709 100644 --- a/server/api/v1/app/ai_config.go +++ b/server/api/v1/app/ai_config.go @@ -54,7 +54,12 @@ func (a *AIConfigApi) GetAIConfigList(c *gin.Context) { return } - commonResponse.OkWithData(resp, c) + commonResponse.OkWithDetailed(commonResponse.PageResult{ + List: resp.List, + Total: resp.Total, + Page: 0, + PageSize: 0, + }, "获取成功", c) } // UpdateAIConfig diff --git a/server/api/v1/app/character.go b/server/api/v1/app/character.go index ff1ceec..3fc3554 100644 --- a/server/api/v1/app/character.go +++ b/server/api/v1/app/character.go @@ -84,7 +84,12 @@ func (a *CharacterApi) GetCharacterList(c *gin.Context) { return } - commonResponse.OkWithData(resp, c) + commonResponse.OkWithDetailed(commonResponse.PageResult{ + List: resp.List, + Total: resp.Total, + Page: resp.Page, + PageSize: resp.PageSize, + }, "获取成功", c) } // GetCharacterByID diff --git a/server/api/v1/app/conversation.go b/server/api/v1/app/conversation.go index f62e842..99a3ba6 100644 --- a/server/api/v1/app/conversation.go +++ b/server/api/v1/app/conversation.go @@ -74,7 +74,12 @@ func (a *ConversationApi) GetConversationList(c *gin.Context) { return } - commonResponse.OkWithData(resp, c) + commonResponse.OkWithDetailed(commonResponse.PageResult{ + List: resp.List, + Total: resp.Total, + Page: resp.Page, + PageSize: resp.PageSize, + }, "获取成功", c) } // GetConversationByID @@ -199,7 +204,12 @@ func (a *ConversationApi) GetMessageList(c *gin.Context) { return } - commonResponse.OkWithData(resp, c) + commonResponse.OkWithDetailed(commonResponse.PageResult{ + List: resp.List, + Total: resp.Total, + Page: resp.Page, + PageSize: resp.PageSize, + }, "获取成功", c) } // RegenerateMessage diff --git a/server/api/v1/app/enter.go b/server/api/v1/app/enter.go index 303d1fe..a4941fa 100644 --- a/server/api/v1/app/enter.go +++ b/server/api/v1/app/enter.go @@ -7,4 +7,5 @@ type ApiGroup struct { AIConfigApi PresetApi UploadApi + WorldbookApi } diff --git a/server/api/v1/app/preset.go b/server/api/v1/app/preset.go index b4a04d6..9c429d4 100644 --- a/server/api/v1/app/preset.go +++ b/server/api/v1/app/preset.go @@ -79,14 +79,12 @@ func (a *PresetApi) GetPresetList(c *gin.Context) { list = append(list, response.ToPresetResponse(&preset)) } - resp := response.PresetListResponse{ + commonResponse.OkWithDetailed(commonResponse.PageResult{ List: list, Total: total, Page: req.Page, PageSize: req.PageSize, - } - - commonResponse.OkWithData(resp, c) + }, "获取成功", c) } // GetPresetByID 根据ID获取预设 diff --git a/server/api/v1/app/worldbook.go b/server/api/v1/app/worldbook.go new file mode 100644 index 0000000..fabbff6 --- /dev/null +++ b/server/api/v1/app/worldbook.go @@ -0,0 +1,296 @@ +package app + +import ( + "net/http" + "strconv" + + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/model/app/request" + "git.echol.cn/loser/st/server/model/common" + commonResponse "git.echol.cn/loser/st/server/model/common/response" + "git.echol.cn/loser/st/server/service" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type WorldbookApi struct{} + +// CreateWorldbook +// @Tags AppWorldbook +// @Summary 创建世界书 +// @Router /app/worldbook [post] +// @Security ApiKeyAuth +func (a *WorldbookApi) CreateWorldbook(c *gin.Context) { + userID := common.GetAppUserID(c) + var req request.CreateWorldbookRequest + if err := c.ShouldBindJSON(&req); err != nil { + commonResponse.FailWithMessage(err.Error(), c) + return + } + resp, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.CreateWorldbook(userID, &req) + if err != nil { + global.GVA_LOG.Error("创建世界书失败", zap.Error(err)) + commonResponse.FailWithMessage(err.Error(), c) + return + } + commonResponse.OkWithData(resp, c) +} + +// GetWorldbookList +// @Tags AppWorldbook +// @Summary 获取世界书列表 +// @Router /app/worldbook [get] +// @Security ApiKeyAuth +func (a *WorldbookApi) GetWorldbookList(c *gin.Context) { + userID := common.GetAppUserID(c) + var req request.GetWorldbookListRequest + req.Page, _ = strconv.Atoi(c.DefaultQuery("page", "1")) + req.PageSize, _ = strconv.Atoi(c.DefaultQuery("pageSize", "20")) + req.Keyword = c.Query("keyword") + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 || req.PageSize > 100 { + req.PageSize = 20 + } + list, total, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.GetWorldbookList(userID, &req) + if err != nil { + global.GVA_LOG.Error("获取世界书列表失败", zap.Error(err)) + commonResponse.FailWithMessage(err.Error(), c) + return + } + commonResponse.OkWithDetailed(commonResponse.PageResult{ + List: list, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, "获取成功", c) +} + +// GetWorldbookByID +// @Tags AppWorldbook +// @Summary 获取世界书详情 +// @Router /app/worldbook/:id [get] +// @Security ApiKeyAuth +func (a *WorldbookApi) GetWorldbookByID(c *gin.Context) { + userID := common.GetAppUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + commonResponse.FailWithMessage("无效的世界书ID", c) + return + } + resp, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.GetWorldbookByID(userID, uint(id)) + if err != nil { + global.GVA_LOG.Error("获取世界书失败", zap.Error(err)) + commonResponse.FailWithMessage(err.Error(), c) + return + } + commonResponse.OkWithData(resp, c) +} + +// UpdateWorldbook +// @Tags AppWorldbook +// @Summary 更新世界书 +// @Router /app/worldbook/:id [put] +// @Security ApiKeyAuth +func (a *WorldbookApi) UpdateWorldbook(c *gin.Context) { + userID := common.GetAppUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + commonResponse.FailWithMessage("无效的世界书ID", c) + return + } + var req request.UpdateWorldbookRequest + if err := c.ShouldBindJSON(&req); err != nil { + commonResponse.FailWithMessage(err.Error(), c) + return + } + if err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.UpdateWorldbook(userID, uint(id), &req); err != nil { + global.GVA_LOG.Error("更新世界书失败", zap.Error(err)) + commonResponse.FailWithMessage(err.Error(), c) + return + } + commonResponse.OkWithMessage("更新成功", c) +} + +// DeleteWorldbook +// @Tags AppWorldbook +// @Summary 删除世界书 +// @Router /app/worldbook/:id [delete] +// @Security ApiKeyAuth +func (a *WorldbookApi) DeleteWorldbook(c *gin.Context) { + userID := common.GetAppUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + commonResponse.FailWithMessage("无效的世界书ID", c) + return + } + if err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.DeleteWorldbook(userID, uint(id)); err != nil { + global.GVA_LOG.Error("删除世界书失败", zap.Error(err)) + commonResponse.FailWithMessage(err.Error(), c) + return + } + commonResponse.OkWithMessage("删除成功", c) +} + +// ImportWorldbook +// @Tags AppWorldbook +// @Summary 导入世界书(JSON) +// @Router /app/worldbook/import [post] +// @Security ApiKeyAuth +func (a *WorldbookApi) ImportWorldbook(c *gin.Context) { + userID := common.GetAppUserID(c) + file, header, err := c.Request.FormFile("file") + if err != nil { + commonResponse.FailWithMessage("请上传文件", c) + return + } + defer file.Close() + + if header.Size > 5*1024*1024 { + commonResponse.FailWithMessage("文件大小不能超过 5MB", c) + return + } + + buf := make([]byte, header.Size) + if _, err := file.Read(buf); err != nil { + commonResponse.FailWithMessage("读取文件失败", c) + return + } + + // 去掉扩展名作为名称 + filename := header.Filename + for i := len(filename) - 1; i >= 0; i-- { + if filename[i] == '.' { + filename = filename[:i] + break + } + } + + resp, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.ImportFromJSON(userID, buf, filename) + if err != nil { + global.GVA_LOG.Error("导入世界书失败", zap.Error(err)) + commonResponse.FailWithMessage(err.Error(), c) + return + } + commonResponse.OkWithData(resp, c) +} + +// ExportWorldbook +// @Tags AppWorldbook +// @Summary 导出世界书为 JSON +// @Router /app/worldbook/:id/export [get] +// @Security ApiKeyAuth +func (a *WorldbookApi) ExportWorldbook(c *gin.Context) { + userID := common.GetAppUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + commonResponse.FailWithMessage("无效的世界书ID", c) + return + } + data, filename, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.ExportToJSON(userID, uint(id)) + if err != nil { + global.GVA_LOG.Error("导出世界书失败", zap.Error(err)) + commonResponse.FailWithMessage(err.Error(), c) + return + } + c.Header("Content-Disposition", "attachment; filename="+filename) + c.Data(http.StatusOK, "application/json", data) +} + +// CreateEntry +// @Tags AppWorldbook +// @Summary 创建世界书条目 +// @Router /app/worldbook/:id/entry [post] +// @Security ApiKeyAuth +func (a *WorldbookApi) CreateEntry(c *gin.Context) { + userID := common.GetAppUserID(c) + worldbookID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + commonResponse.FailWithMessage("无效的世界书ID", c) + return + } + var req request.CreateEntryRequest + if err := c.ShouldBindJSON(&req); err != nil { + commonResponse.FailWithMessage(err.Error(), c) + return + } + resp, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.CreateEntry(userID, uint(worldbookID), &req) + if err != nil { + global.GVA_LOG.Error("创建世界书条目失败", zap.Error(err)) + commonResponse.FailWithMessage(err.Error(), c) + return + } + commonResponse.OkWithData(resp, c) +} + +// GetEntryList +// @Tags AppWorldbook +// @Summary 获取世界书条目列表 +// @Router /app/worldbook/:id/entries [get] +// @Security ApiKeyAuth +func (a *WorldbookApi) GetEntryList(c *gin.Context) { + userID := common.GetAppUserID(c) + worldbookID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + commonResponse.FailWithMessage("无效的世界书ID", c) + return + } + list, total, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.GetEntryList(userID, uint(worldbookID)) + if err != nil { + global.GVA_LOG.Error("获取条目列表失败", zap.Error(err)) + commonResponse.FailWithMessage(err.Error(), c) + return + } + commonResponse.OkWithDetailed(commonResponse.PageResult{ + List: list, + Total: total, + Page: 0, + PageSize: 0, + }, "获取成功", c) +} + +// UpdateEntry +// @Tags AppWorldbook +// @Summary 更新世界书条目 +// @Router /app/worldbook/:id/entry/:entryId [put] +// @Security ApiKeyAuth +func (a *WorldbookApi) UpdateEntry(c *gin.Context) { + userID := common.GetAppUserID(c) + entryID, err := strconv.ParseUint(c.Param("entryId"), 10, 32) + if err != nil { + commonResponse.FailWithMessage("无效的条目ID", c) + return + } + var req request.UpdateEntryRequest + if err := c.ShouldBindJSON(&req); err != nil { + commonResponse.FailWithMessage(err.Error(), c) + return + } + if err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.UpdateEntry(userID, uint(entryID), &req); err != nil { + global.GVA_LOG.Error("更新条目失败", zap.Error(err)) + commonResponse.FailWithMessage(err.Error(), c) + return + } + commonResponse.OkWithMessage("更新成功", c) +} + +// DeleteEntry +// @Tags AppWorldbook +// @Summary 删除世界书条目 +// @Router /app/worldbook/:id/entry/:entryId [delete] +// @Security ApiKeyAuth +func (a *WorldbookApi) DeleteEntry(c *gin.Context) { + userID := common.GetAppUserID(c) + entryID, err := strconv.ParseUint(c.Param("entryId"), 10, 32) + if err != nil { + commonResponse.FailWithMessage("无效的条目ID", c) + return + } + if err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.DeleteEntry(userID, uint(entryID)); err != nil { + global.GVA_LOG.Error("删除条目失败", zap.Error(err)) + commonResponse.FailWithMessage(err.Error(), c) + return + } + commonResponse.OkWithMessage("删除成功", c) +} diff --git a/server/initialize/gorm.go b/server/initialize/gorm.go index af5c610..a934ca2 100644 --- a/server/initialize/gorm.go +++ b/server/initialize/gorm.go @@ -86,6 +86,8 @@ func RegisterTables() { app.Message{}, app.AIConfig{}, app.AIPreset{}, + app.Worldbook{}, + app.WorldbookEntry{}, ) if err != nil { global.GVA_LOG.Error("register table failed", zap.Error(err)) diff --git a/server/initialize/router.go b/server/initialize/router.go index 8a6d4d0..b459b89 100644 --- a/server/initialize/router.go +++ b/server/initialize/router.go @@ -153,6 +153,7 @@ func Routers() *gin.Engine { appRouter.InitAIConfigRouter(appGroup) // AI配置路由:/app/ai-config/* appRouter.InitPresetRouter(appGroup) // 预设路由:/app/preset/* appRouter.InitUploadRouter(appGroup) // 上传路由:/app/upload/* + appRouter.InitWorldbookRouter(appGroup) // 世界书路由:/app/worldbook/* } //插件路由安装 diff --git a/server/model/app/request/worldbook.go b/server/model/app/request/worldbook.go new file mode 100644 index 0000000..a08e570 --- /dev/null +++ b/server/model/app/request/worldbook.go @@ -0,0 +1,64 @@ +package request + +// CreateWorldbookRequest 创建世界书请求 +type CreateWorldbookRequest struct { + Name string `json:"name" binding:"required,max=100"` + Description string `json:"description"` + IsPublic bool `json:"isPublic"` +} + +// UpdateWorldbookRequest 更新世界书请求 +type UpdateWorldbookRequest struct { + Name *string `json:"name" binding:"omitempty,max=100"` + Description *string `json:"description"` + IsPublic *bool `json:"isPublic"` +} + +// GetWorldbookListRequest 获取世界书列表请求 +type GetWorldbookListRequest struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Keyword string `json:"keyword"` +} + +// CreateEntryRequest 创建世界书条目请求 +type CreateEntryRequest struct { + Comment string `json:"comment"` + Content string `json:"content" binding:"required"` + Keys []string `json:"keys"` + SecondaryKeys []string `json:"secondaryKeys"` + Constant bool `json:"constant"` + Enabled *bool `json:"enabled"` // 指针以区分 false 和未传 + UseRegex bool `json:"useRegex"` + CaseSensitive bool `json:"caseSensitive"` + MatchWholeWords bool `json:"matchWholeWords"` + Selective bool `json:"selective"` + SelectiveLogic int `json:"selectiveLogic"` + Position int `json:"position"` + Depth int `json:"depth"` + Order int `json:"order"` + Probability int `json:"probability"` + ScanDepth int `json:"scanDepth"` + GroupID string `json:"groupId"` +} + +// UpdateEntryRequest 更新世界书条目请求(所有字段可选) +type UpdateEntryRequest struct { + Comment *string `json:"comment"` + Content *string `json:"content"` + Keys []string `json:"keys"` + SecondaryKeys []string `json:"secondaryKeys"` + Constant *bool `json:"constant"` + Enabled *bool `json:"enabled"` + UseRegex *bool `json:"useRegex"` + CaseSensitive *bool `json:"caseSensitive"` + MatchWholeWords *bool `json:"matchWholeWords"` + Selective *bool `json:"selective"` + SelectiveLogic *int `json:"selectiveLogic"` + Position *int `json:"position"` + Depth *int `json:"depth"` + Order *int `json:"order"` + Probability *int `json:"probability"` + ScanDepth *int `json:"scanDepth"` + GroupID *string `json:"groupId"` +} diff --git a/server/model/app/response/worldbook.go b/server/model/app/response/worldbook.go new file mode 100644 index 0000000..875e15d --- /dev/null +++ b/server/model/app/response/worldbook.go @@ -0,0 +1,114 @@ +package response + +import ( + "encoding/json" + "time" + + "git.echol.cn/loser/st/server/model/app" +) + +// WorldbookResponse 世界书响应 +type WorldbookResponse struct { + ID uint `json:"id"` + UserID uint `json:"userId"` + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"isPublic"` + EntryCount int `json:"entryCount"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// WorldbookListResponse 世界书列表响应 +type WorldbookListResponse struct { + List []WorldbookResponse `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +// EntryResponse 世界书条目响应 +type EntryResponse struct { + ID uint `json:"id"` + WorldbookID uint `json:"worldbookId"` + Comment string `json:"comment"` + Content string `json:"content"` + Keys []string `json:"keys"` + SecondaryKeys []string `json:"secondaryKeys"` + Constant bool `json:"constant"` + Enabled bool `json:"enabled"` + UseRegex bool `json:"useRegex"` + CaseSensitive bool `json:"caseSensitive"` + MatchWholeWords bool `json:"matchWholeWords"` + Selective bool `json:"selective"` + SelectiveLogic int `json:"selectiveLogic"` + Position int `json:"position"` + Depth int `json:"depth"` + Order int `json:"order"` + Probability int `json:"probability"` + ScanDepth int `json:"scanDepth"` + GroupID string `json:"groupId"` + Extensions map[string]interface{} `json:"extensions"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// EntryListResponse 条目列表响应 +type EntryListResponse struct { + List []EntryResponse `json:"list"` + Total int64 `json:"total"` +} + +// ToWorldbookResponse 转换为世界书响应结构 +func ToWorldbookResponse(wb *app.Worldbook) WorldbookResponse { + return WorldbookResponse{ + ID: wb.ID, + UserID: wb.UserID, + Name: wb.Name, + Description: wb.Description, + IsPublic: wb.IsPublic, + EntryCount: wb.EntryCount, + CreatedAt: wb.CreatedAt, + UpdatedAt: wb.UpdatedAt, + } +} + +// ToEntryResponse 转换为条目响应结构 +func ToEntryResponse(entry *app.WorldbookEntry) EntryResponse { + var keys []string + if len(entry.Keys) > 0 { + json.Unmarshal(entry.Keys, &keys) + } + var secondaryKeys []string + if len(entry.SecondaryKeys) > 0 { + json.Unmarshal(entry.SecondaryKeys, &secondaryKeys) + } + var extensions map[string]interface{} + if len(entry.Extensions) > 0 { + json.Unmarshal(entry.Extensions, &extensions) + } + return EntryResponse{ + ID: entry.ID, + WorldbookID: entry.WorldbookID, + Comment: entry.Comment, + Content: entry.Content, + Keys: keys, + SecondaryKeys: secondaryKeys, + Constant: entry.Constant, + Enabled: entry.Enabled, + UseRegex: entry.UseRegex, + CaseSensitive: entry.CaseSensitive, + MatchWholeWords: entry.MatchWholeWords, + Selective: entry.Selective, + SelectiveLogic: entry.SelectiveLogic, + Position: entry.Position, + Depth: entry.Depth, + Order: entry.Order, + Probability: entry.Probability, + ScanDepth: entry.ScanDepth, + GroupID: entry.GroupID, + Extensions: extensions, + CreatedAt: entry.CreatedAt, + UpdatedAt: entry.UpdatedAt, + } +} diff --git a/server/model/app/worldbook.go b/server/model/app/worldbook.go new file mode 100644 index 0000000..83734f0 --- /dev/null +++ b/server/model/app/worldbook.go @@ -0,0 +1,68 @@ +package app + +import ( + "time" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +// Worldbook 世界书主表 +type Worldbook struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + UserID uint `gorm:"index;not null" json:"userId"` + Name string `gorm:"type:varchar(100);not null" json:"name"` + Description string `gorm:"type:text" json:"description"` + IsPublic bool `gorm:"default:false" json:"isPublic"` + EntryCount int `gorm:"default:0" json:"entryCount"` +} + +func (Worldbook) TableName() string { + return "worldbooks" +} + +// WorldbookEntry 世界书条目表(完全兼容 SillyTavern 格式) +type WorldbookEntry struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + WorldbookID uint `gorm:"index;not null" json:"worldbookId"` + Comment string `gorm:"type:varchar(200)" json:"comment"` // 条目标题/备注 + Content string `gorm:"type:text;not null" json:"content"` // 注入内容 + + // 关键词(存为 JSONB []string) + Keys datatypes.JSON `gorm:"type:jsonb" json:"keys"` + SecondaryKeys datatypes.JSON `gorm:"type:jsonb" json:"secondaryKeys"` + + // 触发设置 + Constant bool `gorm:"default:false" json:"constant"` // 常驻注入,无需关键词触发 + Enabled bool `gorm:"default:true" json:"enabled"` // 是否启用 + UseRegex bool `gorm:"default:false" json:"useRegex"` // 关键词用正则表达式 + CaseSensitive bool `gorm:"default:false" json:"caseSensitive"` // 区分大小写 + MatchWholeWords bool `gorm:"default:false" json:"matchWholeWords"` // 全词匹配 + Selective bool `gorm:"default:false" json:"selective"` // 是否需要次要关键词 + SelectiveLogic int `gorm:"default:0" json:"selectiveLogic"` // 0=AND, 1=NOT + + // 注入位置与优先级 + Position int `gorm:"default:1" json:"position"` // 0=系统提示词前, 1=系统提示词后, 4=指定深度 + Depth int `gorm:"default:4" json:"depth"` // position=4 时生效 + Order int `gorm:"default:100" json:"order"` // 同位置时的排序 + + // 概率与触发控制(SillyTavern 兼容字段) + Probability int `gorm:"default:100" json:"probability"` // 触发概率 0-100 + ScanDepth int `gorm:"default:2" json:"scanDepth"` // 扫描最近 N 条消息(0=全部) + GroupID string `gorm:"type:varchar(100)" json:"groupId"` // 条目分组 + + // 扩展字段 + Extensions datatypes.JSON `gorm:"type:jsonb" json:"extensions"` +} + +func (WorldbookEntry) TableName() string { + return "worldbook_entries" +} diff --git a/server/router/app/enter.go b/server/router/app/enter.go index fa158fc..9cda855 100644 --- a/server/router/app/enter.go +++ b/server/router/app/enter.go @@ -7,4 +7,5 @@ type RouterGroup struct { AIConfigRouter PresetRouter UploadRouter + WorldbookRouter } diff --git a/server/router/app/worldbook.go b/server/router/app/worldbook.go new file mode 100644 index 0000000..d51e6a3 --- /dev/null +++ b/server/router/app/worldbook.go @@ -0,0 +1,29 @@ +package app + +import ( + v1 "git.echol.cn/loser/st/server/api/v1" + "git.echol.cn/loser/st/server/middleware" + "github.com/gin-gonic/gin" +) + +type WorldbookRouter struct{} + +// InitWorldbookRouter 初始化世界书路由 +func (r *WorldbookRouter) InitWorldbookRouter(Router *gin.RouterGroup) { + worldbookRouter := Router.Group("worldbook").Use(middleware.AppJWTAuth()) + worldbookApi := v1.ApiGroupApp.AppApiGroup.WorldbookApi + + { + worldbookRouter.POST("", worldbookApi.CreateWorldbook) // 创建世界书 + worldbookRouter.GET("", worldbookApi.GetWorldbookList) // 获取世界书列表 + worldbookRouter.POST("import", worldbookApi.ImportWorldbook) // 导入世界书 + worldbookRouter.GET(":id", worldbookApi.GetWorldbookByID) // 获取世界书详情 + worldbookRouter.PUT(":id", worldbookApi.UpdateWorldbook) // 更新世界书 + worldbookRouter.DELETE(":id", worldbookApi.DeleteWorldbook) // 删除世界书 + worldbookRouter.GET(":id/export", worldbookApi.ExportWorldbook) // 导出世界书 + worldbookRouter.POST(":id/entry", worldbookApi.CreateEntry) // 创建条目 + worldbookRouter.GET(":id/entries", worldbookApi.GetEntryList) // 获取条目列表 + worldbookRouter.PUT(":id/entry/:entryId", worldbookApi.UpdateEntry) // 更新条目 + worldbookRouter.DELETE(":id/entry/:entryId", worldbookApi.DeleteEntry) // 删除条目 + } +} diff --git a/server/service/app/enter.go b/server/service/app/enter.go index e475060..bb83796 100644 --- a/server/service/app/enter.go +++ b/server/service/app/enter.go @@ -7,4 +7,5 @@ type AppServiceGroup struct { AIConfigService PresetService UploadService + WorldbookService } diff --git a/server/service/app/worldbook.go b/server/service/app/worldbook.go new file mode 100644 index 0000000..a55ddd7 --- /dev/null +++ b/server/service/app/worldbook.go @@ -0,0 +1,498 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/model/app" + "git.echol.cn/loser/st/server/model/app/request" + "git.echol.cn/loser/st/server/model/app/response" + "go.uber.org/zap" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type WorldbookService struct{} + +// CreateWorldbook 创建世界书 +func (s *WorldbookService) CreateWorldbook(userID uint, req *request.CreateWorldbookRequest) (*response.WorldbookResponse, error) { + wb := &app.Worldbook{ + UserID: userID, + Name: req.Name, + Description: req.Description, + IsPublic: req.IsPublic, + } + if err := global.GVA_DB.Create(wb).Error; err != nil { + global.GVA_LOG.Error("创建世界书失败", zap.Error(err)) + return nil, err + } + resp := response.ToWorldbookResponse(wb) + return &resp, nil +} + +// GetWorldbookList 获取世界书列表(自己的 + 公开的) +func (s *WorldbookService) GetWorldbookList(userID uint, req *request.GetWorldbookListRequest) ([]response.WorldbookResponse, int64, error) { + var worldbooks []app.Worldbook + var total int64 + + db := global.GVA_DB.Model(&app.Worldbook{}).Where("user_id = ? OR is_public = ?", userID, true) + + if req.Keyword != "" { + db = db.Where("name LIKE ? OR description LIKE ?", "%"+req.Keyword+"%", "%"+req.Keyword+"%") + } + + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (req.Page - 1) * req.PageSize + if err := db.Order("updated_at DESC").Offset(offset).Limit(req.PageSize).Find(&worldbooks).Error; err != nil { + global.GVA_LOG.Error("获取世界书列表失败", zap.Error(err)) + return nil, 0, err + } + + var list []response.WorldbookResponse + for i := range worldbooks { + list = append(list, response.ToWorldbookResponse(&worldbooks[i])) + } + return list, total, nil +} + +// GetWorldbookByID 获取世界书详情 +func (s *WorldbookService) GetWorldbookByID(userID uint, id uint) (*response.WorldbookResponse, error) { + var wb app.Worldbook + if err := global.GVA_DB.Where("id = ? AND (user_id = ? OR is_public = ?)", id, userID, true). + First(&wb).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("世界书不存在或无权访问") + } + return nil, err + } + resp := response.ToWorldbookResponse(&wb) + return &resp, nil +} + +// UpdateWorldbook 更新世界书 +func (s *WorldbookService) UpdateWorldbook(userID uint, id uint, req *request.UpdateWorldbookRequest) error { + var wb app.Worldbook + if err := global.GVA_DB.Where("id = ? AND user_id = ?", id, userID).First(&wb).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("世界书不存在或无权修改") + } + return err + } + + updates := make(map[string]interface{}) + if req.Name != nil { + updates["name"] = *req.Name + } + if req.Description != nil { + updates["description"] = *req.Description + } + if req.IsPublic != nil { + updates["is_public"] = *req.IsPublic + } + + return global.GVA_DB.Model(&wb).Updates(updates).Error +} + +// DeleteWorldbook 删除世界书(级联删除条目) +func (s *WorldbookService) DeleteWorldbook(userID uint, id uint) error { + return global.GVA_DB.Transaction(func(tx *gorm.DB) error { + // 删除所有条目 + if err := tx.Where("worldbook_id = ?", id).Delete(&app.WorldbookEntry{}).Error; err != nil { + return err + } + // 删除世界书 + result := tx.Where("id = ? AND user_id = ?", id, userID).Delete(&app.Worldbook{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("世界书不存在或无权删除") + } + return nil + }) +} + +// CreateEntry 创建世界书条目 +func (s *WorldbookService) CreateEntry(userID uint, worldbookID uint, req *request.CreateEntryRequest) (*response.EntryResponse, error) { + // 验证世界书归属 + var wb app.Worldbook + if err := global.GVA_DB.Where("id = ? AND user_id = ?", worldbookID, userID).First(&wb).Error; err != nil { + return nil, errors.New("世界书不存在或无权操作") + } + + keysJSON, _ := json.Marshal(req.Keys) + secKeysJSON, _ := json.Marshal(req.SecondaryKeys) + + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + probability := req.Probability + if probability == 0 { + probability = 100 + } + order := req.Order + if order == 0 { + order = 100 + } + scanDepth := req.ScanDepth + if scanDepth == 0 { + scanDepth = 2 + } + + entry := &app.WorldbookEntry{ + WorldbookID: worldbookID, + Comment: req.Comment, + Content: req.Content, + Keys: datatypes.JSON(keysJSON), + SecondaryKeys: datatypes.JSON(secKeysJSON), + Constant: req.Constant, + Enabled: enabled, + UseRegex: req.UseRegex, + CaseSensitive: req.CaseSensitive, + MatchWholeWords: req.MatchWholeWords, + Selective: req.Selective, + SelectiveLogic: req.SelectiveLogic, + Position: req.Position, + Depth: req.Depth, + Order: order, + Probability: probability, + ScanDepth: scanDepth, + GroupID: req.GroupID, + } + + if err := global.GVA_DB.Create(entry).Error; err != nil { + global.GVA_LOG.Error("创建世界书条目失败", zap.Error(err)) + return nil, err + } + + // 更新世界书条目计数 + global.GVA_DB.Model(&wb).Update("entry_count", gorm.Expr("entry_count + ?", 1)) + + resp := response.ToEntryResponse(entry) + return &resp, nil +} + +// GetEntryList 获取世界书条目列表 +func (s *WorldbookService) GetEntryList(userID uint, worldbookID uint) ([]response.EntryResponse, int64, error) { + // 验证访问权限 + var wb app.Worldbook + if err := global.GVA_DB.Where("id = ? AND (user_id = ? OR is_public = ?)", worldbookID, userID, true). + First(&wb).Error; err != nil { + return nil, 0, errors.New("世界书不存在或无权访问") + } + + var entries []app.WorldbookEntry + var total int64 + + db := global.GVA_DB.Model(&app.WorldbookEntry{}).Where("worldbook_id = ?", worldbookID) + db.Count(&total) + + if err := db.Order("`order` ASC, created_at ASC").Find(&entries).Error; err != nil { + return nil, 0, err + } + + var list []response.EntryResponse + for i := range entries { + list = append(list, response.ToEntryResponse(&entries[i])) + } + return list, total, nil +} + +// UpdateEntry 更新世界书条目 +func (s *WorldbookService) UpdateEntry(userID uint, entryID uint, req *request.UpdateEntryRequest) error { + // 查找条目并验证归属 + var entry app.WorldbookEntry + if err := global.GVA_DB.First(&entry, entryID).Error; err != nil { + return errors.New("条目不存在") + } + var wb app.Worldbook + if err := global.GVA_DB.Where("id = ? AND user_id = ?", entry.WorldbookID, userID).First(&wb).Error; err != nil { + return errors.New("无权修改此条目") + } + + updates := make(map[string]interface{}) + if req.Comment != nil { + updates["comment"] = *req.Comment + } + if req.Content != nil { + updates["content"] = *req.Content + } + if req.Keys != nil { + keysJSON, _ := json.Marshal(req.Keys) + updates["keys"] = datatypes.JSON(keysJSON) + } + if req.SecondaryKeys != nil { + secKeysJSON, _ := json.Marshal(req.SecondaryKeys) + updates["secondary_keys"] = datatypes.JSON(secKeysJSON) + } + if req.Constant != nil { + updates["constant"] = *req.Constant + } + if req.Enabled != nil { + updates["enabled"] = *req.Enabled + } + if req.UseRegex != nil { + updates["use_regex"] = *req.UseRegex + } + if req.CaseSensitive != nil { + updates["case_sensitive"] = *req.CaseSensitive + } + if req.MatchWholeWords != nil { + updates["match_whole_words"] = *req.MatchWholeWords + } + if req.Selective != nil { + updates["selective"] = *req.Selective + } + if req.SelectiveLogic != nil { + updates["selective_logic"] = *req.SelectiveLogic + } + if req.Position != nil { + updates["position"] = *req.Position + } + if req.Depth != nil { + updates["depth"] = *req.Depth + } + if req.Order != nil { + updates["order"] = *req.Order + } + if req.Probability != nil { + updates["probability"] = *req.Probability + } + if req.ScanDepth != nil { + updates["scan_depth"] = *req.ScanDepth + } + if req.GroupID != nil { + updates["group_id"] = *req.GroupID + } + + return global.GVA_DB.Model(&entry).Updates(updates).Error +} + +// DeleteEntry 删除世界书条目 +func (s *WorldbookService) DeleteEntry(userID uint, entryID uint) error { + var entry app.WorldbookEntry + if err := global.GVA_DB.First(&entry, entryID).Error; err != nil { + return errors.New("条目不存在") + } + var wb app.Worldbook + if err := global.GVA_DB.Where("id = ? AND user_id = ?", entry.WorldbookID, userID).First(&wb).Error; err != nil { + return errors.New("无权删除此条目") + } + + if err := global.GVA_DB.Delete(&entry).Error; err != nil { + return err + } + + // 更新条目计数 + global.GVA_DB.Model(&wb).Update("entry_count", gorm.Expr("GREATEST(entry_count - 1, 0)")) + return nil +} + +// ImportFromJSON 从 JSON 文件导入世界书(兼容 SillyTavern 格式) +func (s *WorldbookService) ImportFromJSON(userID uint, jsonData []byte, filename string) (*response.WorldbookResponse, error) { + // 尝试解析 SillyTavern 世界书格式 + var stFormat map[string]interface{} + if err := json.Unmarshal(jsonData, &stFormat); err != nil { + return nil, fmt.Errorf("JSON 格式错误: %v", err) + } + + // 提取世界书名称 + name := filename + if n, ok := stFormat["name"].(string); ok && n != "" { + name = n + } + description := "" + if d, ok := stFormat["description"].(string); ok { + description = d + } + + wb := &app.Worldbook{ + UserID: userID, + Name: name, + Description: description, + } + if err := global.GVA_DB.Create(wb).Error; err != nil { + return nil, err + } + + // 解析条目(SillyTavern entries 格式:map[string]entry 或 []entry) + var entryCount int + if entriesRaw, ok := stFormat["entries"]; ok { + switch entries := entriesRaw.(type) { + case map[string]interface{}: + // SillyTavern 格式:键值对 + for _, v := range entries { + if entryMap, ok := v.(map[string]interface{}); ok { + s.importEntry(wb.ID, entryMap) + entryCount++ + } + } + case []interface{}: + // 数组格式 + for _, v := range entries { + if entryMap, ok := v.(map[string]interface{}); ok { + s.importEntry(wb.ID, entryMap) + entryCount++ + } + } + } + } + + // 更新条目计数 + global.GVA_DB.Model(wb).Update("entry_count", entryCount) + + resp := response.ToWorldbookResponse(wb) + resp.EntryCount = entryCount + return &resp, nil +} + +// importEntry 辅助方法:从 SillyTavern 格式导入单条条目 +func (s *WorldbookService) importEntry(worldbookID uint, entryMap map[string]interface{}) { + content := "" + if c, ok := entryMap["content"].(string); ok { + content = c + } + if content == "" { + return + } + + comment := "" + if c, ok := entryMap["comment"].(string); ok { + comment = c + } + + // 解析 keys(SillyTavern 存为 []string 或 []interface{}) + var keys []string + if k, ok := entryMap["key"].([]interface{}); ok { + for _, kk := range k { + if ks, ok := kk.(string); ok { + keys = append(keys, ks) + } + } + } else if k, ok := entryMap["keys"].([]interface{}); ok { + for _, kk := range k { + if ks, ok := kk.(string); ok { + keys = append(keys, ks) + } + } + } + keysJSON, _ := json.Marshal(keys) + + var secKeys []string + if k, ok := entryMap["secondary_key"].([]interface{}); ok { + for _, kk := range k { + if ks, ok := kk.(string); ok { + secKeys = append(secKeys, ks) + } + } + } else if k, ok := entryMap["secondaryKeys"].([]interface{}); ok { + for _, kk := range k { + if ks, ok := kk.(string); ok { + secKeys = append(secKeys, ks) + } + } + } + secKeysJSON, _ := json.Marshal(secKeys) + + constant := false + if c, ok := entryMap["constant"].(bool); ok { + constant = c + } + enabled := true + if e, ok := entryMap["enabled"].(bool); ok { + enabled = e + } else if d, ok := entryMap["disable"].(bool); ok { + enabled = !d + } + useRegex := false + if r, ok := entryMap["use_regex"].(bool); ok { + useRegex = r + } + position := 1 + if p, ok := entryMap["position"].(float64); ok { + position = int(p) + } + order := 100 + if o, ok := entryMap["insertion_order"].(float64); ok { + order = int(o) + } else if o, ok := entryMap["order"].(float64); ok { + order = int(o) + } + probability := 100 + if p, ok := entryMap["probability"].(float64); ok { + probability = int(p) + } + + entry := &app.WorldbookEntry{ + WorldbookID: worldbookID, + Comment: comment, + Content: content, + Keys: datatypes.JSON(keysJSON), + SecondaryKeys: datatypes.JSON(secKeysJSON), + Constant: constant, + Enabled: enabled, + UseRegex: useRegex, + Position: position, + Order: order, + Probability: probability, + ScanDepth: 2, + } + global.GVA_DB.Create(entry) +} + +// ExportToJSON 导出世界书为 JSON(兼容 SillyTavern 格式) +func (s *WorldbookService) ExportToJSON(userID uint, worldbookID uint) ([]byte, string, error) { + var wb app.Worldbook + if err := global.GVA_DB.Where("id = ? AND (user_id = ? OR is_public = ?)", worldbookID, userID, true). + First(&wb).Error; err != nil { + return nil, "", errors.New("世界书不存在或无权访问") + } + + var entries []app.WorldbookEntry + global.GVA_DB.Where("worldbook_id = ?", worldbookID).Order("`order` ASC").Find(&entries) + + // 构建 SillyTavern 兼容格式 + entriesMap := make(map[string]interface{}) + for i, entry := range entries { + var keys []string + json.Unmarshal(entry.Keys, &keys) + var secKeys []string + json.Unmarshal(entry.SecondaryKeys, &secKeys) + + entriesMap[fmt.Sprintf("%d", i)] = map[string]interface{}{ + "uid": entry.ID, + "key": keys, + "secondary_key": secKeys, + "comment": entry.Comment, + "content": entry.Content, + "constant": entry.Constant, + "enabled": entry.Enabled, + "use_regex": entry.UseRegex, + "case_sensitive": entry.CaseSensitive, + "match_whole_words": entry.MatchWholeWords, + "selective": entry.Selective, + "selectiveLogic": entry.SelectiveLogic, + "position": entry.Position, + "depth": entry.Depth, + "insertion_order": entry.Order, + "probability": entry.Probability, + "scanDepth": entry.ScanDepth, + "group": entry.GroupID, + } + } + + exportData := map[string]interface{}{ + "name": wb.Name, + "description": wb.Description, + "entries": entriesMap, + } + + data, err := json.MarshalIndent(exportData, "", " ") + return data, wb.Name + ".json", err +} diff --git a/web-app/src/App.tsx b/web-app/src/App.tsx index 72fd35c..b1f84bb 100644 --- a/web-app/src/App.tsx +++ b/web-app/src/App.tsx @@ -10,6 +10,7 @@ import ForgotPasswordPage from './pages/ForgotPasswordPage' import ProfilePage from './pages/ProfilePage' import CharacterManagePage from './pages/CharacterManagePage' import PresetManagePage from './pages/PresetManagePage' +import WorldbookManagePage from './pages/WorldbookManagePage' import AdminPage from './pages/AdminPage' function App() { @@ -27,6 +28,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/web-app/src/api/worldbook.ts b/web-app/src/api/worldbook.ts new file mode 100644 index 0000000..fdf8c5e --- /dev/null +++ b/web-app/src/api/worldbook.ts @@ -0,0 +1,164 @@ +import client from './client'; + +export interface Worldbook { + id: number; + userId: number; + name: string; + description: string; + isPublic: boolean; + entryCount: number; + createdAt: string; + updatedAt: string; +} + +export interface WorldbookEntry { + id: number; + worldbookId: number; + comment: string; + content: string; + keys: string[]; + secondaryKeys: string[]; + constant: boolean; + enabled: boolean; + useRegex: boolean; + caseSensitive: boolean; + matchWholeWords: boolean; + selective: boolean; + selectiveLogic: number; + position: number; + depth: number; + order: number; + probability: number; + scanDepth: number; + groupId: string; + extensions?: Record; + createdAt: string; + updatedAt: string; +} + +export interface CreateWorldbookRequest { + name: string; + description?: string; + isPublic?: boolean; +} + +export interface UpdateWorldbookRequest { + name?: string; + description?: string; + isPublic?: boolean; +} + +export interface CreateEntryRequest { + comment?: string; + content: string; + keys?: string[]; + secondaryKeys?: string[]; + constant?: boolean; + enabled?: boolean; + useRegex?: boolean; + caseSensitive?: boolean; + matchWholeWords?: boolean; + selective?: boolean; + selectiveLogic?: number; + position?: number; + depth?: number; + order?: number; + probability?: number; + scanDepth?: number; + groupId?: string; +} + +export interface UpdateEntryRequest { + comment?: string; + content?: string; + keys?: string[]; + secondaryKeys?: string[]; + constant?: boolean; + enabled?: boolean; + useRegex?: boolean; + caseSensitive?: boolean; + matchWholeWords?: boolean; + selective?: boolean; + selectiveLogic?: number; + position?: number; + depth?: number; + order?: number; + probability?: number; + scanDepth?: number; + groupId?: string; +} + +// 创建世界书 +export const createWorldbook = (data: CreateWorldbookRequest) => { + return client.post('/app/worldbook', data); +}; + +// 获取世界书列表 +export const getWorldbookList = (params: { + page?: number; + pageSize?: number; + keyword?: string; +}) => { + return client.get<{ + list: Worldbook[]; + total: number; + page: number; + pageSize: number; + }>('/app/worldbook', { params }); +}; + +// 获取世界书详情 +export const getWorldbookById = (id: number) => { + return client.get(`/app/worldbook/${id}`); +}; + +// 更新世界书 +export const updateWorldbook = (id: number, data: UpdateWorldbookRequest) => { + return client.put(`/app/worldbook/${id}`, data); +}; + +// 删除世界书 +export const deleteWorldbook = (id: number) => { + return client.delete(`/app/worldbook/${id}`); +}; + +// 导入世界书 +export const importWorldbook = (file: File) => { + const formData = new FormData(); + formData.append('file', file); + return client.post('/app/worldbook/import', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); +}; + +// 导出世界书 +export const exportWorldbook = (id: number) => { + return client.get(`/app/worldbook/${id}/export`, { + responseType: 'blob', + }); +}; + +// 创建条目 +export const createEntry = (worldbookId: number, data: CreateEntryRequest) => { + return client.post(`/app/worldbook/${worldbookId}/entry`, data); +}; + +// 获取条目列表 +export const getEntryList = (worldbookId: number) => { + return client.get<{ + list: WorldbookEntry[]; + total: number; + }>(`/app/worldbook/${worldbookId}/entries`); +}; + +// 更新条目 +export const updateEntry = (worldbookId: number, entryId: number, data: UpdateEntryRequest) => { + return client.put(`/app/worldbook/${worldbookId}/entry/${entryId}`, data); +}; + +// 删除条目 +export const deleteEntry = (worldbookId: number, entryId: number) => { + return client.delete(`/app/worldbook/${worldbookId}/entry/${entryId}`); +}; diff --git a/web-app/src/pages/WorldbookManagePage.tsx b/web-app/src/pages/WorldbookManagePage.tsx new file mode 100644 index 0000000..bb7ef14 --- /dev/null +++ b/web-app/src/pages/WorldbookManagePage.tsx @@ -0,0 +1,613 @@ +import React, { useState, useEffect } from 'react'; +import { + getWorldbookList, + createWorldbook, + updateWorldbook, + deleteWorldbook, + importWorldbook, + exportWorldbook, + getEntryList, + createEntry, + updateEntry, + deleteEntry, + type Worldbook, + type WorldbookEntry, + type CreateWorldbookRequest, + type CreateEntryRequest, +} from '../api/worldbook'; + +const WorldbookManagePage: React.FC = () => { + const [worldbooks, setWorldbooks] = useState([]); + const [selectedWorldbook, setSelectedWorldbook] = useState(null); + const [entries, setEntries] = useState([]); + const [selectedEntry, setSelectedEntry] = useState(null); + const [loading, setLoading] = useState(false); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showEntryModal, setShowEntryModal] = useState(false); + const [searchKeyword, setSearchKeyword] = useState(''); + + // 表单状态 + const [worldbookForm, setWorldbookForm] = useState({ + name: '', + description: '', + isPublic: false, + }); + + const [entryForm, setEntryForm] = useState({ + comment: '', + content: '', + keys: [], + secondaryKeys: [], + constant: false, + enabled: true, + useRegex: false, + caseSensitive: false, + matchWholeWords: false, + selective: false, + selectiveLogic: 0, + position: 1, + depth: 4, + order: 100, + probability: 100, + scanDepth: 2, + groupId: '', + }); + + const [keyInput, setKeyInput] = useState(''); + const [secondaryKeyInput, setSecondaryKeyInput] = useState(''); + + useEffect(() => { + loadWorldbooks(); + }, [searchKeyword]); + + useEffect(() => { + if (selectedWorldbook) { + loadEntries(selectedWorldbook.id); + } + }, [selectedWorldbook]); + + const loadWorldbooks = async () => { + try { + setLoading(true); + const response = await getWorldbookList({ + page: 1, + pageSize: 100, + keyword: searchKeyword, + }); + setWorldbooks(response.data.list || []); + } catch (error: any) { + alert('加载世界书列表失败: ' + (error.response?.data?.msg || error.message)); + } finally { + setLoading(false); + } + }; + + const loadEntries = async (worldbookId: number) => { + try { + const response = await getEntryList(worldbookId); + setEntries(response.data.list || []); + } catch (error: any) { + alert('加载条目列表失败: ' + (error.response?.data?.msg || error.message)); + } + }; + + const handleCreateWorldbook = async () => { + if (!worldbookForm.name.trim()) { + alert('请输入世界书名称'); + return; + } + try { + await createWorldbook(worldbookForm); + alert('创建成功'); + setShowCreateModal(false); + setWorldbookForm({ name: '', description: '', isPublic: false }); + loadWorldbooks(); + } catch (error: any) { + alert('创建失败: ' + (error.response?.data?.msg || error.message)); + } + }; + + const handleDeleteWorldbook = async (id: number) => { + if (!confirm('确定要删除这个世界书吗?所有条目也会被删除。')) return; + try { + await deleteWorldbook(id); + alert('删除成功'); + if (selectedWorldbook?.id === id) { + setSelectedWorldbook(null); + setEntries([]); + } + loadWorldbooks(); + } catch (error: any) { + alert('删除失败: ' + (error.response?.data?.msg || error.message)); + } + }; + + const handleImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + setLoading(true); + await importWorldbook(file); + alert('导入成功'); + loadWorldbooks(); + } catch (error: any) { + alert('导入失败: ' + (error.response?.data?.msg || error.message)); + } finally { + setLoading(false); + e.target.value = ''; + } + }; + + const handleExport = async (id: number) => { + try { + const response = await exportWorldbook(id); + const blob = new Blob([response.data], { type: 'application/json' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `worldbook_${id}.json`; + a.click(); + window.URL.revokeObjectURL(url); + } catch (error: any) { + alert('导出失败: ' + (error.response?.data?.msg || error.message)); + } + }; + + const handleSaveEntry = async () => { + if (!selectedWorldbook) return; + if (!entryForm.content.trim()) { + alert('请输入条目内容'); + return; + } + try { + if (selectedEntry) { + await updateEntry(selectedWorldbook.id, selectedEntry.id, entryForm); + alert('更新成功'); + } else { + await createEntry(selectedWorldbook.id, entryForm); + alert('创建成功'); + } + setShowEntryModal(false); + resetEntryForm(); + loadEntries(selectedWorldbook.id); + } catch (error: any) { + alert('保存失败: ' + (error.response?.data?.msg || error.message)); + } + }; + + const handleDeleteEntry = async (entryId: number) => { + if (!selectedWorldbook) return; + if (!confirm('确定要删除这个条目吗?')) return; + try { + await deleteEntry(selectedWorldbook.id, entryId); + alert('删除成功'); + loadEntries(selectedWorldbook.id); + } catch (error: any) { + alert('删除失败: ' + (error.response?.data?.msg || error.message)); + } + }; + + const openEntryModal = (entry?: WorldbookEntry) => { + if (entry) { + setSelectedEntry(entry); + setEntryForm({ + comment: entry.comment, + content: entry.content, + keys: entry.keys, + secondaryKeys: entry.secondaryKeys, + constant: entry.constant, + enabled: entry.enabled, + useRegex: entry.useRegex, + caseSensitive: entry.caseSensitive, + matchWholeWords: entry.matchWholeWords, + selective: entry.selective, + selectiveLogic: entry.selectiveLogic, + position: entry.position, + depth: entry.depth, + order: entry.order, + probability: entry.probability, + scanDepth: entry.scanDepth, + groupId: entry.groupId, + }); + } else { + resetEntryForm(); + } + setShowEntryModal(true); + }; + + const resetEntryForm = () => { + setSelectedEntry(null); + setEntryForm({ + comment: '', + content: '', + keys: [], + secondaryKeys: [], + constant: false, + enabled: true, + useRegex: false, + caseSensitive: false, + matchWholeWords: false, + selective: false, + selectiveLogic: 0, + position: 1, + depth: 4, + order: 100, + probability: 100, + scanDepth: 2, + groupId: '', + }); + setKeyInput(''); + setSecondaryKeyInput(''); + }; + + const addKey = () => { + if (keyInput.trim()) { + setEntryForm({ + ...entryForm, + keys: [...(entryForm.keys || []), keyInput.trim()], + }); + setKeyInput(''); + } + }; + + const removeKey = (index: number) => { + setEntryForm({ + ...entryForm, + keys: entryForm.keys?.filter((_, i) => i !== index), + }); + }; + + const addSecondaryKey = () => { + if (secondaryKeyInput.trim()) { + setEntryForm({ + ...entryForm, + secondaryKeys: [...(entryForm.secondaryKeys || []), secondaryKeyInput.trim()], + }); + setSecondaryKeyInput(''); + } + }; + + const removeSecondaryKey = (index: number) => { + setEntryForm({ + ...entryForm, + secondaryKeys: entryForm.secondaryKeys?.filter((_, i) => i !== index), + }); + }; + + return ( +
+
+
+

世界书管理

+
+ + +
+
+ +
+ {/* 世界书列表 */} +
+ setSearchKeyword(e.target.value)} + className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 mb-4" + /> +
+ {worldbooks.map((wb) => ( +
setSelectedWorldbook(wb)} + className={`p-3 rounded-lg cursor-pointer transition-colors ${ + selectedWorldbook?.id === wb.id + ? 'bg-purple-600 text-white' + : 'bg-white/5 hover:bg-white/10 text-white/80' + }`} + > +
{wb.name}
+
{wb.entryCount} 条目
+
+ + +
+
+ ))} +
+
+ + {/* 条目列表 */} +
+ {selectedWorldbook ? ( + <> +
+

{selectedWorldbook.name}

+ +
+
+ {entries.map((entry) => ( +
+
+
+
+ {entry.comment || '未命名条目'} +
+
+ 关键词: {entry.keys.join(', ') || '无'} +
+
+
+ + +
+
+
{entry.content}
+
+ {entry.constant && 常驻} + {!entry.enabled && 已禁用} + {entry.useRegex && 正则} +
+
+ ))} +
+ + ) : ( +
+ 请选择一个世界书 +
+ )} +
+
+
+ + {/* 创建世界书模态框 */} + {showCreateModal && ( +
+
+

创建世界书

+
+ setWorldbookForm({ ...worldbookForm, name: e.target.value })} + className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white" + /> +