🎨 新增世界书模块,将原有的世界书分离

Signed-off-by: Echo <1711788888@qq.com>
This commit is contained in:
2026-02-27 23:15:30 +08:00
parent 689e8af3df
commit 032d0ccdf0
18 changed files with 1880 additions and 8 deletions

View File

@@ -54,7 +54,12 @@ func (a *AIConfigApi) GetAIConfigList(c *gin.Context) {
return
}
commonResponse.OkWithData(resp, c)
commonResponse.OkWithDetailed(commonResponse.PageResult{
List: resp.List,
Total: resp.Total,
Page: 0,
PageSize: 0,
}, "获取成功", c)
}
// UpdateAIConfig

View File

@@ -84,7 +84,12 @@ func (a *CharacterApi) GetCharacterList(c *gin.Context) {
return
}
commonResponse.OkWithData(resp, c)
commonResponse.OkWithDetailed(commonResponse.PageResult{
List: resp.List,
Total: resp.Total,
Page: resp.Page,
PageSize: resp.PageSize,
}, "获取成功", c)
}
// GetCharacterByID

View File

@@ -74,7 +74,12 @@ func (a *ConversationApi) GetConversationList(c *gin.Context) {
return
}
commonResponse.OkWithData(resp, c)
commonResponse.OkWithDetailed(commonResponse.PageResult{
List: resp.List,
Total: resp.Total,
Page: resp.Page,
PageSize: resp.PageSize,
}, "获取成功", c)
}
// GetConversationByID
@@ -199,7 +204,12 @@ func (a *ConversationApi) GetMessageList(c *gin.Context) {
return
}
commonResponse.OkWithData(resp, c)
commonResponse.OkWithDetailed(commonResponse.PageResult{
List: resp.List,
Total: resp.Total,
Page: resp.Page,
PageSize: resp.PageSize,
}, "获取成功", c)
}
// RegenerateMessage

View File

@@ -7,4 +7,5 @@ type ApiGroup struct {
AIConfigApi
PresetApi
UploadApi
WorldbookApi
}

View File

@@ -79,14 +79,12 @@ func (a *PresetApi) GetPresetList(c *gin.Context) {
list = append(list, response.ToPresetResponse(&preset))
}
resp := response.PresetListResponse{
commonResponse.OkWithDetailed(commonResponse.PageResult{
List: list,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}
commonResponse.OkWithData(resp, c)
}, "获取成功", c)
}
// GetPresetByID 根据ID获取预设

View File

@@ -0,0 +1,296 @@
package app
import (
"net/http"
"strconv"
"git.echol.cn/loser/st/server/global"
"git.echol.cn/loser/st/server/model/app/request"
"git.echol.cn/loser/st/server/model/common"
commonResponse "git.echol.cn/loser/st/server/model/common/response"
"git.echol.cn/loser/st/server/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type WorldbookApi struct{}
// CreateWorldbook
// @Tags AppWorldbook
// @Summary 创建世界书
// @Router /app/worldbook [post]
// @Security ApiKeyAuth
func (a *WorldbookApi) CreateWorldbook(c *gin.Context) {
userID := common.GetAppUserID(c)
var req request.CreateWorldbookRequest
if err := c.ShouldBindJSON(&req); err != nil {
commonResponse.FailWithMessage(err.Error(), c)
return
}
resp, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.CreateWorldbook(userID, &req)
if err != nil {
global.GVA_LOG.Error("创建世界书失败", zap.Error(err))
commonResponse.FailWithMessage(err.Error(), c)
return
}
commonResponse.OkWithData(resp, c)
}
// GetWorldbookList
// @Tags AppWorldbook
// @Summary 获取世界书列表
// @Router /app/worldbook [get]
// @Security ApiKeyAuth
func (a *WorldbookApi) GetWorldbookList(c *gin.Context) {
userID := common.GetAppUserID(c)
var req request.GetWorldbookListRequest
req.Page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
req.PageSize, _ = strconv.Atoi(c.DefaultQuery("pageSize", "20"))
req.Keyword = c.Query("keyword")
if req.Page < 1 {
req.Page = 1
}
if req.PageSize < 1 || req.PageSize > 100 {
req.PageSize = 20
}
list, total, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.GetWorldbookList(userID, &req)
if err != nil {
global.GVA_LOG.Error("获取世界书列表失败", zap.Error(err))
commonResponse.FailWithMessage(err.Error(), c)
return
}
commonResponse.OkWithDetailed(commonResponse.PageResult{
List: list,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}, "获取成功", c)
}
// GetWorldbookByID
// @Tags AppWorldbook
// @Summary 获取世界书详情
// @Router /app/worldbook/:id [get]
// @Security ApiKeyAuth
func (a *WorldbookApi) GetWorldbookByID(c *gin.Context) {
userID := common.GetAppUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
commonResponse.FailWithMessage("无效的世界书ID", c)
return
}
resp, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.GetWorldbookByID(userID, uint(id))
if err != nil {
global.GVA_LOG.Error("获取世界书失败", zap.Error(err))
commonResponse.FailWithMessage(err.Error(), c)
return
}
commonResponse.OkWithData(resp, c)
}
// UpdateWorldbook
// @Tags AppWorldbook
// @Summary 更新世界书
// @Router /app/worldbook/:id [put]
// @Security ApiKeyAuth
func (a *WorldbookApi) UpdateWorldbook(c *gin.Context) {
userID := common.GetAppUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
commonResponse.FailWithMessage("无效的世界书ID", c)
return
}
var req request.UpdateWorldbookRequest
if err := c.ShouldBindJSON(&req); err != nil {
commonResponse.FailWithMessage(err.Error(), c)
return
}
if err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.UpdateWorldbook(userID, uint(id), &req); err != nil {
global.GVA_LOG.Error("更新世界书失败", zap.Error(err))
commonResponse.FailWithMessage(err.Error(), c)
return
}
commonResponse.OkWithMessage("更新成功", c)
}
// DeleteWorldbook
// @Tags AppWorldbook
// @Summary 删除世界书
// @Router /app/worldbook/:id [delete]
// @Security ApiKeyAuth
func (a *WorldbookApi) DeleteWorldbook(c *gin.Context) {
userID := common.GetAppUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
commonResponse.FailWithMessage("无效的世界书ID", c)
return
}
if err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.DeleteWorldbook(userID, uint(id)); err != nil {
global.GVA_LOG.Error("删除世界书失败", zap.Error(err))
commonResponse.FailWithMessage(err.Error(), c)
return
}
commonResponse.OkWithMessage("删除成功", c)
}
// ImportWorldbook
// @Tags AppWorldbook
// @Summary 导入世界书JSON
// @Router /app/worldbook/import [post]
// @Security ApiKeyAuth
func (a *WorldbookApi) ImportWorldbook(c *gin.Context) {
userID := common.GetAppUserID(c)
file, header, err := c.Request.FormFile("file")
if err != nil {
commonResponse.FailWithMessage("请上传文件", c)
return
}
defer file.Close()
if header.Size > 5*1024*1024 {
commonResponse.FailWithMessage("文件大小不能超过 5MB", c)
return
}
buf := make([]byte, header.Size)
if _, err := file.Read(buf); err != nil {
commonResponse.FailWithMessage("读取文件失败", c)
return
}
// 去掉扩展名作为名称
filename := header.Filename
for i := len(filename) - 1; i >= 0; i-- {
if filename[i] == '.' {
filename = filename[:i]
break
}
}
resp, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.ImportFromJSON(userID, buf, filename)
if err != nil {
global.GVA_LOG.Error("导入世界书失败", zap.Error(err))
commonResponse.FailWithMessage(err.Error(), c)
return
}
commonResponse.OkWithData(resp, c)
}
// ExportWorldbook
// @Tags AppWorldbook
// @Summary 导出世界书为 JSON
// @Router /app/worldbook/:id/export [get]
// @Security ApiKeyAuth
func (a *WorldbookApi) ExportWorldbook(c *gin.Context) {
userID := common.GetAppUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
commonResponse.FailWithMessage("无效的世界书ID", c)
return
}
data, filename, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.ExportToJSON(userID, uint(id))
if err != nil {
global.GVA_LOG.Error("导出世界书失败", zap.Error(err))
commonResponse.FailWithMessage(err.Error(), c)
return
}
c.Header("Content-Disposition", "attachment; filename="+filename)
c.Data(http.StatusOK, "application/json", data)
}
// CreateEntry
// @Tags AppWorldbook
// @Summary 创建世界书条目
// @Router /app/worldbook/:id/entry [post]
// @Security ApiKeyAuth
func (a *WorldbookApi) CreateEntry(c *gin.Context) {
userID := common.GetAppUserID(c)
worldbookID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
commonResponse.FailWithMessage("无效的世界书ID", c)
return
}
var req request.CreateEntryRequest
if err := c.ShouldBindJSON(&req); err != nil {
commonResponse.FailWithMessage(err.Error(), c)
return
}
resp, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.CreateEntry(userID, uint(worldbookID), &req)
if err != nil {
global.GVA_LOG.Error("创建世界书条目失败", zap.Error(err))
commonResponse.FailWithMessage(err.Error(), c)
return
}
commonResponse.OkWithData(resp, c)
}
// GetEntryList
// @Tags AppWorldbook
// @Summary 获取世界书条目列表
// @Router /app/worldbook/:id/entries [get]
// @Security ApiKeyAuth
func (a *WorldbookApi) GetEntryList(c *gin.Context) {
userID := common.GetAppUserID(c)
worldbookID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
commonResponse.FailWithMessage("无效的世界书ID", c)
return
}
list, total, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.GetEntryList(userID, uint(worldbookID))
if err != nil {
global.GVA_LOG.Error("获取条目列表失败", zap.Error(err))
commonResponse.FailWithMessage(err.Error(), c)
return
}
commonResponse.OkWithDetailed(commonResponse.PageResult{
List: list,
Total: total,
Page: 0,
PageSize: 0,
}, "获取成功", c)
}
// UpdateEntry
// @Tags AppWorldbook
// @Summary 更新世界书条目
// @Router /app/worldbook/:id/entry/:entryId [put]
// @Security ApiKeyAuth
func (a *WorldbookApi) UpdateEntry(c *gin.Context) {
userID := common.GetAppUserID(c)
entryID, err := strconv.ParseUint(c.Param("entryId"), 10, 32)
if err != nil {
commonResponse.FailWithMessage("无效的条目ID", c)
return
}
var req request.UpdateEntryRequest
if err := c.ShouldBindJSON(&req); err != nil {
commonResponse.FailWithMessage(err.Error(), c)
return
}
if err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.UpdateEntry(userID, uint(entryID), &req); err != nil {
global.GVA_LOG.Error("更新条目失败", zap.Error(err))
commonResponse.FailWithMessage(err.Error(), c)
return
}
commonResponse.OkWithMessage("更新成功", c)
}
// DeleteEntry
// @Tags AppWorldbook
// @Summary 删除世界书条目
// @Router /app/worldbook/:id/entry/:entryId [delete]
// @Security ApiKeyAuth
func (a *WorldbookApi) DeleteEntry(c *gin.Context) {
userID := common.GetAppUserID(c)
entryID, err := strconv.ParseUint(c.Param("entryId"), 10, 32)
if err != nil {
commonResponse.FailWithMessage("无效的条目ID", c)
return
}
if err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.DeleteEntry(userID, uint(entryID)); err != nil {
global.GVA_LOG.Error("删除条目失败", zap.Error(err))
commonResponse.FailWithMessage(err.Error(), c)
return
}
commonResponse.OkWithMessage("删除成功", c)
}

