新增世界书模块

This commit is contained in:
2026-02-11 14:55:41 +08:00
parent 1757b92b5f
commit 2bca8e2788
15 changed files with 2311 additions and 11 deletions

View File

@@ -5,6 +5,7 @@ import "git.echol.cn/loser/st/server/service"
type ApiGroup struct {
AuthApi
CharacterApi
WorldInfoApi
}
var (

View File

@@ -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)
}

View File

@@ -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/*
}
//插件路由安装

View File

@@ -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"
}

View File

@@ -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"`
}

View File

@@ -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,
}
}

View File

@@ -3,4 +3,5 @@ package app
type RouterGroup struct {
AuthRouter
CharacterRouter
WorldInfoRouter
}

View File

@@ -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) // 匹配世界书条目
}
}

View File

@@ -3,4 +3,5 @@ package app
type AppServiceGroup struct {
AuthService
CharacterService
WorldInfoService
}

View File

@@ -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
}