diff --git a/.gitignore b/.gitignore index e2fd613..5da267e 100644 --- a/.gitignore +++ b/.gitignore @@ -174,4 +174,5 @@ dist .yarn/install-state.gz .pnp.* -.claude \ No newline at end of file +.claude +uploads/ \ No newline at end of file diff --git a/server/api/v1/app/enter.go b/server/api/v1/app/enter.go index 64d77ff..367b49b 100644 --- a/server/api/v1/app/enter.go +++ b/server/api/v1/app/enter.go @@ -5,6 +5,7 @@ import "git.echol.cn/loser/st/server/service" type ApiGroup struct { AuthApi CharacterApi + WorldInfoApi } var ( diff --git a/server/api/v1/app/world_info.go b/server/api/v1/app/world_info.go new file mode 100644 index 0000000..79c98d6 --- /dev/null +++ b/server/api/v1/app/world_info.go @@ -0,0 +1,468 @@ +package app + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/middleware" + "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" + sysResponse "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 WorldInfoApi struct{} + +var worldInfoService = service.ServiceGroupApp.AppServiceGroup.WorldInfoService + +// CreateWorldBook 创建世界书 +// @Summary 创建世界书 +// @Description 创建一个新的世界书 +// @Tags 世界书管理 +// @Accept json +// @Produce json +// @Param data body request.CreateWorldBookRequest true "世界书信息" +// @Success 200 {object} response.Response{data=app.AIWorldInfo} +// @Router /app/worldbook [post] +func (a *WorldInfoApi) CreateWorldBook(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.CreateWorldBookRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + book, err := worldInfoService.CreateWorldBook(userID, &req) + if err != nil { + global.GVA_LOG.Error("创建世界书失败", zap.Error(err)) + sysResponse.FailWithMessage("创建失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(response.ToWorldBookResponse(book), c) +} + +// UpdateWorldBook 更新世界书 +// @Summary 更新世界书 +// @Description 更新世界书信息 +// @Tags 世界书管理 +// @Accept json +// @Produce json +// @Param id path int true "世界书ID" +// @Param data body request.UpdateWorldBookRequest true "世界书信息" +// @Success 200 {object} response.Response +// @Router /app/worldbook/:id [put] +func (a *WorldInfoApi) UpdateWorldBook(c *gin.Context) { + userID := middleware.GetAppUserID(c) + bookID := c.GetUint("id") + + var req request.UpdateWorldBookRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + if err := worldInfoService.UpdateWorldBook(userID, bookID, &req); err != nil { + global.GVA_LOG.Error("更新世界书失败", zap.Error(err)) + sysResponse.FailWithMessage("更新失败: "+err.Error(), c) + return + } + + sysResponse.OkWithMessage("更新成功", c) +} + +// DeleteWorldBook 删除世界书 +// @Summary 删除世界书 +// @Description 删除世界书 +// @Tags 世界书管理 +// @Accept json +// @Produce json +// @Param id path int true "世界书ID" +// @Success 200 {object} response.Response +// @Router /app/worldbook/:id [delete] +func (a *WorldInfoApi) DeleteWorldBook(c *gin.Context) { + userID := middleware.GetAppUserID(c) + bookID := c.GetUint("id") + + if err := worldInfoService.DeleteWorldBook(userID, bookID); err != nil { + global.GVA_LOG.Error("删除世界书失败", zap.Error(err)) + sysResponse.FailWithMessage("删除失败: "+err.Error(), c) + return + } + + sysResponse.OkWithMessage("删除成功", c) +} + +// GetWorldBook 获取世界书详情 +// @Summary 获取世界书详情 +// @Description 获取世界书详细信息 +// @Tags 世界书管理 +// @Accept json +// @Produce json +// @Param id path int true "世界书ID" +// @Success 200 {object} response.Response{data=response.WorldBookResponse} +// @Router /app/worldbook/:id [get] +func (a *WorldInfoApi) GetWorldBook(c *gin.Context) { + userID := middleware.GetAppUserID(c) + bookID := c.GetUint("id") + + book, err := worldInfoService.GetWorldBook(userID, bookID) + if err != nil { + global.GVA_LOG.Error("获取世界书失败", zap.Error(err)) + sysResponse.FailWithMessage("获取失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(response.ToWorldBookResponse(book), c) +} + +// GetWorldBookList 获取世界书列表 +// @Summary 获取世界书列表 +// @Description 获取世界书列表 +// @Tags 世界书管理 +// @Accept json +// @Produce json +// @Param data query request.WorldBookListRequest true "查询参数" +// @Success 200 {object} response.Response{data=response.WorldBookListResponse} +// @Router /app/worldbook/list [get] +func (a *WorldInfoApi) GetWorldBookList(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.WorldBookListRequest + if err := c.ShouldBindQuery(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + // 默认分页 + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = 10 + } + + result, err := worldInfoService.GetWorldBookList(userID, &req) + if err != nil { + global.GVA_LOG.Error("获取世界书列表失败", zap.Error(err)) + sysResponse.FailWithMessage("获取失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(result, c) +} + +// CreateWorldEntry 创建世界书条目 +// @Summary 创建世界书条目 +// @Description 在世界书中创建一个新条目 +// @Tags 世界书管理 +// @Accept json +// @Produce json +// @Param data body request.CreateWorldEntryRequest true "条目信息" +// @Success 200 {object} response.Response +// @Router /app/worldbook/entry [post] +func (a *WorldInfoApi) CreateWorldEntry(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.CreateWorldEntryRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + if err := worldInfoService.CreateWorldEntry(userID, req.BookID, &req.Entry); err != nil { + global.GVA_LOG.Error("创建条目失败", zap.Error(err)) + sysResponse.FailWithMessage("创建失败: "+err.Error(), c) + return + } + + sysResponse.OkWithMessage("创建成功", c) +} + +// UpdateWorldEntry 更新世界书条目 +// @Summary 更新世界书条目 +// @Description 更新世界书条目信息 +// @Tags 世界书管理 +// @Accept json +// @Produce json +// @Param data body request.UpdateWorldEntryRequest true "条目信息" +// @Success 200 {object} response.Response +// @Router /app/worldbook/entry [put] +func (a *WorldInfoApi) UpdateWorldEntry(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.UpdateWorldEntryRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + if err := worldInfoService.UpdateWorldEntry(userID, req.BookID, &req.Entry); err != nil { + global.GVA_LOG.Error("更新条目失败", zap.Error(err)) + sysResponse.FailWithMessage("更新失败: "+err.Error(), c) + return + } + + sysResponse.OkWithMessage("更新成功", c) +} + +// DeleteWorldEntry 删除世界书条目 +// @Summary 删除世界书条目 +// @Description 删除世界书条目 +// @Tags 世界书管理 +// @Accept json +// @Produce json +// @Param data body request.DeleteWorldEntryRequest true "删除参数" +// @Success 200 {object} response.Response +// @Router /app/worldbook/entry [delete] +func (a *WorldInfoApi) DeleteWorldEntry(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.DeleteWorldEntryRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + if err := worldInfoService.DeleteWorldEntry(userID, req.BookID, req.EntryID); err != nil { + global.GVA_LOG.Error("删除条目失败", zap.Error(err)) + sysResponse.FailWithMessage("删除失败: "+err.Error(), c) + return + } + + sysResponse.OkWithMessage("删除成功", c) +} + +// LinkCharactersToWorldBook 关联角色到世界书 +// @Summary 关联角色到世界书 +// @Description 将角色关联到世界书 +// @Tags 世界书管理 +// @Accept json +// @Produce json +// @Param data body request.LinkCharacterRequest true "关联参数" +// @Success 200 {object} response.Response +// @Router /app/worldbook/link [post] +func (a *WorldInfoApi) LinkCharactersToWorldBook(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.LinkCharacterRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + if err := worldInfoService.LinkCharactersToWorldBook(userID, req.BookID, req.CharacterIDs); err != nil { + global.GVA_LOG.Error("关联角色失败", zap.Error(err)) + sysResponse.FailWithMessage("关联失败: "+err.Error(), c) + return + } + + sysResponse.OkWithMessage("关联成功", c) +} + +// ImportWorldBook 导入世界书 +// @Summary 导入世界书 +// @Description 从文件导入世界书 +// @Tags 世界书管理 +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "世界书文件(JSON)" +// @Param bookName formData string false "世界书名称" +// @Success 200 {object} response.Response{data=app.AIWorldInfo} +// @Router /app/worldbook/import [post] +func (a *WorldInfoApi) ImportWorldBook(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 获取文件 + file, err := c.FormFile("file") + if err != nil { + sysResponse.FailWithMessage("请上传世界书文件", c) + return + } + + // 文件大小限制(10MB) + if file.Size > 10<<20 { + sysResponse.FailWithMessage("文件大小不能超过 10MB", c) + return + } + + // 读取文件内容 + src, err := file.Open() + if err != nil { + global.GVA_LOG.Error("打开文件失败", zap.Error(err)) + sysResponse.FailWithMessage("文件读取失败", c) + return + } + defer src.Close() + + fileData, err := io.ReadAll(src) + if err != nil { + global.GVA_LOG.Error("读取文件内容失败", zap.Error(err)) + sysResponse.FailWithMessage("文件读取失败", c) + return + } + + // 获取世界书名称 + bookName := c.PostForm("bookName") + + // 导入世界书 + book, err := worldInfoService.ImportWorldBook(userID, bookName, fileData, "json") + if err != nil { + global.GVA_LOG.Error("导入世界书失败", zap.Error(err)) + sysResponse.FailWithMessage("导入失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(response.ToWorldBookResponse(book), c) +} + +// ExportWorldBook 导出世界书 +// @Summary 导出世界书 +// @Description 导出世界书为 JSON 文件 +// @Tags 世界书管理 +// @Accept json +// @Produce application/json +// @Param id path int true "世界书ID" +// @Success 200 {object} response.WorldBookExportData +// @Router /app/worldbook/:id/export [get] +func (a *WorldInfoApi) ExportWorldBook(c *gin.Context) { + userID := middleware.GetAppUserID(c) + bookID := c.GetUint("id") + + exportData, err := worldInfoService.ExportWorldBook(userID, bookID) + if err != nil { + global.GVA_LOG.Error("导出世界书失败", zap.Error(err)) + sysResponse.FailWithMessage("导出失败: "+err.Error(), c) + return + } + + // 设置响应头 + filename := fmt.Sprintf("%s.json", exportData.Name) + c.Header("Content-Type", "application/json") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + + // 直接返回 JSON 数据 + c.JSON(http.StatusOK, exportData) +} + +// MatchWorldInfo 匹配世界书条目(用于聊天) +// @Summary 匹配世界书条目 +// @Description 根据消息内容匹配激活的世界书条目 +// @Tags 世界书管理 +// @Accept json +// @Produce json +// @Param data body request.MatchWorldInfoRequest true "匹配参数" +// @Success 200 {object} response.Response{data=response.MatchWorldInfoResponse} +// @Router /app/worldbook/match [post] +func (a *WorldInfoApi) MatchWorldInfo(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.MatchWorldInfoRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + // 默认值 + if req.ScanDepth < 1 { + req.ScanDepth = 10 + } + if req.MaxTokens < 100 { + req.MaxTokens = 2000 + } + + result, err := worldInfoService.MatchWorldInfo(userID, &req) + if err != nil { + global.GVA_LOG.Error("匹配世界书失败", zap.Error(err)) + sysResponse.FailWithMessage("匹配失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(result, c) +} + +// GetCharacterWorldBooks 获取角色关联的世界书列表 +// @Summary 获取角色关联的世界书列表 +// @Description 获取特定角色关联的所有世界书 +// @Tags 世界书管理 +// @Accept json +// @Produce json +// @Param characterId path int true "角色ID" +// @Success 200 {object} response.Response{data=[]response.WorldBookResponse} +// @Router /app/worldbook/character/:characterId [get] +func (a *WorldInfoApi) GetCharacterWorldBooks(c *gin.Context) { + userID := middleware.GetAppUserID(c) + characterID := c.GetUint("characterId") + + var books []app.AIWorldInfo + err := global.GVA_DB. + Where("user_id = ? AND (is_global = true OR ? = ANY(linked_chars))", userID, fmt.Sprintf("%d", characterID)). + Find(&books).Error + + if err != nil { + global.GVA_LOG.Error("获取角色世界书失败", zap.Error(err)) + sysResponse.FailWithMessage("获取失败: "+err.Error(), c) + return + } + + result := make([]response.WorldBookResponse, 0, len(books)) + for i := range books { + result = append(result, response.ToWorldBookResponse(&books[i])) + } + + sysResponse.OkWithData(result, c) +} + +// DuplicateWorldBook 复制世界书 +// @Summary 复制世界书 +// @Description 创建世界书的副本 +// @Tags 世界书管理 +// @Accept json +// @Produce json +// @Param id path int true "世界书ID" +// @Success 200 {object} response.Response{data=app.AIWorldInfo} +// @Router /app/worldbook/:id/duplicate [post] +func (a *WorldInfoApi) DuplicateWorldBook(c *gin.Context) { + userID := middleware.GetAppUserID(c) + bookID := c.GetUint("id") + + // 获取原世界书 + book, err := worldInfoService.GetWorldBook(userID, bookID) + if err != nil { + global.GVA_LOG.Error("获取世界书失败", zap.Error(err)) + sysResponse.FailWithMessage("获取失败: "+err.Error(), c) + return + } + + // 解析条目 + var entries []app.AIWorldInfoEntry + if err := json.Unmarshal([]byte(book.Entries), &entries); err != nil { + global.GVA_LOG.Error("解析条目失败", zap.Error(err)) + sysResponse.FailWithMessage("解析失败", c) + return + } + + // 创建副本 + req := request.CreateWorldBookRequest{ + BookName: book.BookName + " (副本)", + IsGlobal: book.IsGlobal, + Entries: entries, + LinkedChars: book.LinkedChars, + } + + newBook, err := worldInfoService.CreateWorldBook(userID, &req) + if err != nil { + global.GVA_LOG.Error("复制世界书失败", zap.Error(err)) + sysResponse.FailWithMessage("复制失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(response.ToWorldBookResponse(newBook), c) +} diff --git a/server/initialize/router.go b/server/initialize/router.go index d830b2b..ab6e614 100644 --- a/server/initialize/router.go +++ b/server/initialize/router.go @@ -146,6 +146,7 @@ func Routers() *gin.Engine { appGroup := PublicGroup.Group("app") // 统一使用 /app 前缀 appRouter.InitAuthRouter(appGroup) // 认证路由:/app/auth/* 和 /app/user/* appRouter.InitCharacterRouter(appGroup) // 角色卡路由:/app/character/* + appRouter.InitWorldInfoRouter(appGroup) // 世界书路由:/app/worldbook/* } //插件路由安装 diff --git a/server/model/app/ai_world_info.go b/server/model/app/ai_world_info.go index feb7191..049a762 100644 --- a/server/model/app/ai_world_info.go +++ b/server/model/app/ai_world_info.go @@ -9,18 +9,57 @@ import ( // AIWorldInfo 世界书(World Info)表 type AIWorldInfo struct { global.GVA_MODEL - UserID uint `json:"userId" gorm:"not null;index;comment:所属用户ID"` - User *AppUser `json:"user" gorm:"foreignKey:UserID"` - CharacterID *uint `json:"characterId" gorm:"index;comment:关联角色ID"` - Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"` - Name string `json:"name" gorm:"type:varchar(500);not null;comment:世界书名称"` - Keywords pq.StringArray `json:"keywords" gorm:"type:text[];comment:触发关键词"` - Content string `json:"content" gorm:"type:text;not null;comment:内容"` - Priority int `json:"priority" gorm:"default:0;comment:优先级"` - IsEnabled bool `json:"isEnabled" gorm:"default:true;comment:是否启用"` - TriggerConfig datatypes.JSON `json:"triggerConfig" gorm:"type:jsonb;comment:触发条件配置"` + UserID uint `json:"userId" gorm:"not null;index;comment:所属用户ID"` + User *AppUser `json:"user" gorm:"foreignKey:UserID"` + BookName string `json:"bookName" gorm:"type:varchar(500);not null;index;comment:世界书名称"` + IsGlobal bool `json:"isGlobal" gorm:"default:false;index;comment:是否全局世界书"` + Entries datatypes.JSON `json:"entries" gorm:"type:jsonb;not null;comment:世界书条目列表"` + LinkedChars pq.StringArray `json:"linkedChars" gorm:"type:text[];comment:关联的角色ID列表"` } func (AIWorldInfo) TableName() string { return "ai_world_info" } + +// AIWorldInfoEntry 世界书条目(存储在 AIWorldInfo.Entries 中) +type AIWorldInfoEntry struct { + UID string `json:"uid"` // 条目唯一ID + Keys []string `json:"keys"` // 主要关键词 + SecondaryKeys []string `json:"secondary_keys"` // 次要关键词(可选匹配) + Content string `json:"content"` // 条目内容 + Comment string `json:"comment"` // 备注 + Enabled bool `json:"enabled"` // 是否启用 + Constant bool `json:"constant"` // 永远激活 + Selective bool `json:"selective"` // 选择性激活(需要主键+次键都匹配) + Order int `json:"order"` // 插入顺序(越小越靠前) + Position string `json:"position"` // 插入位置:before_char, after_char + Depth int `json:"depth"` // 扫描深度(从最近消息往前扫描几条) + Probability int `json:"probability"` // 激活概率(0-100) + UseProbability bool `json:"use_probability"` // 是否使用概率 + Group string `json:"group"` // 分组(同组只激活一个) + GroupOverride bool `json:"group_override"` // 分组覆盖 + GroupWeight int `json:"group_weight"` // 分组权重 + PreventRecursion bool `json:"prevent_recursion"` // 防止递归激活 + DelayUntilRecursion bool `json:"delay_until_recursion"` // 延迟到递归时激活 + ScanDepth *int `json:"scan_depth"` // 扫描深度(nil=使用全局设置) + CaseSensitive *bool `json:"case_sensitive"` // 大小写敏感(nil=使用全局设置) + MatchWholeWords *bool `json:"match_whole_words"` // 匹配整个单词 + UseRegex *bool `json:"use_regex"` // 使用正则表达式 + Automation string `json:"automation_id"` // 自动化ID + Role string `json:"role"` // 角色(system/user/assistant) + VectorizedContent string `json:"vectorized"` // 向量化的内容ID + Extensions map[string]interface{} `json:"extensions"` // 扩展数据 +} + +// AICharacterWorldInfo 角色关联的世界书(中间表) +type AICharacterWorldInfo struct { + global.GVA_MODEL + CharacterID uint `json:"characterId" gorm:"not null;index:idx_char_world,unique;comment:角色ID"` + Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"` + WorldInfoID uint `json:"worldInfoId" gorm:"not null;index:idx_char_world,unique;comment:世界书ID"` + WorldInfo *AIWorldInfo `json:"worldInfo" gorm:"foreignKey:WorldInfoID"` +} + +func (AICharacterWorldInfo) TableName() string { + return "ai_character_world_info" +} diff --git a/server/model/app/request/world_info.go b/server/model/app/request/world_info.go new file mode 100644 index 0000000..0855cc4 --- /dev/null +++ b/server/model/app/request/world_info.go @@ -0,0 +1,71 @@ +package request + +import "git.echol.cn/loser/st/server/model/app" + +// CreateWorldBookRequest 创建世界书请求 +type CreateWorldBookRequest struct { + BookName string `json:"bookName" binding:"required,min=1,max=500"` + IsGlobal bool `json:"isGlobal"` + Entries []app.AIWorldInfoEntry `json:"entries" binding:"required"` + LinkedChars []string `json:"linkedChars"` +} + +// UpdateWorldBookRequest 更新世界书请求 +type UpdateWorldBookRequest struct { + BookName string `json:"bookName" binding:"required,min=1,max=500"` + IsGlobal bool `json:"isGlobal"` + Entries []app.AIWorldInfoEntry `json:"entries" binding:"required"` + LinkedChars []string `json:"linkedChars"` +} + +// WorldBookListRequest 世界书列表查询请求 +type WorldBookListRequest struct { + PageInfo + BookName string `json:"bookName" form:"bookName"` // 世界书名称(模糊搜索) + IsGlobal *bool `json:"isGlobal" form:"isGlobal"` // 是否全局 + CharacterID *uint `json:"characterId" form:"characterId"` // 关联角色ID +} + +// CreateWorldEntryRequest 创建世界书条目请求 +type CreateWorldEntryRequest struct { + BookID uint `json:"bookId" binding:"required"` + Entry app.AIWorldInfoEntry `json:"entry" binding:"required"` +} + +// UpdateWorldEntryRequest 更新世界书条目请求 +type UpdateWorldEntryRequest struct { + BookID uint `json:"bookId" binding:"required"` + Entry app.AIWorldInfoEntry `json:"entry" binding:"required"` +} + +// DeleteWorldEntryRequest 删除世界书条目请求 +type DeleteWorldEntryRequest struct { + BookID uint `json:"bookId" binding:"required"` + EntryID string `json:"entryId" binding:"required"` +} + +// LinkCharacterRequest 关联角色请求 +type LinkCharacterRequest struct { + BookID uint `json:"bookId" binding:"required"` + CharacterIDs []uint `json:"characterIds" binding:"required"` +} + +// WorldBookImportRequest 导入世界书请求 +type WorldBookImportRequest struct { + BookName string `json:"bookName" binding:"required"` + Format string `json:"format" binding:"required,oneof=json lorebook"` +} + +// WorldBookExportRequest 导出世界书请求 +type WorldBookExportRequest struct { + BookID uint `json:"bookId" binding:"required"` + Format string `json:"format" binding:"required,oneof=json lorebook"` +} + +// MatchWorldInfoRequest 匹配世界书条目请求(用于聊天时激活) +type MatchWorldInfoRequest struct { + CharacterID uint `json:"characterId" binding:"required"` + Messages []string `json:"messages" binding:"required"` + ScanDepth int `json:"scanDepth" binding:"min=1,max=100"` + MaxTokens int `json:"maxTokens" binding:"min=100"` +} diff --git a/server/model/app/response/world_info.go b/server/model/app/response/world_info.go new file mode 100644 index 0000000..e1a7b03 --- /dev/null +++ b/server/model/app/response/world_info.go @@ -0,0 +1,76 @@ +package response + +import ( + "encoding/json" + "git.echol.cn/loser/st/server/model/app" + "time" +) + +// WorldBookResponse 世界书响应 +type WorldBookResponse struct { + ID uint `json:"id"` + UserID uint `json:"userId"` + BookName string `json:"bookName"` + IsGlobal bool `json:"isGlobal"` + Entries []app.AIWorldInfoEntry `json:"entries"` + LinkedChars []string `json:"linkedChars"` + 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"` +} + +// WorldBookExportData 世界书导出数据 +type WorldBookExportData struct { + Name string `json:"name"` + Entries []app.AIWorldInfoEntry `json:"entries"` +} + +// MatchedWorldInfoEntry 匹配到的世界书条目 +type MatchedWorldInfoEntry struct { + Content string `json:"content"` + Position string `json:"position"` + Order int `json:"order"` + Source string `json:"source"` // 来源世界书名称 +} + +// MatchWorldInfoResponse 匹配世界书响应 +type MatchWorldInfoResponse struct { + Entries []MatchedWorldInfoEntry `json:"entries"` + TotalTokens int `json:"totalTokens"` +} + +// ToWorldBookResponse 转换为世界书响应 +func ToWorldBookResponse(book *app.AIWorldInfo) WorldBookResponse { + var entries []app.AIWorldInfoEntry + if book.Entries != nil { + _ = json.Unmarshal([]byte(book.Entries), &entries) + } + if entries == nil { + entries = []app.AIWorldInfoEntry{} + } + + linkedChars := book.LinkedChars + if linkedChars == nil { + linkedChars = []string{} + } + + return WorldBookResponse{ + ID: book.ID, + UserID: book.UserID, + BookName: book.BookName, + IsGlobal: book.IsGlobal, + Entries: entries, + LinkedChars: linkedChars, + EntryCount: len(entries), + CreatedAt: book.CreatedAt, + UpdatedAt: book.UpdatedAt, + } +} diff --git a/server/router/app/enter.go b/server/router/app/enter.go index 39fde4d..eb414a2 100644 --- a/server/router/app/enter.go +++ b/server/router/app/enter.go @@ -3,4 +3,5 @@ package app type RouterGroup struct { AuthRouter CharacterRouter + WorldInfoRouter } diff --git a/server/router/app/world_info.go b/server/router/app/world_info.go new file mode 100644 index 0000000..88636aa --- /dev/null +++ b/server/router/app/world_info.go @@ -0,0 +1,40 @@ +package app + +import ( + "git.echol.cn/loser/st/server/api/v1" + "git.echol.cn/loser/st/server/middleware" + "github.com/gin-gonic/gin" +) + +type WorldInfoRouter struct{} + +func (r *WorldInfoRouter) InitWorldInfoRouter(Router *gin.RouterGroup) { + worldInfoRouter := Router.Group("worldbook").Use(middleware.AppJWTAuth()) + worldInfoApi := v1.ApiGroupApp.AppApiGroup.WorldInfoApi + + { + // 世界书管理 + worldInfoRouter.POST("", worldInfoApi.CreateWorldBook) // 创建世界书 + worldInfoRouter.PUT("/:id", worldInfoApi.UpdateWorldBook) // 更新世界书 + worldInfoRouter.DELETE("/:id", worldInfoApi.DeleteWorldBook) // 删除世界书 + worldInfoRouter.GET("/:id", worldInfoApi.GetWorldBook) // 获取世界书详情 + worldInfoRouter.GET("/list", worldInfoApi.GetWorldBookList) // 获取世界书列表 + worldInfoRouter.POST("/:id/duplicate", worldInfoApi.DuplicateWorldBook) // 复制世界书 + + // 条目管理 + worldInfoRouter.POST("/entry", worldInfoApi.CreateWorldEntry) // 创建条目 + worldInfoRouter.PUT("/entry", worldInfoApi.UpdateWorldEntry) // 更新条目 + worldInfoRouter.DELETE("/entry", worldInfoApi.DeleteWorldEntry) // 删除条目 + + // 关联管理 + worldInfoRouter.POST("/link", worldInfoApi.LinkCharactersToWorldBook) // 关联角色 + worldInfoRouter.GET("/character/:characterId", worldInfoApi.GetCharacterWorldBooks) // 获取角色的世界书 + + // 导入导出 + worldInfoRouter.POST("/import", worldInfoApi.ImportWorldBook) // 导入世界书 + worldInfoRouter.GET("/:id/export", worldInfoApi.ExportWorldBook) // 导出世界书 + + // 匹配引擎(用于聊天) + worldInfoRouter.POST("/match", worldInfoApi.MatchWorldInfo) // 匹配世界书条目 + } +} diff --git a/server/service/app/enter.go b/server/service/app/enter.go index 6ea27df..4b2f195 100644 --- a/server/service/app/enter.go +++ b/server/service/app/enter.go @@ -3,4 +3,5 @@ package app type AppServiceGroup struct { AuthService CharacterService + WorldInfoService } diff --git a/server/service/app/world_info.go b/server/service/app/world_info.go new file mode 100644 index 0000000..ff25403 --- /dev/null +++ b/server/service/app/world_info.go @@ -0,0 +1,713 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "math/rand" + "regexp" + "sort" + "strings" + + "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" + "github.com/google/uuid" + "github.com/lib/pq" + "go.uber.org/zap" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type WorldInfoService struct{} + +// CreateWorldBook 创建世界书 +func (s *WorldInfoService) CreateWorldBook(userID uint, req *request.CreateWorldBookRequest) (*app.AIWorldInfo, error) { + // 验证条目 UID 唯一性 + if err := s.validateEntries(req.Entries); err != nil { + return nil, err + } + + entriesJSON, err := json.Marshal(req.Entries) + if err != nil { + return nil, errors.New("条目数据序列化失败") + } + + worldBook := &app.AIWorldInfo{ + UserID: userID, + BookName: req.BookName, + IsGlobal: req.IsGlobal, + Entries: datatypes.JSON(entriesJSON), + LinkedChars: req.LinkedChars, + } + + if err := global.GVA_DB.Create(worldBook).Error; err != nil { + return nil, err + } + + return worldBook, nil +} + +// UpdateWorldBook 更新世界书 +func (s *WorldInfoService) UpdateWorldBook(userID, bookID uint, req *request.UpdateWorldBookRequest) error { + // 验证条目 UID 唯一性 + if err := s.validateEntries(req.Entries); err != nil { + return err + } + + entriesJSON, err := json.Marshal(req.Entries) + if err != nil { + return errors.New("条目数据序列化失败") + } + + result := global.GVA_DB.Model(&app.AIWorldInfo{}). + Where("id = ? AND user_id = ?", bookID, userID). + Updates(map[string]interface{}{ + "book_name": req.BookName, + "is_global": req.IsGlobal, + "entries": datatypes.JSON(entriesJSON), + "linked_chars": pq.StringArray(req.LinkedChars), + }) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return errors.New("世界书不存在或无权限") + } + + return nil +} + +// DeleteWorldBook 删除世界书 +func (s *WorldInfoService) DeleteWorldBook(userID, bookID uint) error { + result := global.GVA_DB. + Where("id = ? AND user_id = ?", bookID, userID). + Delete(&app.AIWorldInfo{}) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return errors.New("世界书不存在或无权限") + } + + return nil +} + +// GetWorldBook 获取世界书详情 +func (s *WorldInfoService) GetWorldBook(userID, bookID uint) (*app.AIWorldInfo, error) { + var book app.AIWorldInfo + err := global.GVA_DB. + Where("id = ? AND user_id = ?", bookID, userID). + First(&book).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("世界书不存在") + } + return nil, err + } + + return &book, nil +} + +// GetWorldBookList 获取世界书列表 +func (s *WorldInfoService) GetWorldBookList(userID uint, req *request.WorldBookListRequest) (*response.WorldBookListResponse, error) { + var books []app.AIWorldInfo + var total int64 + + db := global.GVA_DB.Model(&app.AIWorldInfo{}).Where("user_id = ?", userID) + + // 条件过滤 + if req.BookName != "" { + db = db.Where("book_name ILIKE ?", "%"+req.BookName+"%") + } + if req.IsGlobal != nil { + db = db.Where("is_global = ?", *req.IsGlobal) + } + if req.CharacterID != nil { + db = db.Where("? = ANY(linked_chars)", fmt.Sprintf("%d", *req.CharacterID)) + } + + // 总数 + if err := db.Count(&total).Error; err != nil { + return nil, err + } + + // 分页查询 + offset := (req.Page - 1) * req.PageSize + if err := db.Offset(offset).Limit(req.PageSize).Order("updated_at DESC").Find(&books).Error; err != nil { + return nil, err + } + + // 转换响应 + list := make([]response.WorldBookResponse, 0, len(books)) + for i := range books { + list = append(list, response.ToWorldBookResponse(&books[i])) + } + + return &response.WorldBookListResponse{ + List: list, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, nil +} + +// CreateWorldEntry 创建世界书条目 +func (s *WorldInfoService) CreateWorldEntry(userID, bookID uint, entry *app.AIWorldInfoEntry) error { + book, err := s.GetWorldBook(userID, bookID) + if err != nil { + return err + } + + var entries []app.AIWorldInfoEntry + if err := json.Unmarshal([]byte(book.Entries), &entries); err != nil { + return errors.New("条目数据解析失败") + } + + // 生成唯一 UID + if entry.UID == "" { + entry.UID = uuid.New().String() + } + + // 检查 UID 是否重复 + for _, e := range entries { + if e.UID == entry.UID { + return errors.New("条目 UID 已存在") + } + } + + entries = append(entries, *entry) + + entriesJSON, err := json.Marshal(entries) + if err != nil { + return errors.New("条目数据序列化失败") + } + + return global.GVA_DB.Model(&app.AIWorldInfo{}). + Where("id = ? AND user_id = ?", bookID, userID). + Update("entries", datatypes.JSON(entriesJSON)).Error +} + +// UpdateWorldEntry 更新世界书条目 +func (s *WorldInfoService) UpdateWorldEntry(userID, bookID uint, entry *app.AIWorldInfoEntry) error { + book, err := s.GetWorldBook(userID, bookID) + if err != nil { + return err + } + + var entries []app.AIWorldInfoEntry + if err := json.Unmarshal([]byte(book.Entries), &entries); err != nil { + return errors.New("条目数据解析失败") + } + + found := false + for i := range entries { + if entries[i].UID == entry.UID { + entries[i] = *entry + found = true + break + } + } + + if !found { + return errors.New("条目不存在") + } + + entriesJSON, err := json.Marshal(entries) + if err != nil { + return errors.New("条目数据序列化失败") + } + + return global.GVA_DB.Model(&app.AIWorldInfo{}). + Where("id = ? AND user_id = ?", bookID, userID). + Update("entries", datatypes.JSON(entriesJSON)).Error +} + +// DeleteWorldEntry 删除世界书条目 +func (s *WorldInfoService) DeleteWorldEntry(userID, bookID uint, entryID string) error { + book, err := s.GetWorldBook(userID, bookID) + if err != nil { + return err + } + + var entries []app.AIWorldInfoEntry + if err := json.Unmarshal([]byte(book.Entries), &entries); err != nil { + return errors.New("条目数据解析失败") + } + + newEntries := make([]app.AIWorldInfoEntry, 0) + found := false + for _, e := range entries { + if e.UID != entryID { + newEntries = append(newEntries, e) + } else { + found = true + } + } + + if !found { + return errors.New("条目不存在") + } + + entriesJSON, err := json.Marshal(newEntries) + if err != nil { + return errors.New("条目数据序列化失败") + } + + return global.GVA_DB.Model(&app.AIWorldInfo{}). + Where("id = ? AND user_id = ?", bookID, userID). + Update("entries", datatypes.JSON(entriesJSON)).Error +} + +// LinkCharactersToWorldBook 关联角色到世界书 +func (s *WorldInfoService) LinkCharactersToWorldBook(userID, bookID uint, characterIDs []uint) error { + // 验证世界书是否存在 + _, err := s.GetWorldBook(userID, bookID) + if err != nil { + return err + } + + // 验证角色是否属于该用户 + var count int64 + err = global.GVA_DB.Model(&app.AICharacter{}). + Where("user_id = ? AND id IN ?", userID, characterIDs). + Count(&count).Error + if err != nil { + return err + } + if int(count) != len(characterIDs) { + return errors.New("部分角色不存在或无权限") + } + + // 转换为字符串数组 + linkedChars := make([]string, len(characterIDs)) + for i, id := range characterIDs { + linkedChars[i] = fmt.Sprintf("%d", id) + } + + return global.GVA_DB.Model(&app.AIWorldInfo{}). + Where("id = ? AND user_id = ?", bookID, userID). + Update("linked_chars", pq.StringArray(linkedChars)).Error +} + +// ImportWorldBook 导入世界书 +func (s *WorldInfoService) ImportWorldBook(userID uint, bookName string, data []byte, format string) (*app.AIWorldInfo, error) { + var entries []app.AIWorldInfoEntry + + switch format { + case "json", "lorebook": + var importData struct { + Name string `json:"name"` + Entries []app.AIWorldInfoEntry `json:"entries"` + } + if err := json.Unmarshal(data, &importData); err != nil { + return nil, errors.New("JSON 格式解析失败") + } + entries = importData.Entries + if bookName == "" && importData.Name != "" { + bookName = importData.Name + } + default: + return nil, errors.New("不支持的导入格式") + } + + if bookName == "" { + bookName = "导入的世界书" + } + + // 为没有 UID 的条目生成 UID + for i := range entries { + if entries[i].UID == "" { + entries[i].UID = uuid.New().String() + } + } + + // 验证条目 UID 唯一性 + if err := s.validateEntries(entries); err != nil { + return nil, err + } + + entriesJSON, err := json.Marshal(entries) + if err != nil { + return nil, errors.New("条目数据序列化失败") + } + + worldBook := &app.AIWorldInfo{ + UserID: userID, + BookName: bookName, + IsGlobal: false, + Entries: datatypes.JSON(entriesJSON), + LinkedChars: []string{}, + } + + if err := global.GVA_DB.Create(worldBook).Error; err != nil { + return nil, err + } + + return worldBook, nil +} + +// ExportWorldBook 导出世界书 +func (s *WorldInfoService) ExportWorldBook(userID, bookID uint) (*response.WorldBookExportData, error) { + book, err := s.GetWorldBook(userID, bookID) + if err != nil { + return nil, err + } + + var entries []app.AIWorldInfoEntry + if err := json.Unmarshal([]byte(book.Entries), &entries); err != nil { + return nil, errors.New("条目数据解析失败") + } + + return &response.WorldBookExportData{ + Name: book.BookName, + Entries: entries, + }, nil +} + +// MatchWorldInfo 匹配世界书条目(用于聊天时激活) +func (s *WorldInfoService) MatchWorldInfo(userID uint, req *request.MatchWorldInfoRequest) (*response.MatchWorldInfoResponse, error) { + // 获取角色关联的世界书 + var books []app.AIWorldInfo + err := global.GVA_DB. + Where("user_id = ? AND (is_global = true OR ? = ANY(linked_chars))", userID, fmt.Sprintf("%d", req.CharacterID)). + Find(&books).Error + if err != nil { + return nil, err + } + + // 合并所有世界书的条目 + var allEntries []struct { + Entry app.AIWorldInfoEntry + Source string + } + + for _, book := range books { + var entries []app.AIWorldInfoEntry + if err := json.Unmarshal([]byte(book.Entries), &entries); err != nil { + global.GVA_LOG.Warn("世界书条目解析失败", + zap.Uint("bookID", book.ID), + zap.String("bookName", book.BookName), + zap.Error(err)) + continue + } + + for _, entry := range entries { + if entry.Enabled { + allEntries = append(allEntries, struct { + Entry app.AIWorldInfoEntry + Source string + }{Entry: entry, Source: book.BookName}) + } + } + } + + // 匹配条目 + matched := s.matchEntries(allEntries, req.Messages, req.ScanDepth, req.MaxTokens) + + // 计算总 Token 数(简单估算:每 4 个字符 = 1 token) + totalTokens := 0 + for _, m := range matched { + totalTokens += len(m.Content) / 4 + } + + return &response.MatchWorldInfoResponse{ + Entries: matched, + TotalTokens: totalTokens, + }, nil +} + +// matchEntries 核心匹配引擎 +func (s *WorldInfoService) matchEntries( + allEntries []struct { + Entry app.AIWorldInfoEntry + Source string + }, + messages []string, + scanDepth int, + maxTokens int, +) []response.MatchedWorldInfoEntry { + // 获取扫描范围内的消息 + scanMessages := messages + if scanDepth > 0 && scanDepth < len(messages) { + scanMessages = messages[len(messages)-scanDepth:] + } + + // 合并扫描文本 + scanText := strings.Join(scanMessages, "\n") + + // 第一轮:匹配常驻条目(Constant) + var matched []matchedEntry + for _, item := range allEntries { + if item.Entry.Constant { + matched = append(matched, matchedEntry{ + entry: item.Entry, + source: item.Source, + depth: 0, + }) + } + } + + // 第二轮:正常匹配 + for _, item := range allEntries { + if item.Entry.Constant { + continue // 常驻条目已处理 + } + + // 检查概率 + if item.Entry.UseProbability && item.Entry.Probability < 100 { + if rand.Intn(100) >= item.Entry.Probability { + continue + } + } + + // 匹配关键词 + isMatch := s.matchKeys(scanText, item.Entry.Keys, item.Entry) + + // 如果是选择性激活,还需要匹配次要关键词 + if isMatch && item.Entry.Selective && len(item.Entry.SecondaryKeys) > 0 { + secondaryMatch := s.matchKeys(scanText, item.Entry.SecondaryKeys, item.Entry) + if !secondaryMatch { + isMatch = false + } + } + + if isMatch { + matched = append(matched, matchedEntry{ + entry: item.Entry, + source: item.Source, + depth: 0, + }) + } + } + + // 第三轮:递归激活 + recursiveDepth := 1 + for recursiveDepth <= 3 { // 最多递归 3 层 + newMatches := []matchedEntry{} + + for _, item := range allEntries { + // 跳过已匹配的条目 + alreadyMatched := false + for _, m := range matched { + if m.entry.UID == item.Entry.UID { + alreadyMatched = true + break + } + } + if alreadyMatched { + continue + } + + // 检查是否设置了递归延迟 + if item.Entry.DelayUntilRecursion && recursiveDepth == 1 { + continue + } + + // 防止递归 + if item.Entry.PreventRecursion { + continue + } + + // 在已匹配的内容中查找 + for _, m := range matched { + if m.depth == recursiveDepth-1 { + isMatch := s.matchKeys(m.entry.Content, item.Entry.Keys, item.Entry) + + if isMatch && item.Entry.Selective && len(item.Entry.SecondaryKeys) > 0 { + secondaryMatch := s.matchKeys(m.entry.Content, item.Entry.SecondaryKeys, item.Entry) + if !secondaryMatch { + isMatch = false + } + } + + if isMatch { + newMatches = append(newMatches, matchedEntry{ + entry: item.Entry, + source: item.Source, + depth: recursiveDepth, + }) + break + } + } + } + } + + if len(newMatches) == 0 { + break // 没有新匹配,停止递归 + } + + matched = append(matched, newMatches...) + recursiveDepth++ + } + + // 分组处理(同组只保留一个) + matched = s.applyGroupFilters(matched) + + // 排序(按 Order 排序) + sort.Slice(matched, func(i, j int) bool { + return matched[i].entry.Order < matched[j].entry.Order + }) + + // Token 限制 + result := []response.MatchedWorldInfoEntry{} + currentTokens := 0 + for _, m := range matched { + entryTokens := len(m.entry.Content) / 4 + if currentTokens+entryTokens > maxTokens { + break + } + + result = append(result, response.MatchedWorldInfoEntry{ + Content: m.entry.Content, + Position: m.entry.Position, + Order: m.entry.Order, + Source: m.source, + }) + + currentTokens += entryTokens + } + + return result +} + +// matchedEntry 内部匹配结果 +type matchedEntry struct { + entry app.AIWorldInfoEntry + source string + depth int // 递归深度 +} + +// matchKeys 关键词匹配 +func (s *WorldInfoService) matchKeys(text string, keys []string, entry app.AIWorldInfoEntry) bool { + // 确定大小写敏感性 + caseSensitive := false + if entry.CaseSensitive != nil { + caseSensitive = *entry.CaseSensitive + } + + // 确定是否使用正则 + useRegex := false + if entry.UseRegex != nil { + useRegex = *entry.UseRegex + } + + // 确定是否匹配整词 + matchWholeWords := false + if entry.MatchWholeWords != nil { + matchWholeWords = *entry.MatchWholeWords + } + + searchText := text + if !caseSensitive { + searchText = strings.ToLower(text) + } + + for _, key := range keys { + if key == "" { + continue + } + + searchKey := key + if !caseSensitive { + searchKey = strings.ToLower(key) + } + + if useRegex { + // 正则匹配 + pattern := searchKey + if !caseSensitive { + pattern = "(?i)" + pattern + } + matched, err := regexp.MatchString(pattern, text) + if err != nil { + global.GVA_LOG.Warn("正则表达式匹配失败", + zap.String("pattern", pattern), + zap.Error(err)) + continue + } + if matched { + return true + } + } else if matchWholeWords { + // 整词匹配 + pattern := `\b` + regexp.QuoteMeta(searchKey) + `\b` + if !caseSensitive { + pattern = "(?i)" + pattern + } + matched, err := regexp.MatchString(pattern, searchText) + if err != nil { + global.GVA_LOG.Warn("整词匹配失败", + zap.String("pattern", pattern), + zap.Error(err)) + continue + } + if matched { + return true + } + } else { + // 普通子串匹配 + if strings.Contains(searchText, searchKey) { + return true + } + } + } + + return false +} + +// applyGroupFilters 应用分组过滤 +func (s *WorldInfoService) applyGroupFilters(matched []matchedEntry) []matchedEntry { + // 按分组分类 + groups := make(map[string][]matchedEntry) + noGroup := []matchedEntry{} + + for _, m := range matched { + if m.entry.Group == "" { + noGroup = append(noGroup, m) + } else { + groups[m.entry.Group] = append(groups[m.entry.Group], m) + } + } + + // 每个分组只保留一个(权重最高的) + result := noGroup + for _, group := range groups { + if len(group) == 0 { + continue + } + + // 按权重排序 + sort.Slice(group, func(i, j int) bool { + if group[i].entry.GroupWeight != group[j].entry.GroupWeight { + return group[i].entry.GroupWeight > group[j].entry.GroupWeight + } + return group[i].entry.Order < group[j].entry.Order + }) + + result = append(result, group[0]) + } + + return result +} + +// validateEntries 验证条目 UID 唯一性 +func (s *WorldInfoService) validateEntries(entries []app.AIWorldInfoEntry) error { + uidMap := make(map[string]bool) + for _, entry := range entries { + if entry.UID == "" { + return errors.New("条目 UID 不能为空") + } + if uidMap[entry.UID] { + return errors.New("条目 UID 重复: " + entry.UID) + } + uidMap[entry.UID] = true + } + return nil +} diff --git a/web-app-vue/src/api/worldInfo.ts b/web-app-vue/src/api/worldInfo.ts new file mode 100644 index 0000000..54a0a98 --- /dev/null +++ b/web-app-vue/src/api/worldInfo.ts @@ -0,0 +1,158 @@ +import request from '@/utils/request' +import type { + WorldBook, + WorldBookListResponse, + WorldBookListParams, + CreateWorldBookRequest, + UpdateWorldBookRequest, + WorldInfoEntry, + CreateWorldEntryRequest, + UpdateWorldEntryRequest, + DeleteWorldEntryRequest, + LinkCharacterRequest, + WorldBookExportData, + MatchWorldInfoRequest, + MatchWorldInfoResponse +} from '@/types/worldInfo' +import type { ApiResponse } from '@/types/api' + +// ========== 世界书管理 ========== + +/** + * 创建世界书 + */ +export const createWorldBook = (data: CreateWorldBookRequest) => { + return request.post>('/app/worldbook', data) +} + +/** + * 更新世界书 + */ +export const updateWorldBook = (id: number, data: UpdateWorldBookRequest) => { + return request.put>(`/app/worldbook/${id}`, data) +} + +/** + * 删除世界书 + */ +export const deleteWorldBook = (id: number) => { + return request.delete>(`/app/worldbook/${id}`) +} + +/** + * 获取世界书详情 + */ +export const getWorldBook = (id: number) => { + return request.get>(`/app/worldbook/${id}`) +} + +/** + * 获取世界书列表 + */ +export const getWorldBookList = (params: WorldBookListParams) => { + return request.get>('/app/worldbook/list', { params }) +} + +/** + * 复制世界书 + */ +export const duplicateWorldBook = (id: number) => { + return request.post>(`/app/worldbook/${id}/duplicate`) +} + +// ========== 条目管理 ========== + +/** + * 创建世界书条目 + */ +export const createWorldEntry = (data: CreateWorldEntryRequest) => { + return request.post>('/app/worldbook/entry', data) +} + +/** + * 更新世界书条目 + */ +export const updateWorldEntry = (data: UpdateWorldEntryRequest) => { + return request.put>('/app/worldbook/entry', data) +} + +/** + * 删除世界书条目 + */ +export const deleteWorldEntry = (data: DeleteWorldEntryRequest) => { + return request.delete>('/app/worldbook/entry', { data }) +} + +// ========== 关联管理 ========== + +/** + * 关联角色到世界书 + */ +export const linkCharactersToWorldBook = (data: LinkCharacterRequest) => { + return request.post>('/app/worldbook/link', data) +} + +/** + * 获取角色关联的世界书列表 + */ +export const getCharacterWorldBooks = (characterId: number) => { + return request.get>(`/app/worldbook/character/${characterId}`) +} + +// ========== 导入导出 ========== + +/** + * 导入世界书 + */ +export const importWorldBook = (file: File, bookName?: string) => { + const formData = new FormData() + formData.append('file', file) + if (bookName) { + formData.append('bookName', bookName) + } + + return request.post>('/app/worldbook/import', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) +} + +/** + * 导出世界书(JSON) + */ +export const exportWorldBook = (id: number) => { + return request.get(`/app/worldbook/${id}/export`) +} + +/** + * 下载世界书 JSON 文件 + */ +export const downloadWorldBookJSON = async (id: number, bookName: string) => { + try { + const response = await exportWorldBook(id) + + // 创建 Blob 并下载 + const blob = new Blob([JSON.stringify(response, null, 2)], { type: 'application/json' }) + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `${bookName}.json` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + } catch (error) { + console.error('导出世界书失败:', error) + throw error + } +} + +// ========== 匹配引擎 ========== + +/** + * 匹配世界书条目(用于聊天) + */ +export const matchWorldInfo = (data: MatchWorldInfoRequest) => { + return request.post>('/app/worldbook/match', data) +} diff --git a/web-app-vue/src/stores/worldInfo.ts b/web-app-vue/src/stores/worldInfo.ts new file mode 100644 index 0000000..a32ef11 --- /dev/null +++ b/web-app-vue/src/stores/worldInfo.ts @@ -0,0 +1,298 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { + WorldBook, + WorldBookListParams, + CreateWorldBookRequest, + UpdateWorldBookRequest, + WorldInfoEntry, + MatchWorldInfoRequest, + MatchedWorldInfoEntry +} from '@/types/worldInfo' +import * as worldInfoApi from '@/api/worldInfo' +import { ElMessage } from 'element-plus' + +export const useWorldInfoStore = defineStore('worldInfo', () => { + // 状态 + const worldBooks = ref([]) + const currentWorldBook = ref(null) + const characterWorldBooks = ref([]) + const loading = ref(false) + const total = ref(0) + const currentPage = ref(1) + const pageSize = ref(10) + + // 获取世界书列表 + const fetchWorldBookList = async (params?: Partial) => { + try { + loading.value = true + const requestParams: WorldBookListParams = { + page: params?.page || currentPage.value, + pageSize: params?.pageSize || pageSize.value, + bookName: params?.bookName, + isGlobal: params?.isGlobal, + characterId: params?.characterId + } + + const response = await worldInfoApi.getWorldBookList(requestParams) + worldBooks.value = response.data.data.list + total.value = response.data.data.total + currentPage.value = response.data.data.page + pageSize.value = response.data.data.pageSize + } catch (error: any) { + console.error('获取世界书列表失败:', error) + ElMessage.error(error.response?.data?.msg || '获取世界书列表失败') + throw error + } finally { + loading.value = false + } + } + + // 获取世界书详情 + const fetchWorldBookDetail = async (id: number) => { + try { + loading.value = true + const response = await worldInfoApi.getWorldBook(id) + currentWorldBook.value = response.data.data + return response.data.data + } catch (error: any) { + console.error('获取世界书详情失败:', error) + ElMessage.error(error.response?.data?.msg || '获取世界书详情失败') + throw error + } finally { + loading.value = false + } + } + + // 创建世界书 + const createWorldBook = async (data: CreateWorldBookRequest) => { + try { + loading.value = true + const response = await worldInfoApi.createWorldBook(data) + ElMessage.success('创建成功') + await fetchWorldBookList() + return response.data.data + } catch (error: any) { + console.error('创建世界书失败:', error) + ElMessage.error(error.response?.data?.msg || '创建世界书失败') + throw error + } finally { + loading.value = false + } + } + + // 更新世界书 + const updateWorldBook = async (id: number, data: UpdateWorldBookRequest) => { + try { + loading.value = true + await worldInfoApi.updateWorldBook(id, data) + ElMessage.success('更新成功') + await fetchWorldBookList() + } catch (error: any) { + console.error('更新世界书失败:', error) + ElMessage.error(error.response?.data?.msg || '更新世界书失败') + throw error + } finally { + loading.value = false + } + } + + // 删除世界书 + const deleteWorldBook = async (id: number) => { + try { + loading.value = true + await worldInfoApi.deleteWorldBook(id) + ElMessage.success('删除成功') + await fetchWorldBookList() + } catch (error: any) { + console.error('删除世界书失败:', error) + ElMessage.error(error.response?.data?.msg || '删除世界书失败') + throw error + } finally { + loading.value = false + } + } + + // 复制世界书 + const duplicateWorldBook = async (id: number) => { + try { + loading.value = true + const response = await worldInfoApi.duplicateWorldBook(id) + ElMessage.success('复制成功') + await fetchWorldBookList() + return response.data.data + } catch (error: any) { + console.error('复制世界书失败:', error) + ElMessage.error(error.response?.data?.msg || '复制世界书失败') + throw error + } finally { + loading.value = false + } + } + + // 添加条目 + const addEntry = async (bookId: number, entry: WorldInfoEntry) => { + try { + loading.value = true + await worldInfoApi.createWorldEntry({ bookId, entry }) + ElMessage.success('添加条目成功') + if (currentWorldBook.value?.id === bookId) { + await fetchWorldBookDetail(bookId) + } + } catch (error: any) { + console.error('添加条目失败:', error) + ElMessage.error(error.response?.data?.msg || '添加条目失败') + throw error + } finally { + loading.value = false + } + } + + // 更新条目 + const updateEntry = async (bookId: number, entry: WorldInfoEntry) => { + try { + loading.value = true + await worldInfoApi.updateWorldEntry({ bookId, entry }) + ElMessage.success('更新条目成功') + if (currentWorldBook.value?.id === bookId) { + await fetchWorldBookDetail(bookId) + } + } catch (error: any) { + console.error('更新条目失败:', error) + ElMessage.error(error.response?.data?.msg || '更新条目失败') + throw error + } finally { + loading.value = false + } + } + + // 删除条目 + const deleteEntry = async (bookId: number, entryId: string) => { + try { + loading.value = true + await worldInfoApi.deleteWorldEntry({ bookId, entryId }) + ElMessage.success('删除条目成功') + if (currentWorldBook.value?.id === bookId) { + await fetchWorldBookDetail(bookId) + } + } catch (error: any) { + console.error('删除条目失败:', error) + ElMessage.error(error.response?.data?.msg || '删除条目失败') + throw error + } finally { + loading.value = false + } + } + + // 关联角色 + const linkCharacters = async (bookId: number, characterIds: number[]) => { + try { + loading.value = true + await worldInfoApi.linkCharactersToWorldBook({ bookId, characterIds }) + ElMessage.success('关联角色成功') + if (currentWorldBook.value?.id === bookId) { + await fetchWorldBookDetail(bookId) + } + } catch (error: any) { + console.error('关联角色失败:', error) + ElMessage.error(error.response?.data?.msg || '关联角色失败') + throw error + } finally { + loading.value = false + } + } + + // 获取角色关联的世界书 + const fetchCharacterWorldBooks = async (characterId: number) => { + try { + loading.value = true + const response = await worldInfoApi.getCharacterWorldBooks(characterId) + characterWorldBooks.value = response.data.data + return response.data.data + } catch (error: any) { + console.error('获取角色世界书失败:', error) + ElMessage.error(error.response?.data?.msg || '获取角色世界书失败') + throw error + } finally { + loading.value = false + } + } + + // 导入世界书 + const importWorldBook = async (file: File, bookName?: string) => { + try { + loading.value = true + const response = await worldInfoApi.importWorldBook(file, bookName) + ElMessage.success('导入成功') + await fetchWorldBookList() + return response.data.data + } catch (error: any) { + console.error('导入世界书失败:', error) + ElMessage.error(error.response?.data?.msg || '导入世界书失败') + throw error + } finally { + loading.value = false + } + } + + // 导出世界书 + const exportWorldBook = async (id: number, bookName: string) => { + try { + loading.value = true + await worldInfoApi.downloadWorldBookJSON(id, bookName) + ElMessage.success('导出成功') + } catch (error: any) { + console.error('导出世界书失败:', error) + ElMessage.error(error.response?.data?.msg || '导出世界书失败') + throw error + } finally { + loading.value = false + } + } + + // 匹配世界书条目(用于聊天) + const matchWorldInfo = async (params: MatchWorldInfoRequest): Promise => { + try { + const response = await worldInfoApi.matchWorldInfo(params) + return response.data.data.entries + } catch (error: any) { + console.error('匹配世界书失败:', error) + return [] + } + } + + // 重置分页 + const resetPagination = () => { + currentPage.value = 1 + pageSize.value = 10 + total.value = 0 + } + + return { + // 状态 + worldBooks, + currentWorldBook, + characterWorldBooks, + loading, + total, + currentPage, + pageSize, + + // 方法 + fetchWorldBookList, + fetchWorldBookDetail, + createWorldBook, + updateWorldBook, + deleteWorldBook, + duplicateWorldBook, + addEntry, + updateEntry, + deleteEntry, + linkCharacters, + fetchCharacterWorldBooks, + importWorldBook, + exportWorldBook, + matchWorldInfo, + resetPagination + } +}) diff --git a/web-app-vue/src/types/worldInfo.d.ts b/web-app-vue/src/types/worldInfo.d.ts new file mode 100644 index 0000000..e9d2172 --- /dev/null +++ b/web-app-vue/src/types/worldInfo.d.ts @@ -0,0 +1,127 @@ +// 世界书条目 +export interface WorldInfoEntry { + uid: string; // 条目唯一ID + keys: string[]; // 主要关键词 + secondary_keys?: string[]; // 次要关键词 + content: string; // 条目内容 + comment?: string; // 备注 + enabled: boolean; // 是否启用 + constant?: boolean; // 永远激活 + selective?: boolean; // 选择性激活 + order: number; // 插入顺序 + position?: string; // 插入位置:before_char, after_char + depth?: number; // 扫描深度 + probability?: number; // 激活概率(0-100) + use_probability?: boolean; // 是否使用概率 + group?: string; // 分组 + group_override?: boolean; // 分组覆盖 + group_weight?: number; // 分组权重 + prevent_recursion?: boolean; // 防止递归激活 + delay_until_recursion?: boolean;// 延迟到递归时激活 + scan_depth?: number | null; // 扫描深度(null=使用全局设置) + case_sensitive?: boolean | null;// 大小写敏感 + match_whole_words?: boolean | null; // 匹配整词 + use_regex?: boolean | null; // 使用正则表达式 + automation_id?: string; // 自动化ID + role?: string; // 角色(system/user/assistant) + vectorized?: string; // 向量化的内容ID + extensions?: Record; // 扩展数据 +} + +// 世界书 +export interface WorldBook { + id: number; + userId: number; + bookName: string; + isGlobal: boolean; + entries: WorldInfoEntry[]; + linkedChars: string[]; + entryCount: number; + createdAt: string; + updatedAt: string; +} + +// 世界书列表响应 +export interface WorldBookListResponse { + list: WorldBook[]; + total: number; + page: number; + pageSize: number; +} + +// 世界书列表查询参数 +export interface WorldBookListParams { + page: number; + pageSize: number; + bookName?: string; + isGlobal?: boolean; + characterId?: number; +} + +// 创建世界书请求 +export interface CreateWorldBookRequest { + bookName: string; + isGlobal: boolean; + entries: WorldInfoEntry[]; + linkedChars?: string[]; +} + +// 更新世界书请求 +export interface UpdateWorldBookRequest { + bookName: string; + isGlobal: boolean; + entries: WorldInfoEntry[]; + linkedChars?: string[]; +} + +// 创建条目请求 +export interface CreateWorldEntryRequest { + bookId: number; + entry: WorldInfoEntry; +} + +// 更新条目请求 +export interface UpdateWorldEntryRequest { + bookId: number; + entry: WorldInfoEntry; +} + +// 删除条目请求 +export interface DeleteWorldEntryRequest { + bookId: number; + entryId: string; +} + +// 关联角色请求 +export interface LinkCharacterRequest { + bookId: number; + characterIds: number[]; +} + +// 世界书导出数据 +export interface WorldBookExportData { + name: string; + entries: WorldInfoEntry[]; +} + +// 匹配的世界书条目 +export interface MatchedWorldInfoEntry { + content: string; + position: string; + order: number; + source: string; +} + +// 匹配世界书请求 +export interface MatchWorldInfoRequest { + characterId: number; + messages: string[]; + scanDepth?: number; + maxTokens?: number; +} + +// 匹配世界书响应 +export interface MatchWorldInfoResponse { + entries: MatchedWorldInfoEntry[]; + totalTokens: number; +} diff --git a/web-app-vue/src/views/worldbook/WorldBookList.vue b/web-app-vue/src/views/worldbook/WorldBookList.vue new file mode 100644 index 0000000..a440db5 --- /dev/null +++ b/web-app-vue/src/views/worldbook/WorldBookList.vue @@ -0,0 +1,305 @@ + + + + +