✨ 新增世界书模块
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -174,4 +174,5 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
.claude
|
.claude
|
||||||
|
uploads/
|
||||||
@@ -5,6 +5,7 @@ import "git.echol.cn/loser/st/server/service"
|
|||||||
type ApiGroup struct {
|
type ApiGroup struct {
|
||||||
AuthApi
|
AuthApi
|
||||||
CharacterApi
|
CharacterApi
|
||||||
|
WorldInfoApi
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
468
server/api/v1/app/world_info.go
Normal file
468
server/api/v1/app/world_info.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -146,6 +146,7 @@ func Routers() *gin.Engine {
|
|||||||
appGroup := PublicGroup.Group("app") // 统一使用 /app 前缀
|
appGroup := PublicGroup.Group("app") // 统一使用 /app 前缀
|
||||||
appRouter.InitAuthRouter(appGroup) // 认证路由:/app/auth/* 和 /app/user/*
|
appRouter.InitAuthRouter(appGroup) // 认证路由:/app/auth/* 和 /app/user/*
|
||||||
appRouter.InitCharacterRouter(appGroup) // 角色卡路由:/app/character/*
|
appRouter.InitCharacterRouter(appGroup) // 角色卡路由:/app/character/*
|
||||||
|
appRouter.InitWorldInfoRouter(appGroup) // 世界书路由:/app/worldbook/*
|
||||||
}
|
}
|
||||||
|
|
||||||
//插件路由安装
|
//插件路由安装
|
||||||
|
|||||||
@@ -9,18 +9,57 @@ import (
|
|||||||
// AIWorldInfo 世界书(World Info)表
|
// AIWorldInfo 世界书(World Info)表
|
||||||
type AIWorldInfo struct {
|
type AIWorldInfo struct {
|
||||||
global.GVA_MODEL
|
global.GVA_MODEL
|
||||||
UserID uint `json:"userId" gorm:"not null;index;comment:所属用户ID"`
|
UserID uint `json:"userId" gorm:"not null;index;comment:所属用户ID"`
|
||||||
User *AppUser `json:"user" gorm:"foreignKey:UserID"`
|
User *AppUser `json:"user" gorm:"foreignKey:UserID"`
|
||||||
CharacterID *uint `json:"characterId" gorm:"index;comment:关联角色ID"`
|
BookName string `json:"bookName" gorm:"type:varchar(500);not null;index;comment:世界书名称"`
|
||||||
Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"`
|
IsGlobal bool `json:"isGlobal" gorm:"default:false;index;comment:是否全局世界书"`
|
||||||
Name string `json:"name" gorm:"type:varchar(500);not null;comment:世界书名称"`
|
Entries datatypes.JSON `json:"entries" gorm:"type:jsonb;not null;comment:世界书条目列表"`
|
||||||
Keywords pq.StringArray `json:"keywords" gorm:"type:text[];comment:触发关键词"`
|
LinkedChars pq.StringArray `json:"linkedChars" gorm:"type:text[];comment:关联的角色ID列表"`
|
||||||
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:触发条件配置"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (AIWorldInfo) TableName() string {
|
func (AIWorldInfo) TableName() string {
|
||||||
return "ai_world_info"
|
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"
|
||||||
|
}
|
||||||
|
|||||||
71
server/model/app/request/world_info.go
Normal file
71
server/model/app/request/world_info.go
Normal 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"`
|
||||||
|
}
|
||||||
76
server/model/app/response/world_info.go
Normal file
76
server/model/app/response/world_info.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,4 +3,5 @@ package app
|
|||||||
type RouterGroup struct {
|
type RouterGroup struct {
|
||||||
AuthRouter
|
AuthRouter
|
||||||
CharacterRouter
|
CharacterRouter
|
||||||
|
WorldInfoRouter
|
||||||
}
|
}
|
||||||
|
|||||||
40
server/router/app/world_info.go
Normal file
40
server/router/app/world_info.go
Normal 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) // 匹配世界书条目
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,4 +3,5 @@ package app
|
|||||||
type AppServiceGroup struct {
|
type AppServiceGroup struct {
|
||||||
AuthService
|
AuthService
|
||||||
CharacterService
|
CharacterService
|
||||||
|
WorldInfoService
|
||||||
}
|
}
|
||||||
|
|||||||
713
server/service/app/world_info.go
Normal file
713
server/service/app/world_info.go
Normal 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
|
||||||
|
}
|
||||||
158
web-app-vue/src/api/worldInfo.ts
Normal file
158
web-app-vue/src/api/worldInfo.ts
Normal file
@@ -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<ApiResponse<WorldBook>>('/app/worldbook', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新世界书
|
||||||
|
*/
|
||||||
|
export const updateWorldBook = (id: number, data: UpdateWorldBookRequest) => {
|
||||||
|
return request.put<ApiResponse<void>>(`/app/worldbook/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除世界书
|
||||||
|
*/
|
||||||
|
export const deleteWorldBook = (id: number) => {
|
||||||
|
return request.delete<ApiResponse<void>>(`/app/worldbook/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取世界书详情
|
||||||
|
*/
|
||||||
|
export const getWorldBook = (id: number) => {
|
||||||
|
return request.get<ApiResponse<WorldBook>>(`/app/worldbook/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取世界书列表
|
||||||
|
*/
|
||||||
|
export const getWorldBookList = (params: WorldBookListParams) => {
|
||||||
|
return request.get<ApiResponse<WorldBookListResponse>>('/app/worldbook/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制世界书
|
||||||
|
*/
|
||||||
|
export const duplicateWorldBook = (id: number) => {
|
||||||
|
return request.post<ApiResponse<WorldBook>>(`/app/worldbook/${id}/duplicate`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 条目管理 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建世界书条目
|
||||||
|
*/
|
||||||
|
export const createWorldEntry = (data: CreateWorldEntryRequest) => {
|
||||||
|
return request.post<ApiResponse<void>>('/app/worldbook/entry', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新世界书条目
|
||||||
|
*/
|
||||||
|
export const updateWorldEntry = (data: UpdateWorldEntryRequest) => {
|
||||||
|
return request.put<ApiResponse<void>>('/app/worldbook/entry', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除世界书条目
|
||||||
|
*/
|
||||||
|
export const deleteWorldEntry = (data: DeleteWorldEntryRequest) => {
|
||||||
|
return request.delete<ApiResponse<void>>('/app/worldbook/entry', { data })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 关联管理 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联角色到世界书
|
||||||
|
*/
|
||||||
|
export const linkCharactersToWorldBook = (data: LinkCharacterRequest) => {
|
||||||
|
return request.post<ApiResponse<void>>('/app/worldbook/link', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取角色关联的世界书列表
|
||||||
|
*/
|
||||||
|
export const getCharacterWorldBooks = (characterId: number) => {
|
||||||
|
return request.get<ApiResponse<WorldBook[]>>(`/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<ApiResponse<WorldBook>>('/app/worldbook/import', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出世界书(JSON)
|
||||||
|
*/
|
||||||
|
export const exportWorldBook = (id: number) => {
|
||||||
|
return request.get<WorldBookExportData>(`/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<ApiResponse<MatchWorldInfoResponse>>('/app/worldbook/match', data)
|
||||||
|
}
|
||||||
298
web-app-vue/src/stores/worldInfo.ts
Normal file
298
web-app-vue/src/stores/worldInfo.ts
Normal file
@@ -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<WorldBook[]>([])
|
||||||
|
const currentWorldBook = ref<WorldBook | null>(null)
|
||||||
|
const characterWorldBooks = ref<WorldBook[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
// 获取世界书列表
|
||||||
|
const fetchWorldBookList = async (params?: Partial<WorldBookListParams>) => {
|
||||||
|
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<MatchedWorldInfoEntry[]> => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
127
web-app-vue/src/types/worldInfo.d.ts
vendored
Normal file
127
web-app-vue/src/types/worldInfo.d.ts
vendored
Normal file
@@ -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<string, any>; // 扩展数据
|
||||||
|
}
|
||||||
|
|
||||||
|
// 世界书
|
||||||
|
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;
|
||||||
|
}
|
||||||
305
web-app-vue/src/views/worldbook/WorldBookList.vue
Normal file
305
web-app-vue/src/views/worldbook/WorldBookList.vue
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
<template>
|
||||||
|
<div class="world-book-list">
|
||||||
|
<!-- 页面标题 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>世界书管理</h2>
|
||||||
|
<div class="actions">
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
:before-upload="handleImport"
|
||||||
|
accept=".json"
|
||||||
|
>
|
||||||
|
<el-button type="success" :icon="Upload">导入世界书</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="handleCreate">
|
||||||
|
创建世界书
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<el-card class="search-card">
|
||||||
|
<el-form :inline="true" :model="searchForm" @submit.prevent="handleSearch">
|
||||||
|
<el-form-item label="世界书名称">
|
||||||
|
<el-input
|
||||||
|
v-model="searchForm.bookName"
|
||||||
|
placeholder="搜索世界书"
|
||||||
|
clearable
|
||||||
|
@clear="handleSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="类型">
|
||||||
|
<el-select
|
||||||
|
v-model="searchForm.isGlobal"
|
||||||
|
placeholder="全部"
|
||||||
|
clearable
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option label="全局" :value="true" />
|
||||||
|
<el-option label="非全局" :value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 世界书列表 -->
|
||||||
|
<el-card class="list-card">
|
||||||
|
<el-table
|
||||||
|
v-loading="worldInfoStore.loading"
|
||||||
|
:data="worldInfoStore.worldBooks"
|
||||||
|
stripe
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="bookName" label="世界书名称" min-width="200" />
|
||||||
|
<el-table-column label="类型" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isGlobal ? 'success' : 'info'">
|
||||||
|
{{ row.isGlobal ? '全局' : '非全局' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="条目数" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-badge :value="row.entryCount" :max="999">
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
</el-badge>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="关联角色" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.linkedChars?.length || 0 }} 个
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="updatedAt" label="更新时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.updatedAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:icon="Edit"
|
||||||
|
@click="handleEdit(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
:icon="DocumentCopy"
|
||||||
|
@click="handleDuplicate(row)"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="info"
|
||||||
|
size="small"
|
||||||
|
:icon="Download"
|
||||||
|
@click="handleExport(row)"
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:icon="Delete"
|
||||||
|
@click="handleDelete(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="worldInfoStore.currentPage"
|
||||||
|
v-model:page-size="worldInfoStore.pageSize"
|
||||||
|
:total="worldInfoStore.total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="handleSearch"
|
||||||
|
@current-change="handleSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Edit,
|
||||||
|
Delete,
|
||||||
|
Upload,
|
||||||
|
Download,
|
||||||
|
DocumentCopy,
|
||||||
|
Document
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { useWorldInfoStore } from '@/stores/worldInfo'
|
||||||
|
import type { WorldBook } from '@/types/worldInfo'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const worldInfoStore = useWorldInfoStore()
|
||||||
|
|
||||||
|
// 搜索表单
|
||||||
|
const searchForm = ref({
|
||||||
|
bookName: '',
|
||||||
|
isGlobal: undefined as boolean | undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
worldInfoStore.fetchWorldBookList({
|
||||||
|
page: worldInfoStore.currentPage,
|
||||||
|
pageSize: worldInfoStore.pageSize,
|
||||||
|
bookName: searchForm.value.bookName,
|
||||||
|
isGlobal: searchForm.value.isGlobal
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置搜索
|
||||||
|
const handleReset = () => {
|
||||||
|
searchForm.value = {
|
||||||
|
bookName: '',
|
||||||
|
isGlobal: undefined
|
||||||
|
}
|
||||||
|
worldInfoStore.resetPagination()
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建世界书
|
||||||
|
const handleCreate = () => {
|
||||||
|
router.push('/worldbook/create')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑世界书
|
||||||
|
const handleEdit = (row: WorldBook) => {
|
||||||
|
router.push(`/worldbook/edit/${row.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制世界书
|
||||||
|
const handleDuplicate = async (row: WorldBook) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要复制世界书"${row.bookName}"吗?`,
|
||||||
|
'提示',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'info'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await worldInfoStore.duplicateWorldBook(row.id)
|
||||||
|
} catch (error) {
|
||||||
|
// 用户取消操作
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出世界书
|
||||||
|
const handleExport = async (row: WorldBook) => {
|
||||||
|
try {
|
||||||
|
await worldInfoStore.exportWorldBook(row.id, row.bookName)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除世界书
|
||||||
|
const handleDelete = async (row: WorldBook) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除世界书"${row.bookName}"吗?此操作不可撤销!`,
|
||||||
|
'警告',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await worldInfoStore.deleteWorldBook(row.id)
|
||||||
|
} catch (error) {
|
||||||
|
// 用户取消操作
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入世界书
|
||||||
|
const handleImport = async (file: File) => {
|
||||||
|
// 验证文件类型
|
||||||
|
if (!file.name.endsWith('.json')) {
|
||||||
|
ElMessage.error('只支持 JSON 格式文件')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件大小(10MB)
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
ElMessage.error('文件大小不能超过 10MB')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await worldInfoStore.importWorldBook(file)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导入失败:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false // 阻止自动上传
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
handleSearch()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.world-book-list {
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card {
|
||||||
|
.pagination {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user