View File

@@ -86,6 +86,8 @@ func RegisterTables() {
app.Message{},
app.AIConfig{},
app.AIPreset{},
app.Worldbook{},
app.WorldbookEntry{},
)
if err != nil {
global.GVA_LOG.Error("register table failed", zap.Error(err))

View File

@@ -153,6 +153,7 @@ func Routers() *gin.Engine {
appRouter.InitAIConfigRouter(appGroup) // AI配置路由/app/ai-config/*
appRouter.InitPresetRouter(appGroup) // 预设路由:/app/preset/*
appRouter.InitUploadRouter(appGroup) // 上传路由:/app/upload/*
appRouter.InitWorldbookRouter(appGroup) // 世界书路由:/app/worldbook/*
}
//插件路由安装

View File

@@ -0,0 +1,64 @@
package request
// CreateWorldbookRequest 创建世界书请求
type CreateWorldbookRequest struct {
Name string `json:"name" binding:"required,max=100"`
Description string `json:"description"`
IsPublic bool `json:"isPublic"`
}
// UpdateWorldbookRequest 更新世界书请求
type UpdateWorldbookRequest struct {
Name *string `json:"name" binding:"omitempty,max=100"`
Description *string `json:"description"`
IsPublic *bool `json:"isPublic"`
}
// GetWorldbookListRequest 获取世界书列表请求
type GetWorldbookListRequest struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
Keyword string `json:"keyword"`
}
// CreateEntryRequest 创建世界书条目请求
type CreateEntryRequest struct {
Comment string `json:"comment"`
Content string `json:"content" binding:"required"`
Keys []string `json:"keys"`
SecondaryKeys []string `json:"secondaryKeys"`
Constant bool `json:"constant"`
Enabled *bool `json:"enabled"` // 指针以区分 false 和未传
UseRegex bool `json:"useRegex"`
CaseSensitive bool `json:"caseSensitive"`
MatchWholeWords bool `json:"matchWholeWords"`
Selective bool `json:"selective"`
SelectiveLogic int `json:"selectiveLogic"`
Position int `json:"position"`
Depth int `json:"depth"`
Order int `json:"order"`
Probability int `json:"probability"`
ScanDepth int `json:"scanDepth"`
GroupID string `json:"groupId"`
}
// UpdateEntryRequest 更新世界书条目请求(所有字段可选)
type UpdateEntryRequest struct {
Comment *string `json:"comment"`
Content *string `json:"content"`
Keys []string `json:"keys"`
SecondaryKeys []string `json:"secondaryKeys"`
Constant *bool `json:"constant"`
Enabled *bool `json:"enabled"`
UseRegex *bool `json:"useRegex"`
CaseSensitive *bool `json:"caseSensitive"`
MatchWholeWords *bool `json:"matchWholeWords"`
Selective *bool `json:"selective"`
SelectiveLogic *int `json:"selectiveLogic"`
Position *int `json:"position"`
Depth *int `json:"depth"`
Order *int `json:"order"`
Probability *int `json:"probability"`
ScanDepth *int `json:"scanDepth"`
GroupID *string `json:"groupId"`
}

View File

@@ -0,0 +1,114 @@
package response
import (
"encoding/json"
"time"
"git.echol.cn/loser/st/server/model/app"
)
// WorldbookResponse 世界书响应
type WorldbookResponse struct {
ID uint `json:"id"`
UserID uint `json:"userId"`
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"isPublic"`
EntryCount int `json:"entryCount"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// WorldbookListResponse 世界书列表响应
type WorldbookListResponse struct {
List []WorldbookResponse `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
// EntryResponse 世界书条目响应
type EntryResponse struct {
ID uint `json:"id"`
WorldbookID uint `json:"worldbookId"`
Comment string `json:"comment"`
Content string `json:"content"`
Keys []string `json:"keys"`
SecondaryKeys []string `json:"secondaryKeys"`
Constant bool `json:"constant"`
Enabled bool `json:"enabled"`
UseRegex bool `json:"useRegex"`
CaseSensitive bool `json:"caseSensitive"`
MatchWholeWords bool `json:"matchWholeWords"`
Selective bool `json:"selective"`
SelectiveLogic int `json:"selectiveLogic"`
Position int `json:"position"`
Depth int `json:"depth"`
Order int `json:"order"`
Probability int `json:"probability"`
ScanDepth int `json:"scanDepth"`
GroupID string `json:"groupId"`
Extensions map[string]interface{} `json:"extensions"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// EntryListResponse 条目列表响应
type EntryListResponse struct {
List []EntryResponse `json:"list"`
Total int64 `json:"total"`
}
// ToWorldbookResponse 转换为世界书响应结构
func ToWorldbookResponse(wb *app.Worldbook) WorldbookResponse {
return WorldbookResponse{
ID: wb.ID,
UserID: wb.UserID,
Name: wb.Name,
Description: wb.Description,
IsPublic: wb.IsPublic,
EntryCount: wb.EntryCount,
CreatedAt: wb.CreatedAt,
UpdatedAt: wb.UpdatedAt,
}
}
// ToEntryResponse 转换为条目响应结构
func ToEntryResponse(entry *app.WorldbookEntry) EntryResponse {
var keys []string
if len(entry.Keys) > 0 {
json.Unmarshal(entry.Keys, &keys)
}
var secondaryKeys []string
if len(entry.SecondaryKeys) > 0 {
json.Unmarshal(entry.SecondaryKeys, &secondaryKeys)
}
var extensions map[string]interface{}
if len(entry.Extensions) > 0 {
json.Unmarshal(entry.Extensions, &extensions)
}
return EntryResponse{
ID: entry.ID,
WorldbookID: entry.WorldbookID,
Comment: entry.Comment,
Content: entry.Content,
Keys: keys,
SecondaryKeys: secondaryKeys,
Constant: entry.Constant,
Enabled: entry.Enabled,
UseRegex: entry.UseRegex,
CaseSensitive: entry.CaseSensitive,
MatchWholeWords: entry.MatchWholeWords,
Selective: entry.Selective,
SelectiveLogic: entry.SelectiveLogic,
Position: entry.Position,
Depth: entry.Depth,
Order: entry.Order,
Probability: entry.Probability,
ScanDepth: entry.ScanDepth,
GroupID: entry.GroupID,
Extensions: extensions,
CreatedAt: entry.CreatedAt,
UpdatedAt: entry.UpdatedAt,
}
}

View File

@@ -0,0 +1,68 @@
package app
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// Worldbook 世界书主表
type Worldbook struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
UserID uint `gorm:"index;not null" json:"userId"`
Name string `gorm:"type:varchar(100);not null" json:"name"`
Description string `gorm:"type:text" json:"description"`
IsPublic bool `gorm:"default:false" json:"isPublic"`
EntryCount int `gorm:"default:0" json:"entryCount"`
}
func (Worldbook) TableName() string {
return "worldbooks"
}
// WorldbookEntry 世界书条目表(完全兼容 SillyTavern 格式)
type WorldbookEntry struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
WorldbookID uint `gorm:"index;not null" json:"worldbookId"`
Comment string `gorm:"type:varchar(200)" json:"comment"` // 条目标题/备注
Content string `gorm:"type:text;not null" json:"content"` // 注入内容
// 关键词(存为 JSONB []string
Keys datatypes.JSON `gorm:"type:jsonb" json:"keys"`
SecondaryKeys datatypes.JSON `gorm:"type:jsonb" json:"secondaryKeys"`
// 触发设置
Constant bool `gorm:"default:false" json:"constant"` // 常驻注入,无需关键词触发
Enabled bool `gorm:"default:true" json:"enabled"` // 是否启用
UseRegex bool `gorm:"default:false" json:"useRegex"` // 关键词用正则表达式
CaseSensitive bool `gorm:"default:false" json:"caseSensitive"` // 区分大小写
MatchWholeWords bool `gorm:"default:false" json:"matchWholeWords"` // 全词匹配
Selective bool `gorm:"default:false" json:"selective"` // 是否需要次要关键词
SelectiveLogic int `gorm:"default:0" json:"selectiveLogic"` // 0=AND, 1=NOT
// 注入位置与优先级
Position int `gorm:"default:1" json:"position"` // 0=系统提示词前, 1=系统提示词后, 4=指定深度
Depth int `gorm:"default:4" json:"depth"` // position=4 时生效
Order int `gorm:"default:100" json:"order"` // 同位置时的排序
// 概率与触发控制SillyTavern 兼容字段)
Probability int `gorm:"default:100" json:"probability"` // 触发概率 0-100
ScanDepth int `gorm:"default:2" json:"scanDepth"` // 扫描最近 N 条消息0=全部)
GroupID string `gorm:"type:varchar(100)" json:"groupId"` // 条目分组
// 扩展字段
Extensions datatypes.JSON `gorm:"type:jsonb" json:"extensions"`
}
func (WorldbookEntry) TableName() string {
return "worldbook_entries"
}

View File

@@ -7,4 +7,5 @@ type RouterGroup struct {
AIConfigRouter
PresetRouter
UploadRouter
WorldbookRouter
}

View File

@@ -0,0 +1,29 @@
package app
import (
v1 "git.echol.cn/loser/st/server/api/v1"
"git.echol.cn/loser/st/server/middleware"
"github.com/gin-gonic/gin"
)
type WorldbookRouter struct{}
// InitWorldbookRouter 初始化世界书路由
func (r *WorldbookRouter) InitWorldbookRouter(Router *gin.RouterGroup) {
worldbookRouter := Router.Group("worldbook").Use(middleware.AppJWTAuth())
worldbookApi := v1.ApiGroupApp.AppApiGroup.WorldbookApi
{
worldbookRouter.POST("", worldbookApi.CreateWorldbook) // 创建世界书
worldbookRouter.GET("", worldbookApi.GetWorldbookList) // 获取世界书列表
worldbookRouter.POST("import", worldbookApi.ImportWorldbook) // 导入世界书
worldbookRouter.GET(":id", worldbookApi.GetWorldbookByID) // 获取世界书详情
worldbookRouter.PUT(":id", worldbookApi.UpdateWorldbook) // 更新世界书
worldbookRouter.DELETE(":id", worldbookApi.DeleteWorldbook) // 删除世界书
worldbookRouter.GET(":id/export", worldbookApi.ExportWorldbook) // 导出世界书
worldbookRouter.POST(":id/entry", worldbookApi.CreateEntry) // 创建条目
worldbookRouter.GET(":id/entries", worldbookApi.GetEntryList) // 获取条目列表
worldbookRouter.PUT(":id/entry/:entryId", worldbookApi.UpdateEntry) // 更新条目
worldbookRouter.DELETE(":id/entry/:entryId", worldbookApi.DeleteEntry) // 删除条目
}
}

View File

@@ -7,4 +7,5 @@ type AppServiceGroup struct {
AIConfigService
PresetService
UploadService
WorldbookService
}

View File

@@ -0,0 +1,498 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"git.echol.cn/loser/st/server/global"
"git.echol.cn/loser/st/server/model/app"
"git.echol.cn/loser/st/server/model/app/request"
"git.echol.cn/loser/st/server/model/app/response"
"go.uber.org/zap"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type WorldbookService struct{}
// CreateWorldbook 创建世界书
func (s *WorldbookService) CreateWorldbook(userID uint, req *request.CreateWorldbookRequest) (*response.WorldbookResponse, error) {
wb := &app.Worldbook{
UserID: userID,
Name: req.Name,
Description: req.Description,
IsPublic: req.IsPublic,
}
if err := global.GVA_DB.Create(wb).Error; err != nil {
global.GVA_LOG.Error("创建世界书失败", zap.Error(err))
return nil, err
}
resp := response.ToWorldbookResponse(wb)
return &resp, nil
}
// GetWorldbookList 获取世界书列表(自己的 + 公开的)
func (s *WorldbookService) GetWorldbookList(userID uint, req *request.GetWorldbookListRequest) ([]response.WorldbookResponse, int64, error) {
var worldbooks []app.Worldbook
var total int64
db := global.GVA_DB.Model(&app.Worldbook{}).Where("user_id = ? OR is_public = ?", userID, true)
if req.Keyword != "" {
db = db.Where("name LIKE ? OR description LIKE ?", "%"+req.Keyword+"%", "%"+req.Keyword+"%")
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (req.Page - 1) * req.PageSize
if err := db.Order("updated_at DESC").Offset(offset).Limit(req.PageSize).Find(&worldbooks).Error; err != nil {
global.GVA_LOG.Error("获取世界书列表失败", zap.Error(err))
return nil, 0, err
}
var list []response.WorldbookResponse
for i := range worldbooks {
list = append(list, response.ToWorldbookResponse(&worldbooks[i]))
}
return list, total, nil
}
// GetWorldbookByID 获取世界书详情
func (s *WorldbookService) GetWorldbookByID(userID uint, id uint) (*response.WorldbookResponse, error) {
var wb app.Worldbook
if err := global.GVA_DB.Where("id = ? AND (user_id = ? OR is_public = ?)", id, userID, true).
First(&wb).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("世界书不存在或无权访问")
}
return nil, err
}
resp := response.ToWorldbookResponse(&wb)
return &resp, nil
}
// UpdateWorldbook 更新世界书
func (s *WorldbookService) UpdateWorldbook(userID uint, id uint, req *request.UpdateWorldbookRequest) error {
var wb app.Worldbook
if err := global.GVA_DB.Where("id = ? AND user_id = ?", id, userID).First(&wb).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("世界书不存在或无权修改")
}
return err
}
updates := make(map[string]interface{})
if req.Name != nil {
updates["name"] = *req.Name
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.IsPublic != nil {
updates["is_public"] = *req.IsPublic
}
return global.GVA_DB.Model(&wb).Updates(updates).Error
}
// DeleteWorldbook 删除世界书(级联删除条目)
func (s *WorldbookService) DeleteWorldbook(userID uint, id uint) error {
return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
// 删除所有条目
if err := tx.Where("worldbook_id = ?", id).Delete(&app.WorldbookEntry{}).Error; err != nil {
return err
}
// 删除世界书
result := tx.Where("id = ? AND user_id = ?", id, userID).Delete(&app.Worldbook{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("世界书不存在或无权删除")
}
return nil
})
}
// CreateEntry 创建世界书条目
func (s *WorldbookService) CreateEntry(userID uint, worldbookID uint, req *request.CreateEntryRequest) (*response.EntryResponse, error) {
// 验证世界书归属
var wb app.Worldbook
if err := global.GVA_DB.Where("id = ? AND user_id = ?", worldbookID, userID).First(&wb).Error; err != nil {
return nil, errors.New("世界书不存在或无权操作")
}
keysJSON, _ := json.Marshal(req.Keys)
secKeysJSON, _ := json.Marshal(req.SecondaryKeys)
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
probability := req.Probability
if probability == 0 {
probability = 100
}
order := req.Order
if order == 0 {
order = 100
}
scanDepth := req.ScanDepth
if scanDepth == 0 {
scanDepth = 2
}
entry := &app.WorldbookEntry{
WorldbookID: worldbookID,
Comment: req.Comment,
Content: req.Content,
Keys: datatypes.JSON(keysJSON),
SecondaryKeys: datatypes.JSON(secKeysJSON),
Constant: req.Constant,
Enabled: enabled,
UseRegex: req.UseRegex,
CaseSensitive: req.CaseSensitive,
MatchWholeWords: req.MatchWholeWords,
Selective: req.Selective,
SelectiveLogic: req.SelectiveLogic,
Position: req.Position,
Depth: req.Depth,
Order: order,
Probability: probability,
ScanDepth: scanDepth,
GroupID: req.GroupID,
}
if err := global.GVA_DB.Create(entry).Error; err != nil {
global.GVA_LOG.Error("创建世界书条目失败", zap.Error(err))
return nil, err
}
// 更新世界书条目计数
global.GVA_DB.Model(&wb).Update("entry_count", gorm.Expr("entry_count + ?", 1))
resp := response.ToEntryResponse(entry)
return &resp, nil
}
// GetEntryList 获取世界书条目列表
func (s *WorldbookService) GetEntryList(userID uint, worldbookID uint) ([]response.EntryResponse, int64, error) {
// 验证访问权限
var wb app.Worldbook
if err := global.GVA_DB.Where("id = ? AND (user_id = ? OR is_public = ?)", worldbookID, userID, true).
First(&wb).Error; err != nil {
return nil, 0, errors.New("世界书不存在或无权访问")
}
var entries []app.WorldbookEntry
var total int64
db := global.GVA_DB.Model(&app.WorldbookEntry{}).Where("worldbook_id = ?", worldbookID)
db.Count(&total)
if err := db.Order("`order` ASC, created_at ASC").Find(&entries).Error; err != nil {
return nil, 0, err
}
var list []response.EntryResponse
for i := range entries {
list = append(list, response.ToEntryResponse(&entries[i]))
}
return list, total, nil
}
// UpdateEntry 更新世界书条目
func (s *WorldbookService) UpdateEntry(userID uint, entryID uint, req *request.UpdateEntryRequest) error {
// 查找条目并验证归属
var entry app.WorldbookEntry
if err := global.GVA_DB.First(&entry, entryID).Error; err != nil {
return errors.New("条目不存在")
}
var wb app.Worldbook
if err := global.GVA_DB.Where("id = ? AND user_id = ?", entry.WorldbookID, userID).First(&wb).Error; err != nil {
return errors.New("无权修改此条目")
}
updates := make(map[string]interface{})
if req.Comment != nil {
updates["comment"] = *req.Comment
}
if req.Content != nil {
updates["content"] = *req.Content
}
if req.Keys != nil {
keysJSON, _ := json.Marshal(req.Keys)
updates["keys"] = datatypes.JSON(keysJSON)
}
if req.SecondaryKeys != nil {
secKeysJSON, _ := json.Marshal(req.SecondaryKeys)
updates["secondary_keys"] = datatypes.JSON(secKeysJSON)
}
if req.Constant != nil {
updates["constant"] = *req.Constant
}
if req.Enabled != nil {
updates["enabled"] = *req.Enabled
}
if req.UseRegex != nil {
updates["use_regex"] = *req.UseRegex
}
if req.CaseSensitive != nil {
updates["case_sensitive"] = *req.CaseSensitive
}
if req.MatchWholeWords != nil {
updates["match_whole_words"] = *req.MatchWholeWords
}
if req.Selective != nil {
updates["selective"] = *req.Selective
}
if req.SelectiveLogic != nil {
updates["selective_logic"] = *req.SelectiveLogic
}
if req.Position != nil {
updates["position"] = *req.Position
}
if req.Depth != nil {
updates["depth"] = *req.Depth
}
if req.Order != nil {
updates["order"] = *req.Order
}
if req.Probability != nil {
updates["probability"] = *req.Probability
}
if req.ScanDepth != nil {
updates["scan_depth"] = *req.ScanDepth
}
if req.GroupID != nil {
updates["group_id"] = *req.GroupID
}
return global.GVA_DB.Model(&entry).Updates(updates).Error
}
// DeleteEntry 删除世界书条目
func (s *WorldbookService) DeleteEntry(userID uint, entryID uint) error {
var entry app.WorldbookEntry
if err := global.GVA_DB.First(&entry, entryID).Error; err != nil {
return errors.New("条目不存在")
}
var wb app.Worldbook
if err := global.GVA_DB.Where("id = ? AND user_id = ?", entry.WorldbookID, userID).First(&wb).Error; err != nil {
return errors.New("无权删除此条目")
}
if err := global.GVA_DB.Delete(&entry).Error; err != nil {
return err
}
// 更新条目计数
global.GVA_DB.Model(&wb).Update("entry_count", gorm.Expr("GREATEST(entry_count - 1, 0)"))
return nil
}
// ImportFromJSON 从 JSON 文件导入世界书(兼容 SillyTavern 格式)
func (s *WorldbookService) ImportFromJSON(userID uint, jsonData []byte, filename string) (*response.WorldbookResponse, error) {
// 尝试解析 SillyTavern 世界书格式
var stFormat map[string]interface{}
if err := json.Unmarshal(jsonData, &stFormat); err != nil {
return nil, fmt.Errorf("JSON 格式错误: %v", err)
}
// 提取世界书名称
name := filename
if n, ok := stFormat["name"].(string); ok && n != "" {
name = n
}
description := ""
if d, ok := stFormat["description"].(string); ok {
description = d
}
wb := &app.Worldbook{
UserID: userID,
Name: name,
Description: description,
}
if err := global.GVA_DB.Create(wb).Error; err != nil {
return nil, err
}
// 解析条目SillyTavern entries 格式map[string]entry 或 []entry
var entryCount int
if entriesRaw, ok := stFormat["entries"]; ok {
switch entries := entriesRaw.(type) {
case map[string]interface{}:
// SillyTavern 格式:键值对
for _, v := range entries {
if entryMap, ok := v.(map[string]interface{}); ok {
s.importEntry(wb.ID, entryMap)
entryCount++
}
}
case []interface{}:
// 数组格式
for _, v := range entries {
if entryMap, ok := v.(map[string]interface{}); ok {
s.importEntry(wb.ID, entryMap)
entryCount++
}
}
}
}
// 更新条目计数
global.GVA_DB.Model(wb).Update("entry_count", entryCount)
resp := response.ToWorldbookResponse(wb)
resp.EntryCount = entryCount
return &resp, nil
}
// importEntry 辅助方法:从 SillyTavern 格式导入单条条目
func (s *WorldbookService) importEntry(worldbookID uint, entryMap map[string]interface{}) {
content := ""
if c, ok := entryMap["content"].(string); ok {
content = c
}
if content == "" {
return
}
comment := ""
if c, ok := entryMap["comment"].(string); ok {
comment = c
}
// 解析 keysSillyTavern 存为 []string 或 []interface{}
var keys []string
if k, ok := entryMap["key"].([]interface{}); ok {
for _, kk := range k {
if ks, ok := kk.(string); ok {
keys = append(keys, ks)
}
}
} else if k, ok := entryMap["keys"].([]interface{}); ok {
for _, kk := range k {
if ks, ok := kk.(string); ok {
keys = append(keys, ks)
}
}
}
keysJSON, _ := json.Marshal(keys)
var secKeys []string
if k, ok := entryMap["secondary_key"].([]interface{}); ok {
for _, kk := range k {
if ks, ok := kk.(string); ok {
secKeys = append(secKeys, ks)
}
}
} else if k, ok := entryMap["secondaryKeys"].([]interface{}); ok {
for _, kk := range k {
if ks, ok := kk.(string); ok {
secKeys = append(secKeys, ks)
}
}
}
secKeysJSON, _ := json.Marshal(secKeys)
constant := false
if c, ok := entryMap["constant"].(bool); ok {
constant = c
}
enabled := true
if e, ok := entryMap["enabled"].(bool); ok {
enabled = e
} else if d, ok := entryMap["disable"].(bool); ok {
enabled = !d
}
useRegex := false
if r, ok := entryMap["use_regex"].(bool); ok {
useRegex = r
}
position := 1
if p, ok := entryMap["position"].(float64); ok {
position = int(p)
}
order := 100
if o, ok := entryMap["insertion_order"].(float64); ok {
order = int(o)
} else if o, ok := entryMap["order"].(float64); ok {
order = int(o)
}
probability := 100
if p, ok := entryMap["probability"].(float64); ok {
probability = int(p)
}
entry := &app.WorldbookEntry{
WorldbookID: worldbookID,
Comment: comment,
Content: content,
Keys: datatypes.JSON(keysJSON),
SecondaryKeys: datatypes.JSON(secKeysJSON),
Constant: constant,
Enabled: enabled,
UseRegex: useRegex,
Position: position,
Order: order,
Probability: probability,
ScanDepth: 2,
}
global.GVA_DB.Create(entry)
}
// ExportToJSON 导出世界书为 JSON兼容 SillyTavern 格式)
func (s *WorldbookService) ExportToJSON(userID uint, worldbookID uint) ([]byte, string, error) {
var wb app.Worldbook
if err := global.GVA_DB.Where("id = ? AND (user_id = ? OR is_public = ?)", worldbookID, userID, true).
First(&wb).Error; err != nil {
return nil, "", errors.New("世界书不存在或无权访问")
}
var entries []app.WorldbookEntry
global.GVA_DB.Where("worldbook_id = ?", worldbookID).Order("`order` ASC").Find(&entries)
// 构建 SillyTavern 兼容格式
entriesMap := make(map[string]interface{})
for i, entry := range entries {
var keys []string
json.Unmarshal(entry.Keys, &keys)
var secKeys []string
json.Unmarshal(entry.SecondaryKeys, &secKeys)
entriesMap[fmt.Sprintf("%d", i)] = map[string]interface{}{
"uid": entry.ID,
"key": keys,
"secondary_key": secKeys,
"comment": entry.Comment,
"content": entry.Content,
"constant": entry.Constant,
"enabled": entry.Enabled,
"use_regex": entry.UseRegex,
"case_sensitive": entry.CaseSensitive,
"match_whole_words": entry.MatchWholeWords,
"selective": entry.Selective,
"selectiveLogic": entry.SelectiveLogic,
"position": entry.Position,
"depth": entry.Depth,
"insertion_order": entry.Order,
"probability": entry.Probability,
"scanDepth": entry.ScanDepth,
"group": entry.GroupID,
}
}
exportData := map[string]interface{}{
"name": wb.Name,
"description": wb.Description,
"entries": entriesMap,
}
data, err := json.MarshalIndent(exportData, "", " ")
return data, wb.Name + ".json", err
}

View File

@@ -10,6 +10,7 @@ import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ProfilePage from './pages/ProfilePage'
import CharacterManagePage from './pages/CharacterManagePage'
import PresetManagePage from './pages/PresetManagePage'
import WorldbookManagePage from './pages/WorldbookManagePage'
import AdminPage from './pages/AdminPage'
function App() {
@@ -27,6 +28,7 @@ function App() {
<Route path="/profile" element={<ProfilePage />} />
<Route path="/characters" element={<CharacterManagePage />} />
<Route path="/presets" element={<PresetManagePage />} />
<Route path="/worldbooks" element={<WorldbookManagePage />} />
<Route path="/admin" element={<AdminPage />} />
</Routes>
</BrowserRouter>

View File

@@ -0,0 +1,164 @@
import client from './client';
export interface Worldbook {
id: number;
userId: number;
name: string;
description: string;
isPublic: boolean;
entryCount: number;
createdAt: string;
updatedAt: string;
}
export interface WorldbookEntry {
id: number;
worldbookId: number;
comment: string;
content: string;
keys: string[];
secondaryKeys: string[];
constant: boolean;
enabled: boolean;
useRegex: boolean;
caseSensitive: boolean;
matchWholeWords: boolean;
selective: boolean;
selectiveLogic: number;
position: number;
depth: number;
order: number;
probability: number;
scanDepth: number;
groupId: string;
extensions?: Record<string, any>;
createdAt: string;
updatedAt: string;
}
export interface CreateWorldbookRequest {
name: string;
description?: string;
isPublic?: boolean;
}
export interface UpdateWorldbookRequest {
name?: string;
description?: string;
isPublic?: boolean;
}
export interface CreateEntryRequest {
comment?: string;
content: string;
keys?: string[];
secondaryKeys?: string[];
constant?: boolean;
enabled?: boolean;
useRegex?: boolean;
caseSensitive?: boolean;
matchWholeWords?: boolean;
selective?: boolean;
selectiveLogic?: number;
position?: number;
depth?: number;
order?: number;
probability?: number;
scanDepth?: number;
groupId?: string;
}
export interface UpdateEntryRequest {
comment?: string;
content?: string;
keys?: string[];
secondaryKeys?: string[];
constant?: boolean;
enabled?: boolean;
useRegex?: boolean;
caseSensitive?: boolean;
matchWholeWords?: boolean;
selective?: boolean;
selectiveLogic?: number;
position?: number;
depth?: number;
order?: number;
probability?: number;
scanDepth?: number;
groupId?: string;
}
// 创建世界书
export const createWorldbook = (data: CreateWorldbookRequest) => {
return client.post<Worldbook>('/app/worldbook', data);
};
// 获取世界书列表
export const getWorldbookList = (params: {
page?: number;
pageSize?: number;
keyword?: string;
}) => {
return client.get<{
list: Worldbook[];
total: number;
page: number;
pageSize: number;
}>('/app/worldbook', { params });
};
// 获取世界书详情
export const getWorldbookById = (id: number) => {
return client.get<Worldbook>(`/app/worldbook/${id}`);
};
// 更新世界书
export const updateWorldbook = (id: number, data: UpdateWorldbookRequest) => {
return client.put(`/app/worldbook/${id}`, data);
};
// 删除世界书
export const deleteWorldbook = (id: number) => {
return client.delete(`/app/worldbook/${id}`);
};
// 导入世界书
export const importWorldbook = (file: File) => {
const formData = new FormData();
formData.append('file', file);
return client.post<Worldbook>('/app/worldbook/import', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
};
// 导出世界书
export const exportWorldbook = (id: number) => {
return client.get(`/app/worldbook/${id}/export`, {
responseType: 'blob',
});
};
// 创建条目
export const createEntry = (worldbookId: number, data: CreateEntryRequest) => {
return client.post<WorldbookEntry>(`/app/worldbook/${worldbookId}/entry`, data);
};
// 获取条目列表
export const getEntryList = (worldbookId: number) => {
return client.get<{
list: WorldbookEntry[];
total: number;
}>(`/app/worldbook/${worldbookId}/entries`);
};
// 更新条目
export const updateEntry = (worldbookId: number, entryId: number, data: UpdateEntryRequest) => {
return client.put(`/app/worldbook/${worldbookId}/entry/${entryId}`, data);
};
// 删除条目
export const deleteEntry = (worldbookId: number, entryId: number) => {
return client.delete(`/app/worldbook/${worldbookId}/entry/${entryId}`);
};

View File

@@ -0,0 +1,613 @@
import React, { useState, useEffect } from 'react';
import {
getWorldbookList,
createWorldbook,
updateWorldbook,
deleteWorldbook,
importWorldbook,
exportWorldbook,
getEntryList,
createEntry,
updateEntry,
deleteEntry,
type Worldbook,
type WorldbookEntry,
type CreateWorldbookRequest,
type CreateEntryRequest,
} from '../api/worldbook';
const WorldbookManagePage: React.FC = () => {
const [worldbooks, setWorldbooks] = useState<Worldbook[]>([]);
const [selectedWorldbook, setSelectedWorldbook] = useState<Worldbook | null>(null);
const [entries, setEntries] = useState<WorldbookEntry[]>([]);
const [selectedEntry, setSelectedEntry] = useState<WorldbookEntry | null>(null);
const [loading, setLoading] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEntryModal, setShowEntryModal] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
// 表单状态
const [worldbookForm, setWorldbookForm] = useState<CreateWorldbookRequest>({
name: '',
description: '',
isPublic: false,
});
const [entryForm, setEntryForm] = useState<CreateEntryRequest>({
comment: '',
content: '',
keys: [],
secondaryKeys: [],
constant: false,
enabled: true,
useRegex: false,
caseSensitive: false,
matchWholeWords: false,
selective: false,
selectiveLogic: 0,
position: 1,
depth: 4,
order: 100,
probability: 100,
scanDepth: 2,
groupId: '',
});
const [keyInput, setKeyInput] = useState('');
const [secondaryKeyInput, setSecondaryKeyInput] = useState('');
useEffect(() => {
loadWorldbooks();
}, [searchKeyword]);
useEffect(() => {
if (selectedWorldbook) {
loadEntries(selectedWorldbook.id);
}
}, [selectedWorldbook]);
const loadWorldbooks = async () => {
try {
setLoading(true);
const response = await getWorldbookList({
page: 1,
pageSize: 100,
keyword: searchKeyword,
});
setWorldbooks(response.data.list || []);
} catch (error: any) {
alert('加载世界书列表失败: ' + (error.response?.data?.msg || error.message));
} finally {
setLoading(false);
}
};
const loadEntries = async (worldbookId: number) => {
try {
const response = await getEntryList(worldbookId);
setEntries(response.data.list || []);
} catch (error: any) {
alert('加载条目列表失败: ' + (error.response?.data?.msg || error.message));
}
};
const handleCreateWorldbook = async () => {
if (!worldbookForm.name.trim()) {
alert('请输入世界书名称');
return;
}
try {
await createWorldbook(worldbookForm);
alert('创建成功');
setShowCreateModal(false);
setWorldbookForm({ name: '', description: '', isPublic: false });
loadWorldbooks();
} catch (error: any) {
alert('创建失败: ' + (error.response?.data?.msg || error.message));
}
};
const handleDeleteWorldbook = async (id: number) => {
if (!confirm('确定要删除这个世界书吗?所有条目也会被删除。')) return;
try {
await deleteWorldbook(id);
alert('删除成功');
if (selectedWorldbook?.id === id) {
setSelectedWorldbook(null);
setEntries([]);
}
loadWorldbooks();
} catch (error: any) {
alert('删除失败: ' + (error.response?.data?.msg || error.message));
}
};
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
setLoading(true);
await importWorldbook(file);
alert('导入成功');
loadWorldbooks();
} catch (error: any) {
alert('导入失败: ' + (error.response?.data?.msg || error.message));
} finally {
setLoading(false);
e.target.value = '';
}
};
const handleExport = async (id: number) => {
try {
const response = await exportWorldbook(id);
const blob = new Blob([response.data], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `worldbook_${id}.json`;
a.click();
window.URL.revokeObjectURL(url);
} catch (error: any) {
alert('导出失败: ' + (error.response?.data?.msg || error.message));
}
};
const handleSaveEntry = async () => {
if (!selectedWorldbook) return;
if (!entryForm.content.trim()) {
alert('请输入条目内容');
return;
}
try {
if (selectedEntry) {
await updateEntry(selectedWorldbook.id, selectedEntry.id, entryForm);
alert('更新成功');
} else {
await createEntry(selectedWorldbook.id, entryForm);
alert('创建成功');
}
setShowEntryModal(false);
resetEntryForm();
loadEntries(selectedWorldbook.id);
} catch (error: any) {
alert('保存失败: ' + (error.response?.data?.msg || error.message));
}
};
const handleDeleteEntry = async (entryId: number) => {
if (!selectedWorldbook) return;
if (!confirm('确定要删除这个条目吗?')) return;
try {
await deleteEntry(selectedWorldbook.id, entryId);
alert('删除成功');
loadEntries(selectedWorldbook.id);
} catch (error: any) {
alert('删除失败: ' + (error.response?.data?.msg || error.message));
}
};
const openEntryModal = (entry?: WorldbookEntry) => {
if (entry) {
setSelectedEntry(entry);
setEntryForm({
comment: entry.comment,
content: entry.content,
keys: entry.keys,
secondaryKeys: entry.secondaryKeys,
constant: entry.constant,
enabled: entry.enabled,
useRegex: entry.useRegex,
caseSensitive: entry.caseSensitive,
matchWholeWords: entry.matchWholeWords,
selective: entry.selective,
selectiveLogic: entry.selectiveLogic,
position: entry.position,
depth: entry.depth,
order: entry.order,
probability: entry.probability,
scanDepth: entry.scanDepth,
groupId: entry.groupId,
});
} else {
resetEntryForm();
}
setShowEntryModal(true);
};
const resetEntryForm = () => {
setSelectedEntry(null);
setEntryForm({
comment: '',
content: '',
keys: [],
secondaryKeys: [],
constant: false,
enabled: true,
useRegex: false,
caseSensitive: false,
matchWholeWords: false,
selective: false,
selectiveLogic: 0,
position: 1,
depth: 4,
order: 100,
probability: 100,
scanDepth: 2,
groupId: '',
});
setKeyInput('');
setSecondaryKeyInput('');
};
const addKey = () => {
if (keyInput.trim()) {
setEntryForm({
...entryForm,
keys: [...(entryForm.keys || []), keyInput.trim()],
});
setKeyInput('');
}
};
const removeKey = (index: number) => {
setEntryForm({
...entryForm,
keys: entryForm.keys?.filter((_, i) => i !== index),
});
};
const addSecondaryKey = () => {
if (secondaryKeyInput.trim()) {
setEntryForm({
...entryForm,
secondaryKeys: [...(entryForm.secondaryKeys || []), secondaryKeyInput.trim()],
});
setSecondaryKeyInput('');
}
};
const removeSecondaryKey = (index: number) => {
setEntryForm({
...entryForm,
secondaryKeys: entryForm.secondaryKeys?.filter((_, i) => i !== index),
});
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 p-6">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-white"></h1>
<div className="flex gap-3">
<label className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg cursor-pointer transition-colors">
<input type="file" accept=".json" onChange={handleImport} className="hidden" />
</label>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors"
>
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 世界书列表 */}
<div className="lg:col-span-1 bg-white/10 backdrop-blur-md rounded-xl p-4">
<input
type="text"
placeholder="搜索世界书..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 mb-4"
/>
<div className="space-y-2 max-h-[calc(100vh-250px)] overflow-y-auto">
{worldbooks.map((wb) => (
<div
key={wb.id}
onClick={() => setSelectedWorldbook(wb)}
className={`p-3 rounded-lg cursor-pointer transition-colors ${
selectedWorldbook?.id === wb.id
? 'bg-purple-600 text-white'
: 'bg-white/5 hover:bg-white/10 text-white/80'
}`}
>
<div className="font-medium">{wb.name}</div>
<div className="text-sm opacity-70">{wb.entryCount} </div>
<div className="flex gap-2 mt-2">
<button
onClick={(e) => {
e.stopPropagation();
handleExport(wb.id);
}}
className="text-xs px-2 py-1 bg-blue-500 hover:bg-blue-600 rounded"
>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteWorldbook(wb.id);
}}
className="text-xs px-2 py-1 bg-red-500 hover:bg-red-600 rounded"
>
</button>
</div>
</div>
))}
</div>
</div>
{/* 条目列表 */}
<div className="lg:col-span-2 bg-white/10 backdrop-blur-md rounded-xl p-4">
{selectedWorldbook ? (
<>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-white">{selectedWorldbook.name}</h2>
<button
onClick={() => openEntryModal()}
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
>
</button>
</div>
<div className="space-y-3 max-h-[calc(100vh-250px)] overflow-y-auto">
{entries.map((entry) => (
<div key={entry.id} className="bg-white/5 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="font-medium text-white">
{entry.comment || '未命名条目'}
</div>
<div className="text-sm text-white/60 mt-1">
: {entry.keys.join(', ') || '无'}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => openEntryModal(entry)}
className="text-xs px-3 py-1 bg-blue-500 hover:bg-blue-600 text-white rounded"
>
</button>
<button
onClick={() => handleDeleteEntry(entry.id)}
className="text-xs px-3 py-1 bg-red-500 hover:bg-red-600 text-white rounded"
>
</button>
</div>
</div>
<div className="text-sm text-white/70 line-clamp-2">{entry.content}</div>
<div className="flex gap-2 mt-2 text-xs text-white/50">
{entry.constant && <span className="px-2 py-1 bg-yellow-500/20 rounded"></span>}
{!entry.enabled && <span className="px-2 py-1 bg-red-500/20 rounded"></span>}
{entry.useRegex && <span className="px-2 py-1 bg-blue-500/20 rounded"></span>}
</div>
</div>
))}
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-white/50">
</div>
)}
</div>
</div>
</div>
{/* 创建世界书模态框 */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-slate-800 rounded-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold text-white mb-4"></h2>
<div className="space-y-4">
<input
type="text"
placeholder="世界书名称"
value={worldbookForm.name}
onChange={(e) => setWorldbookForm({ ...worldbookForm, name: e.target.value })}
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
<textarea
placeholder="描述(可选)"
value={worldbookForm.description}
onChange={(e) => setWorldbookForm({ ...worldbookForm, description: e.target.value })}
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white h-24"
/>
<label className="flex items-center text-white">
<input
type="checkbox"
checked={worldbookForm.isPublic}
onChange={(e) => setWorldbookForm({ ...worldbookForm, isPublic: e.target.checked })}
className="mr-2"
/>
</label>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={handleCreateWorldbook}
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg"
>
</button>
<button
onClick={() => setShowCreateModal(false)}
className="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg"
>
</button>
</div>
</div>
</div>
)}
{/* 条目编辑模态框 */}
{showEntryModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-slate-800 rounded-xl p-6 w-full max-w-3xl max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold text-white mb-4">
{selectedEntry ? '编辑条目' : '创建条目'}
</h2>
<div className="space-y-4">
<input
type="text"
placeholder="条目标题/备注"
value={entryForm.comment}
onChange={(e) => setEntryForm({ ...entryForm, comment: e.target.value })}
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
<textarea
placeholder="条目内容 *"
value={entryForm.content}
onChange={(e) => setEntryForm({ ...entryForm, content: e.target.value })}
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white h-32"
/>
{/* 关键词 */}
<div>
<label className="text-white text-sm mb-2 block"></label>
<div className="flex gap-2 mb-2">
<input
type="text"
placeholder="输入关键词后按回车"
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addKey()}
className="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
<button onClick={addKey} className="px-4 py-2 bg-blue-600 rounded-lg text-white">
</button>
</div>
<div className="flex flex-wrap gap-2">
{entryForm.keys?.map((key, index) => (
<span key={index} className="px-3 py-1 bg-blue-500/20 text-white rounded-full text-sm flex items-center gap-2">
{key}
<button onClick={() => removeKey(index)} className="text-red-400 hover:text-red-300">×</button>
</span>
))}
</div>
</div>
{/* 次要关键词 */}
<div>
<label className="text-white text-sm mb-2 block"></label>
<div className="flex gap-2 mb-2">
<input
type="text"
placeholder="输入次要关键词后按回车"
value={secondaryKeyInput}
onChange={(e) => setSecondaryKeyInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addSecondaryKey()}
className="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
<button onClick={addSecondaryKey} className="px-4 py-2 bg-blue-600 rounded-lg text-white">
</button>
</div>
<div className="flex flex-wrap gap-2">
{entryForm.secondaryKeys?.map((key, index) => (
<span key={index} className="px-3 py-1 bg-purple-500/20 text-white rounded-full text-sm flex items-center gap-2">
{key}
<button onClick={() => removeSecondaryKey(index)} className="text-red-400 hover:text-red-300">×</button>
</span>
))}
</div>
</div>
{/* 选项 */}
<div className="grid grid-cols-2 gap-4">
<label className="flex items-center text-white">
<input
type="checkbox"
checked={entryForm.constant}
onChange={(e) => setEntryForm({ ...entryForm, constant: e.target.checked })}
className="mr-2"
/>
</label>
<label className="flex items-center text-white">
<input
type="checkbox"
checked={entryForm.enabled}
onChange={(e) => setEntryForm({ ...entryForm, enabled: e.target.checked })}
className="mr-2"
/>
</label>
<label className="flex items-center text-white">
<input
type="checkbox"
checked={entryForm.useRegex}
onChange={(e) => setEntryForm({ ...entryForm, useRegex: e.target.checked })}
className="mr-2"
/>
使
</label>
<label className="flex items-center text-white">
<input
type="checkbox"
checked={entryForm.caseSensitive}
onChange={(e) => setEntryForm({ ...entryForm, caseSensitive: e.target.checked })}
className="mr-2"
/>
</label>
</div>
{/* 高级选项 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-white text-sm mb-1 block"> (%)</label>
<input
type="number"
min="0"
max="100"
value={entryForm.probability}
onChange={(e) => setEntryForm({ ...entryForm, probability: parseInt(e.target.value) })}
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
</div>
<div>
<label className="text-white text-sm mb-1 block"></label>
<input
type="number"
min="0"
value={entryForm.scanDepth}
onChange={(e) => setEntryForm({ ...entryForm, scanDepth: parseInt(e.target.value) })}
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
</div>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={handleSaveEntry}
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg"
>
</button>
<button
onClick={() => {
setShowEntryModal(false);
resetEntryForm();
}}
className="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg"
>
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default WorldbookManagePage;