🎨 优化前端菜单栏(修改为抽屉模式)&& 新增ai预设功能 && 优化ai对话前端渲染
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -179,4 +179,5 @@ uploads/
|
|||||||
|
|
||||||
# SillyTavern 核心脚本和扩展文件(从 web-app 一次性复制,不提交到 Git)
|
# SillyTavern 核心脚本和扩展文件(从 web-app 一次性复制,不提交到 Git)
|
||||||
server/data/st-core-scripts/
|
server/data/st-core-scripts/
|
||||||
server/data/extensions/
|
server/data/extensions/
|
||||||
|
.vite
|
||||||
256
server/api/v1/app/ai_preset.go
Normal file
256
server/api/v1/app/ai_preset.go
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.echol.cn/loser/st/server/global"
|
||||||
|
"git.echol.cn/loser/st/server/model/app/request"
|
||||||
|
"git.echol.cn/loser/st/server/model/common/response"
|
||||||
|
"git.echol.cn/loser/st/server/service"
|
||||||
|
"git.echol.cn/loser/st/server/utils"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AIPresetApi struct{}
|
||||||
|
|
||||||
|
var aiPresetService = service.ServiceGroupApp.AppServiceGroup.AIPresetService
|
||||||
|
|
||||||
|
// CreateAIPreset 创建预设
|
||||||
|
// @Tags AIPreset
|
||||||
|
// @Summary 创建AI预设
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @accept application/json
|
||||||
|
// @Produce application/json
|
||||||
|
// @Param data body request.CreateAIPresetRequest true "预设信息"
|
||||||
|
// @Success 200 {object} response.Response{data=app.AIPreset,msg=string} "创建成功"
|
||||||
|
// @Router /app/preset/create [post]
|
||||||
|
func (a *AIPresetApi) CreateAIPreset(c *gin.Context) {
|
||||||
|
var req request.CreateAIPresetRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.FailWithMessage(err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户ID
|
||||||
|
userID := utils.GetUserID(c)
|
||||||
|
if userID == 0 {
|
||||||
|
response.FailWithMessage("获取用户信息失败", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
preset, err := aiPresetService.CreateAIPreset(userID, &req)
|
||||||
|
if err != nil {
|
||||||
|
global.GVA_LOG.Error("创建预设失败", zap.Error(err))
|
||||||
|
response.FailWithMessage("创建预设失败: "+err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OkWithData(preset, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAIPreset 更新预设
|
||||||
|
// @Tags AIPreset
|
||||||
|
// @Summary 更新AI预设
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @accept application/json
|
||||||
|
// @Produce application/json
|
||||||
|
// @Param id path int true "预设ID"
|
||||||
|
// @Param data body request.UpdateAIPresetRequest true "预设信息"
|
||||||
|
// @Success 200 {object} response.Response{msg=string} "更新成功"
|
||||||
|
// @Router /app/preset/update/{id} [put]
|
||||||
|
func (a *AIPresetApi) UpdateAIPreset(c *gin.Context) {
|
||||||
|
presetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
response.FailWithMessage("无效的预设ID", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req request.UpdateAIPresetRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.FailWithMessage(err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户ID
|
||||||
|
userID := utils.GetUserID(c)
|
||||||
|
if userID == 0 {
|
||||||
|
response.FailWithMessage("获取用户信息失败", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := aiPresetService.UpdateAIPreset(userID, uint(presetID), &req); err != nil {
|
||||||
|
global.GVA_LOG.Error("更新预设失败", zap.Error(err))
|
||||||
|
response.FailWithMessage("更新预设失败: "+err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OkWithMessage("更新成功", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAIPreset 删除预设
|
||||||
|
// @Tags AIPreset
|
||||||
|
// @Summary 删除AI预设
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @accept application/json
|
||||||
|
// @Produce application/json
|
||||||
|
// @Param id path int true "预设ID"
|
||||||
|
// @Success 200 {object} response.Response{msg=string} "删除成功"
|
||||||
|
// @Router /app/preset/delete/{id} [delete]
|
||||||
|
func (a *AIPresetApi) DeleteAIPreset(c *gin.Context) {
|
||||||
|
presetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
response.FailWithMessage("无效的预设ID", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户ID
|
||||||
|
userID := utils.GetUserID(c)
|
||||||
|
if userID == 0 {
|
||||||
|
response.FailWithMessage("获取用户信息失败", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := aiPresetService.DeleteAIPreset(userID, uint(presetID)); err != nil {
|
||||||
|
global.GVA_LOG.Error("删除预设失败", zap.Error(err))
|
||||||
|
response.FailWithMessage("删除预设失败: "+err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OkWithMessage("删除成功", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAIPreset 获取预设详情
|
||||||
|
// @Tags AIPreset
|
||||||
|
// @Summary 获取AI预设详情
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @accept application/json
|
||||||
|
// @Produce application/json
|
||||||
|
// @Param id path int true "预设ID"
|
||||||
|
// @Success 200 {object} response.Response{data=response.AIPresetResponse,msg=string} "获取成功"
|
||||||
|
// @Router /app/preset/get/{id} [get]
|
||||||
|
func (a *AIPresetApi) GetAIPreset(c *gin.Context) {
|
||||||
|
presetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
response.FailWithMessage("无效的预设ID", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户ID
|
||||||
|
userID := utils.GetUserID(c)
|
||||||
|
if userID == 0 {
|
||||||
|
response.FailWithMessage("获取用户信息失败", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
preset, err := aiPresetService.GetAIPreset(userID, uint(presetID))
|
||||||
|
if err != nil {
|
||||||
|
global.GVA_LOG.Error("获取预设失败", zap.Error(err))
|
||||||
|
response.FailWithMessage("获取预设失败: "+err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OkWithData(preset, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAIPresetList 获取预设列表
|
||||||
|
// @Tags AIPreset
|
||||||
|
// @Summary 获取AI预设列表
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @accept application/json
|
||||||
|
// @Produce application/json
|
||||||
|
// @Param data body request.AIPresetSearch true "搜索条件"
|
||||||
|
// @Success 200 {object} response.Response{data=response.PageResult,msg=string} "获取成功"
|
||||||
|
// @Router /app/preset/list [post]
|
||||||
|
func (a *AIPresetApi) GetAIPresetList(c *gin.Context) {
|
||||||
|
var req request.AIPresetSearch
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.FailWithMessage(err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户ID
|
||||||
|
userID := utils.GetUserID(c)
|
||||||
|
if userID == 0 {
|
||||||
|
response.FailWithMessage("获取用户信息失败", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
list, total, err := aiPresetService.GetAIPresetList(userID, &req)
|
||||||
|
if err != nil {
|
||||||
|
global.GVA_LOG.Error("获取预设列表失败", zap.Error(err))
|
||||||
|
response.FailWithMessage("获取预设列表失败: "+err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OkWithDetailed(response.PageResult{
|
||||||
|
List: list,
|
||||||
|
Total: total,
|
||||||
|
Page: req.Page,
|
||||||
|
PageSize: req.PageSize,
|
||||||
|
}, "获取成功", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuplicateAIPreset 复制预设
|
||||||
|
// @Tags AIPreset
|
||||||
|
// @Summary 复制AI预设
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @accept application/json
|
||||||
|
// @Produce application/json
|
||||||
|
// @Param id path int true "预设ID"
|
||||||
|
// @Success 200 {object} response.Response{data=app.AIPreset,msg=string} "复制成功"
|
||||||
|
// @Router /app/preset/duplicate/{id} [post]
|
||||||
|
func (a *AIPresetApi) DuplicateAIPreset(c *gin.Context) {
|
||||||
|
presetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
response.FailWithMessage("无效的预设ID", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户ID
|
||||||
|
userID := utils.GetUserID(c)
|
||||||
|
if userID == 0 {
|
||||||
|
response.FailWithMessage("获取用户信息失败", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
preset, err := aiPresetService.DuplicateAIPreset(userID, uint(presetID))
|
||||||
|
if err != nil {
|
||||||
|
global.GVA_LOG.Error("复制预设失败", zap.Error(err))
|
||||||
|
response.FailWithMessage("复制预设失败: "+err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OkWithData(preset, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefaultPreset 设置默认预设
|
||||||
|
// @Tags AIPreset
|
||||||
|
// @Summary 设置默认AI预设
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @accept application/json
|
||||||
|
// @Produce application/json
|
||||||
|
// @Param id path int true "预设ID"
|
||||||
|
// @Success 200 {object} response.Response{msg=string} "设置成功"
|
||||||
|
// @Router /app/preset/setDefault/{id} [post]
|
||||||
|
func (a *AIPresetApi) SetDefaultPreset(c *gin.Context) {
|
||||||
|
presetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
response.FailWithMessage("无效的预设ID", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户ID
|
||||||
|
userID := utils.GetUserID(c)
|
||||||
|
if userID == 0 {
|
||||||
|
response.FailWithMessage("获取用户信息失败", c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := aiPresetService.SetDefaultPreset(userID, uint(presetID)); err != nil {
|
||||||
|
global.GVA_LOG.Error("设置默认预设失败", zap.Error(err))
|
||||||
|
response.FailWithMessage("设置默认预设失败: "+err.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OkWithMessage("设置成功", c)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ type ApiGroup struct {
|
|||||||
RegexScriptApi
|
RegexScriptApi
|
||||||
ProviderApi
|
ProviderApi
|
||||||
ChatApi
|
ChatApi
|
||||||
|
AIPresetApi
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ func RegisterTables() {
|
|||||||
app.AIUsageStat{},
|
app.AIUsageStat{},
|
||||||
app.AIRegexScript{},
|
app.AIRegexScript{},
|
||||||
app.AICharacterRegexScript{},
|
app.AICharacterRegexScript{},
|
||||||
|
app.AICharacterWorldInfo{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
global.GVA_LOG.Error("register table failed", zap.Error(err))
|
global.GVA_LOG.Error("register table failed", zap.Error(err))
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ func Routers() *gin.Engine {
|
|||||||
appRouter.InitRegexScriptRouter(appGroup) // 正则脚本路由:/app/regex/*
|
appRouter.InitRegexScriptRouter(appGroup) // 正则脚本路由:/app/regex/*
|
||||||
appRouter.InitProviderRouter(appGroup) // AI提供商路由:/app/provider/*
|
appRouter.InitProviderRouter(appGroup) // AI提供商路由:/app/provider/*
|
||||||
appRouter.InitChatRouter(appGroup) // 对话路由:/app/chat/*
|
appRouter.InitChatRouter(appGroup) // 对话路由:/app/chat/*
|
||||||
|
appRouter.InitAIPresetRouter(appGroup) // AI预设路由:/app/preset/*
|
||||||
}
|
}
|
||||||
|
|
||||||
//插件路由安装
|
//插件路由安装
|
||||||
|
|||||||
24
server/model/app/request/ai_preset.go
Normal file
24
server/model/app/request/ai_preset.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package request
|
||||||
|
|
||||||
|
import "git.echol.cn/loser/st/server/model/common/request"
|
||||||
|
|
||||||
|
// CreateAIPresetRequest 创建预设请求
|
||||||
|
type CreateAIPresetRequest struct {
|
||||||
|
Name string `json:"name" binding:"required,max=200"`
|
||||||
|
Type string `json:"type" binding:"required,max=100"`
|
||||||
|
Content map[string]interface{} `json:"content" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAIPresetRequest 更新预设请求
|
||||||
|
type UpdateAIPresetRequest struct {
|
||||||
|
Name string `json:"name" binding:"required,max=200"`
|
||||||
|
Type string `json:"type" binding:"required,max=100"`
|
||||||
|
Content map[string]interface{} `json:"content" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIPresetSearch 预设搜索请求
|
||||||
|
type AIPresetSearch struct {
|
||||||
|
request.PageInfo
|
||||||
|
Name string `json:"name" form:"name"`
|
||||||
|
Type string `json:"type" form:"type"`
|
||||||
|
}
|
||||||
42
server/model/app/response/ai_preset.go
Normal file
42
server/model/app/response/ai_preset.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package response
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"git.echol.cn/loser/st/server/model/app"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AIPresetResponse 预设响应
|
||||||
|
type AIPresetResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content map[string]interface{} `json:"content"`
|
||||||
|
IsSystem bool `json:"isSystem"`
|
||||||
|
IsDefault bool `json:"isDefault"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToAIPresetResponse 转换为响应格式
|
||||||
|
func ToAIPresetResponse(preset *app.AIPreset) *AIPresetResponse {
|
||||||
|
content := make(map[string]interface{})
|
||||||
|
if preset.Config != nil && len(preset.Config) > 0 {
|
||||||
|
// 将 datatypes.JSON 转换为 map
|
||||||
|
if err := json.Unmarshal(preset.Config, &content); err != nil {
|
||||||
|
// 如果解析失败,返回空 map
|
||||||
|
content = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AIPresetResponse{
|
||||||
|
ID: preset.ID,
|
||||||
|
Name: preset.Name,
|
||||||
|
Type: preset.PresetType,
|
||||||
|
Content: content,
|
||||||
|
IsSystem: preset.IsSystem,
|
||||||
|
IsDefault: preset.IsDefault,
|
||||||
|
CreatedAt: preset.CreatedAt,
|
||||||
|
UpdatedAt: preset.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
23
server/router/app/ai_preset.go
Normal file
23
server/router/app/ai_preset.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.echol.cn/loser/st/server/api/v1"
|
||||||
|
"git.echol.cn/loser/st/server/middleware"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AIPresetRouter struct{}
|
||||||
|
|
||||||
|
func (r *AIPresetRouter) InitAIPresetRouter(Router *gin.RouterGroup) {
|
||||||
|
presetRouter := Router.Group("preset").Use(middleware.JWTAuth())
|
||||||
|
presetApi := v1.ApiGroupApp.AppApiGroup.AIPresetApi
|
||||||
|
{
|
||||||
|
presetRouter.POST("create", presetApi.CreateAIPreset) // 创建预设
|
||||||
|
presetRouter.PUT("update/:id", presetApi.UpdateAIPreset) // 更新预设
|
||||||
|
presetRouter.DELETE("delete/:id", presetApi.DeleteAIPreset) // 删除预设
|
||||||
|
presetRouter.GET("get/:id", presetApi.GetAIPreset) // 获取预设详情
|
||||||
|
presetRouter.POST("list", presetApi.GetAIPresetList) // 获取预设列表
|
||||||
|
presetRouter.POST("duplicate/:id", presetApi.DuplicateAIPreset) // 复制预设
|
||||||
|
presetRouter.POST("setDefault/:id", presetApi.SetDefaultPreset) // 设置默认预设
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,4 +7,5 @@ type RouterGroup struct {
|
|||||||
RegexScriptRouter
|
RegexScriptRouter
|
||||||
ProviderRouter
|
ProviderRouter
|
||||||
ChatRouter
|
ChatRouter
|
||||||
|
AIPresetRouter
|
||||||
}
|
}
|
||||||
|
|||||||
198
server/service/app/ai_preset.go
Normal file
198
server/service/app/ai_preset.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"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"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AIPresetService struct{}
|
||||||
|
|
||||||
|
// CreateAIPreset 创建预设
|
||||||
|
func (s *AIPresetService) CreateAIPreset(userID uint, req *request.CreateAIPresetRequest) (*app.AIPreset, error) {
|
||||||
|
// 将 content 转换为 JSON
|
||||||
|
configBytes, err := json.Marshal(req.Content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("配置格式错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
preset := &app.AIPreset{
|
||||||
|
Name: req.Name,
|
||||||
|
UserID: &userID,
|
||||||
|
PresetType: req.Type,
|
||||||
|
Config: datatypes.JSON(configBytes),
|
||||||
|
IsSystem: false,
|
||||||
|
IsDefault: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := global.GVA_DB.Create(preset).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return preset, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAIPreset 更新预设
|
||||||
|
func (s *AIPresetService) UpdateAIPreset(userID uint, presetID uint, req *request.UpdateAIPresetRequest) error {
|
||||||
|
// 检查预设是否存在且属于当前用户
|
||||||
|
var preset app.AIPreset
|
||||||
|
if err := global.GVA_DB.Where("id = ? AND user_id = ?", presetID, userID).First(&preset).Error; err != nil {
|
||||||
|
return errors.New("预设不存在或无权限")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统预设不允许修改
|
||||||
|
if preset.IsSystem {
|
||||||
|
return errors.New("系统预设不允许修改")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 content 转换为 JSON
|
||||||
|
configBytes, err := json.Marshal(req.Content)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("配置格式错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"name": req.Name,
|
||||||
|
"preset_type": req.Type,
|
||||||
|
"config": datatypes.JSON(configBytes),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := global.GVA_DB.Model(&preset).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAIPreset 删除预设
|
||||||
|
func (s *AIPresetService) DeleteAIPreset(userID uint, presetID uint) error {
|
||||||
|
// 检查预设是否存在且属于当前用户
|
||||||
|
var preset app.AIPreset
|
||||||
|
if err := global.GVA_DB.Where("id = ? AND user_id = ?", presetID, userID).First(&preset).Error; err != nil {
|
||||||
|
return errors.New("预设不存在或无权限")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统预设不允许删除
|
||||||
|
if preset.IsSystem {
|
||||||
|
return errors.New("系统预设不允许删除")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := global.GVA_DB.Delete(&preset).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAIPreset 获取预设详情
|
||||||
|
func (s *AIPresetService) GetAIPreset(userID uint, presetID uint) (*response.AIPresetResponse, error) {
|
||||||
|
var preset app.AIPreset
|
||||||
|
// 可以查看自己的预设或系统预设
|
||||||
|
if err := global.GVA_DB.Where("id = ? AND (user_id = ? OR is_system = true)", presetID, userID).First(&preset).Error; err != nil {
|
||||||
|
return nil, errors.New("预设不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.ToAIPresetResponse(&preset), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAIPresetList 获取预设列表
|
||||||
|
func (s *AIPresetService) GetAIPresetList(userID uint, req *request.AIPresetSearch) ([]response.AIPresetResponse, int64, error) {
|
||||||
|
var presets []app.AIPreset
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
db := global.GVA_DB.Model(&app.AIPreset{})
|
||||||
|
|
||||||
|
// 只查询自己的预设和系统预设
|
||||||
|
db = db.Where("user_id = ? OR is_system = true", userID)
|
||||||
|
|
||||||
|
// 搜索条件
|
||||||
|
if req.Name != "" {
|
||||||
|
db = db.Where("name LIKE ?", "%"+req.Name+"%")
|
||||||
|
}
|
||||||
|
if req.Type != "" {
|
||||||
|
db = db.Where("preset_type = ?", req.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取总数
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页查询
|
||||||
|
offset := (req.Page - 1) * req.PageSize
|
||||||
|
if err := db.Order("is_default DESC, updated_at DESC").
|
||||||
|
Offset(offset).
|
||||||
|
Limit(req.PageSize).
|
||||||
|
Find(&presets).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为响应格式
|
||||||
|
var result []response.AIPresetResponse
|
||||||
|
for _, preset := range presets {
|
||||||
|
result = append(result, *response.ToAIPresetResponse(&preset))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuplicateAIPreset 复制预设
|
||||||
|
func (s *AIPresetService) DuplicateAIPreset(userID uint, presetID uint) (*app.AIPreset, error) {
|
||||||
|
// 获取原预设
|
||||||
|
var original app.AIPreset
|
||||||
|
if err := global.GVA_DB.Where("id = ? AND (user_id = ? OR is_system = true)", presetID, userID).First(&original).Error; err != nil {
|
||||||
|
return nil, errors.New("预设不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建副本
|
||||||
|
duplicate := &app.AIPreset{
|
||||||
|
Name: original.Name + " (副本)",
|
||||||
|
UserID: &userID,
|
||||||
|
PresetType: original.PresetType,
|
||||||
|
Config: original.Config,
|
||||||
|
IsSystem: false,
|
||||||
|
IsDefault: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := global.GVA_DB.Create(duplicate).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return duplicate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefaultPreset 设置默认预设
|
||||||
|
func (s *AIPresetService) SetDefaultPreset(userID uint, presetID uint) error {
|
||||||
|
// 检查预设是否存在且属于当前用户
|
||||||
|
var preset app.AIPreset
|
||||||
|
if err := global.GVA_DB.Where("id = ? AND user_id = ?", presetID, userID).First(&preset).Error; err != nil {
|
||||||
|
return errors.New("预设不存在或无权限")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开启事务
|
||||||
|
tx := global.GVA_DB.Begin()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 取消当前用户的所有默认预设
|
||||||
|
if err := tx.Model(&app.AIPreset{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置新的默认预设
|
||||||
|
if err := tx.Model(&preset).Update("is_default", true).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit().Error
|
||||||
|
}
|
||||||
@@ -7,4 +7,5 @@ type AppServiceGroup struct {
|
|||||||
RegexScriptService
|
RegexScriptService
|
||||||
ProviderService
|
ProviderService
|
||||||
ChatService
|
ChatService
|
||||||
|
AIPresetService
|
||||||
}
|
}
|
||||||
|
|||||||
17
web-app-vue/package-lock.json
generated
17
web-app-vue/package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"element-plus": "^2.13.2",
|
"element-plus": "^2.13.2",
|
||||||
"jquery": "^4.0.0",
|
"jquery": "^4.0.0",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
|
"marked": "^17.0.3",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
@@ -1295,7 +1296,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/dompurify": {
|
"node_modules/@types/dompurify": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1770,7 +1771,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
@@ -2314,6 +2315,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "17.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz",
|
||||||
|
"integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"element-plus": "^2.13.2",
|
"element-plus": "^2.13.2",
|
||||||
"jquery": "^4.0.0",
|
"jquery": "^4.0.0",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
|
"marked": "^17.0.3",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
|
|||||||
74
web-app-vue/src/api/preset.ts
Normal file
74
web-app-vue/src/api/preset.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建AI预设
|
||||||
|
*/
|
||||||
|
export const createPreset = (data: any) => {
|
||||||
|
return request({
|
||||||
|
url: '/app/preset/create',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新AI预设
|
||||||
|
*/
|
||||||
|
export const updatePreset = (id: number, data: any) => {
|
||||||
|
return request({
|
||||||
|
url: `/app/preset/update/${id}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除AI预设
|
||||||
|
*/
|
||||||
|
export const deletePreset = (id: number) => {
|
||||||
|
return request({
|
||||||
|
url: `/app/preset/delete/${id}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取AI预设详情
|
||||||
|
*/
|
||||||
|
export const getPreset = (id: number) => {
|
||||||
|
return request({
|
||||||
|
url: `/app/preset/get/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取AI预设列表
|
||||||
|
*/
|
||||||
|
export const getPresetList = (data: any) => {
|
||||||
|
return request({
|
||||||
|
url: '/app/preset/list',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制AI预设
|
||||||
|
*/
|
||||||
|
export const duplicatePreset = (id: number) => {
|
||||||
|
return request({
|
||||||
|
url: `/app/preset/duplicate/${id}`,
|
||||||
|
method: 'post'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置默认预设
|
||||||
|
*/
|
||||||
|
export const setDefaultPreset = (id: number) => {
|
||||||
|
return request({
|
||||||
|
url: `/app/preset/setDefault/${id}`,
|
||||||
|
method: 'post'
|
||||||
|
})
|
||||||
|
}
|
||||||
8
web-app-vue/src/components.d.ts
vendored
8
web-app-vue/src/components.d.ts
vendored
@@ -11,9 +11,12 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
DrawerContentWrapper: typeof import('./components/DrawerContentWrapper.vue')['default']
|
||||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||||
|
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||||
|
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
@@ -59,9 +62,14 @@ declare module 'vue' {
|
|||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
|
GenericPresetForm: typeof import('./components/preset/GenericPresetForm.vue')['default']
|
||||||
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
|
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
|
||||||
|
MessageRenderer: typeof import('./components/MessageRenderer.vue')['default']
|
||||||
|
OpenAIPresetForm: typeof import('./components/preset/OpenAIPresetForm.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
VariableNode: typeof import('./components/VariableNode.vue')['default']
|
||||||
|
VariableViewer: typeof import('./components/VariableViewer.vue')['default']
|
||||||
}
|
}
|
||||||
export interface GlobalDirectives {
|
export interface GlobalDirectives {
|
||||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
|
|||||||
152
web-app-vue/src/components/DrawerContentWrapper.vue
Normal file
152
web-app-vue/src/components/DrawerContentWrapper.vue
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<template>
|
||||||
|
<div class="drawer-content-wrapper">
|
||||||
|
<!-- 面包屑导航 -->
|
||||||
|
<div v-if="breadcrumbs.length > 1" class="breadcrumb-nav">
|
||||||
|
<el-breadcrumb separator="/">
|
||||||
|
<el-breadcrumb-item
|
||||||
|
v-for="(item, index) in breadcrumbs"
|
||||||
|
:key="index"
|
||||||
|
:class="{ clickable: index < breadcrumbs.length - 1 }"
|
||||||
|
@click="index < breadcrumbs.length - 1 && goToLevel(index)"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 当前视图 -->
|
||||||
|
<div class="drawer-view">
|
||||||
|
<component
|
||||||
|
:is="currentView.component"
|
||||||
|
:ref="(el: any) => setComponentRef(el)"
|
||||||
|
v-bind="currentView.props"
|
||||||
|
@navigate="handleNavigate"
|
||||||
|
@back="handleBack"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, shallowRef, provide } from 'vue'
|
||||||
|
|
||||||
|
interface ViewConfig {
|
||||||
|
component: any
|
||||||
|
props?: Record<string, any>
|
||||||
|
title: string
|
||||||
|
componentRef?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
initialComponent: any
|
||||||
|
initialTitle: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 视图栈
|
||||||
|
const viewStack = ref<ViewConfig[]>([
|
||||||
|
{
|
||||||
|
component: props.initialComponent,
|
||||||
|
props: {},
|
||||||
|
title: props.initialTitle,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// 当前视图
|
||||||
|
const currentView = shallowRef(viewStack.value[0])
|
||||||
|
|
||||||
|
// 面包屑
|
||||||
|
const breadcrumbs = ref([{ title: props.initialTitle }])
|
||||||
|
|
||||||
|
// 设置组件引用
|
||||||
|
function setComponentRef(el: any) {
|
||||||
|
if (el && viewStack.value.length > 0) {
|
||||||
|
viewStack.value[viewStack.value.length - 1].componentRef = el
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导航到新视图
|
||||||
|
function handleNavigate(config: { component: any; props?: Record<string, any>; title: string }) {
|
||||||
|
viewStack.value.push(config)
|
||||||
|
currentView.value = config
|
||||||
|
breadcrumbs.value.push({ title: config.title })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回上一级
|
||||||
|
function handleBack() {
|
||||||
|
if (viewStack.value.length > 1) {
|
||||||
|
viewStack.value.pop()
|
||||||
|
breadcrumbs.value.pop()
|
||||||
|
const previousView = viewStack.value[viewStack.value.length - 1]
|
||||||
|
currentView.value = previousView
|
||||||
|
|
||||||
|
// 如果上一级视图有 refresh 方法,调用它
|
||||||
|
setTimeout(() => {
|
||||||
|
if (previousView.componentRef?.refresh) {
|
||||||
|
console.log('调用上一级视图的 refresh 方法')
|
||||||
|
previousView.componentRef.refresh()
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到指定层级
|
||||||
|
function goToLevel(index: number) {
|
||||||
|
if (index < viewStack.value.length - 1) {
|
||||||
|
viewStack.value = viewStack.value.slice(0, index + 1)
|
||||||
|
breadcrumbs.value = breadcrumbs.value.slice(0, index + 1)
|
||||||
|
const targetView = viewStack.value[index]
|
||||||
|
currentView.value = targetView
|
||||||
|
|
||||||
|
// 如果目标视图有 refresh 方法,调用它
|
||||||
|
setTimeout(() => {
|
||||||
|
if (targetView.componentRef?.refresh) {
|
||||||
|
console.log('调用目标视图的 refresh 方法')
|
||||||
|
targetView.componentRef.refresh()
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提供导航方法给子组件
|
||||||
|
provide('drawerNavigate', handleNavigate)
|
||||||
|
provide('drawerBack', handleBack)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.drawer-content-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.breadcrumb-nav {
|
||||||
|
padding: 0 0 16px 0;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
:deep(.el-breadcrumb__item) {
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.el-breadcrumb__inner {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-color-primary-light-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child .el-breadcrumb__inner {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-view {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
156
web-app-vue/src/components/MessageRenderer.vue
Normal file
156
web-app-vue/src/components/MessageRenderer.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<div class="message-renderer" v-html="renderedContent"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
content: string
|
||||||
|
enableMarkdown?: boolean
|
||||||
|
enableHtml?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 配置 marked
|
||||||
|
marked.setOptions({
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 渲染内容
|
||||||
|
const renderedContent = computed(() => {
|
||||||
|
let content = props.content || ''
|
||||||
|
|
||||||
|
// 如果启用 Markdown
|
||||||
|
if (props.enableMarkdown) {
|
||||||
|
content = marked(content) as string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果启用 HTML,使用 DOMPurify 清理
|
||||||
|
if (props.enableHtml) {
|
||||||
|
content = DOMPurify.sanitize(content, {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'ul', 'ol', 'li',
|
||||||
|
'blockquote',
|
||||||
|
'a', 'img',
|
||||||
|
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||||
|
'div', 'span'
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'style'],
|
||||||
|
ALLOW_DATA_ATTR: false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 不启用 HTML 时,转义 HTML 标签
|
||||||
|
content = content
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理换行
|
||||||
|
content = content.replace(/\n/g, '<br>')
|
||||||
|
|
||||||
|
return content
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.message-renderer {
|
||||||
|
word-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
|
||||||
|
:deep(p) {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(code) {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(pre) {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(blockquote) {
|
||||||
|
border-left: 4px solid var(--el-color-primary);
|
||||||
|
padding-left: 12px;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(a) {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(table) {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(ul, ol) {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding-left: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(h1, h2, h3, h4, h5, h6) {
|
||||||
|
margin: 1em 0 0.5em 0;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
166
web-app-vue/src/components/VariableNode.vue
Normal file
166
web-app-vue/src/components/VariableNode.vue
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<template>
|
||||||
|
<div class="variable-node">
|
||||||
|
<!-- 基本类型 -->
|
||||||
|
<span v-if="isBasicType" :class="`value-${valueType}`">
|
||||||
|
{{ formattedValue }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 数组类型 -->
|
||||||
|
<div v-else-if="Array.isArray(value)" class="array-node">
|
||||||
|
<div class="node-header" @click="toggleExpand">
|
||||||
|
<el-icon :class="{ rotated: expanded }">
|
||||||
|
<ArrowRight />
|
||||||
|
</el-icon>
|
||||||
|
<span class="node-label">Array({{ value.length }})</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="expanded" class="node-children">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in value"
|
||||||
|
:key="index"
|
||||||
|
class="child-item"
|
||||||
|
>
|
||||||
|
<span class="child-key">[{{ index }}]:</span>
|
||||||
|
<VariableNode :value="item" :depth="depth + 1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 对象类型 -->
|
||||||
|
<div v-else-if="isObject" class="object-node">
|
||||||
|
<div class="node-header" @click="toggleExpand">
|
||||||
|
<el-icon :class="{ rotated: expanded }">
|
||||||
|
<ArrowRight />
|
||||||
|
</el-icon>
|
||||||
|
<span class="node-label">Object({{ objectKeys.length }})</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="expanded" class="node-children">
|
||||||
|
<div
|
||||||
|
v-for="key in objectKeys"
|
||||||
|
:key="key"
|
||||||
|
class="child-item"
|
||||||
|
>
|
||||||
|
<span class="child-key">{{ key }}:</span>
|
||||||
|
<VariableNode :value="value[key]" :depth="depth + 1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- null 或 undefined -->
|
||||||
|
<span v-else class="value-null">{{ value }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { ArrowRight } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
value: any
|
||||||
|
depth: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const expanded = ref(props.depth < 2) // 默认展开前两层
|
||||||
|
|
||||||
|
// 值类型
|
||||||
|
const valueType = computed(() => typeof props.value)
|
||||||
|
|
||||||
|
// 是否为基本类型
|
||||||
|
const isBasicType = computed(() => {
|
||||||
|
const type = valueType.value
|
||||||
|
return type === 'string' || type === 'number' || type === 'boolean'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 是否为对象
|
||||||
|
const isObject = computed(() => {
|
||||||
|
return props.value !== null && valueType.value === 'object' && !Array.isArray(props.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 对象的键
|
||||||
|
const objectKeys = computed(() => {
|
||||||
|
if (isObject.value) {
|
||||||
|
return Object.keys(props.value)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 格式化的值
|
||||||
|
const formattedValue = computed(() => {
|
||||||
|
if (valueType.value === 'string') {
|
||||||
|
return `"${props.value}"`
|
||||||
|
}
|
||||||
|
return String(props.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换展开/收起
|
||||||
|
function toggleExpand() {
|
||||||
|
expanded.value = !expanded.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.variable-node {
|
||||||
|
.value-string {
|
||||||
|
color: #22863a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-number {
|
||||||
|
color: #005cc5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-boolean {
|
||||||
|
color: #d73a49;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-null {
|
||||||
|
color: #6a737d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.array-node,
|
||||||
|
.object-node {
|
||||||
|
.node-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&.rotated {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-children {
|
||||||
|
margin-left: 16px;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid var(--el-border-color-lighter);
|
||||||
|
|
||||||
|
.child-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 0;
|
||||||
|
|
||||||
|
.child-key {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
110
web-app-vue/src/components/VariableViewer.vue
Normal file
110
web-app-vue/src/components/VariableViewer.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<div class="variable-viewer">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>变量查看器</span>
|
||||||
|
<el-button size="small" :icon="Refresh" @click="handleRefresh">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-empty v-if="!variables || variables.length === 0" description="暂无变量" />
|
||||||
|
|
||||||
|
<div v-else class="variable-list">
|
||||||
|
<div
|
||||||
|
v-for="variable in variables"
|
||||||
|
:key="variable.key"
|
||||||
|
class="variable-item"
|
||||||
|
>
|
||||||
|
<div class="variable-header">
|
||||||
|
<span class="variable-key">{{ variable.key }}</span>
|
||||||
|
<el-tag :type="getVariableType(variable.value)" size="small">
|
||||||
|
{{ typeof variable.value }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="variable-value">
|
||||||
|
<VariableNode :value="variable.value" :depth="0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
|
import VariableNode from './VariableNode.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
variables: Array<{ key: string; value: any }>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'refresh'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 刷新
|
||||||
|
function handleRefresh() {
|
||||||
|
emit('refresh')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取变量类型标签颜色
|
||||||
|
function getVariableType(value: any): string {
|
||||||
|
const type = typeof value
|
||||||
|
switch (type) {
|
||||||
|
case 'string':
|
||||||
|
return 'success'
|
||||||
|
case 'number':
|
||||||
|
return 'warning'
|
||||||
|
case 'boolean':
|
||||||
|
return 'info'
|
||||||
|
case 'object':
|
||||||
|
return value === null ? '' : 'primary'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.variable-viewer {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.variable-item {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.variable-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.variable-key {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable-value {
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
57
web-app-vue/src/components/preset/GenericPresetForm.vue
Normal file
57
web-app-vue/src/components/preset/GenericPresetForm.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div class="generic-preset-form">
|
||||||
|
<el-alert
|
||||||
|
title="此预设类型暂不支持表单模式"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<p>当前预设类型暂时只支持 JSON 模式编辑。</p>
|
||||||
|
<p>请切换到 JSON 模式进行编辑。</p>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<el-form :model="localData" label-width="140px" style="margin-top: 20px;">
|
||||||
|
<el-form-item label="预设名称">
|
||||||
|
<el-input v-model="localData.name" placeholder="请输入预设名称" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="预设类型">
|
||||||
|
<el-input v-model="localData.type" placeholder="预设类型" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: any): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localData = ref({
|
||||||
|
name: props.modelValue?.name ?? '',
|
||||||
|
type: props.modelValue?.type ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
localData.value = {
|
||||||
|
name: newVal.name ?? '',
|
||||||
|
type: newVal.type ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.generic-preset-form {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
326
web-app-vue/src/components/preset/OpenAIPresetForm.vue
Normal file
326
web-app-vue/src/components/preset/OpenAIPresetForm.vue
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
<template>
|
||||||
|
<div class="openai-preset-form">
|
||||||
|
<el-form :model="localData" label-width="140px" label-position="left">
|
||||||
|
<!-- 基础参数 -->
|
||||||
|
<el-divider content-position="left">基础参数</el-divider>
|
||||||
|
|
||||||
|
<el-form-item label="Temperature">
|
||||||
|
<el-slider
|
||||||
|
v-model="localData.temperature"
|
||||||
|
:min="0"
|
||||||
|
:max="2"
|
||||||
|
:step="0.01"
|
||||||
|
show-input
|
||||||
|
:input-size="'small'"
|
||||||
|
/>
|
||||||
|
<span class="param-desc">控制输出的随机性,值越高越随机</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Max Tokens">
|
||||||
|
<el-input-number
|
||||||
|
v-model="localData.max_tokens"
|
||||||
|
:min="1"
|
||||||
|
:max="32000"
|
||||||
|
:step="100"
|
||||||
|
/>
|
||||||
|
<span class="param-desc">生成的最大token数量</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Top P">
|
||||||
|
<el-slider
|
||||||
|
v-model="localData.top_p"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
show-input
|
||||||
|
:input-size="'small'"
|
||||||
|
/>
|
||||||
|
<span class="param-desc">核采样参数,控制输出多样性</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Frequency Penalty">
|
||||||
|
<el-slider
|
||||||
|
v-model="localData.frequency_penalty"
|
||||||
|
:min="-2"
|
||||||
|
:max="2"
|
||||||
|
:step="0.01"
|
||||||
|
show-input
|
||||||
|
:input-size="'small'"
|
||||||
|
/>
|
||||||
|
<span class="param-desc">降低重复词汇的频率</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Presence Penalty">
|
||||||
|
<el-slider
|
||||||
|
v-model="localData.presence_penalty"
|
||||||
|
:min="-2"
|
||||||
|
:max="2"
|
||||||
|
:step="0.01"
|
||||||
|
show-input
|
||||||
|
:input-size="'small'"
|
||||||
|
/>
|
||||||
|
<span class="param-desc">鼓励谈论新话题</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 提示词模板 -->
|
||||||
|
<el-divider content-position="left">提示词模板</el-divider>
|
||||||
|
|
||||||
|
<div class="prompt-item">
|
||||||
|
<div class="prompt-header">
|
||||||
|
<el-checkbox v-model="promptsEnabled.main_prompt">主提示词</el-checkbox>
|
||||||
|
<span class="prompt-desc">系统的主要提示词</span>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-if="promptsEnabled.main_prompt"
|
||||||
|
v-model="localData.main_prompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="输入主提示词..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prompt-item">
|
||||||
|
<div class="prompt-header">
|
||||||
|
<el-checkbox v-model="promptsEnabled.nsfw_prompt">NSFW提示词</el-checkbox>
|
||||||
|
<span class="prompt-desc">成人内容相关提示词</span>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-if="promptsEnabled.nsfw_prompt"
|
||||||
|
v-model="localData.nsfw_prompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="输入NSFW提示词..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prompt-item">
|
||||||
|
<div class="prompt-header">
|
||||||
|
<el-checkbox v-model="promptsEnabled.jailbreak_prompt">越狱提示词</el-checkbox>
|
||||||
|
<span class="prompt-desc">绕过限制的提示词</span>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-if="promptsEnabled.jailbreak_prompt"
|
||||||
|
v-model="localData.jailbreak_prompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="输入越狱提示词..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prompt-item">
|
||||||
|
<div class="prompt-header">
|
||||||
|
<el-checkbox v-model="promptsEnabled.impersonation_prompt">扮演提示词</el-checkbox>
|
||||||
|
<span class="prompt-desc">角色扮演相关提示词</span>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-if="promptsEnabled.impersonation_prompt"
|
||||||
|
v-model="localData.impersonation_prompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="输入扮演提示词..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prompt-item">
|
||||||
|
<div class="prompt-header">
|
||||||
|
<el-checkbox v-model="promptsEnabled.wi_format">世界书格式</el-checkbox>
|
||||||
|
<span class="prompt-desc">世界书条目的格式化模板</span>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-if="promptsEnabled.wi_format"
|
||||||
|
v-model="localData.wi_format"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="输入世界书格式..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prompt-item">
|
||||||
|
<div class="prompt-header">
|
||||||
|
<el-checkbox v-model="promptsEnabled.scenario_format">场景格式</el-checkbox>
|
||||||
|
<span class="prompt-desc">场景描述的格式化模板</span>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-if="promptsEnabled.scenario_format"
|
||||||
|
v-model="localData.scenario_format"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="输入场景格式..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prompt-item">
|
||||||
|
<div class="prompt-header">
|
||||||
|
<el-checkbox v-model="promptsEnabled.persona_description">角色描述</el-checkbox>
|
||||||
|
<span class="prompt-desc">角色人设描述模板</span>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-if="promptsEnabled.persona_description"
|
||||||
|
v-model="localData.persona_description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="输入角色描述..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prompt-item">
|
||||||
|
<div class="prompt-header">
|
||||||
|
<el-checkbox v-model="promptsEnabled.new_chat_prompt">新对话提示词</el-checkbox>
|
||||||
|
<span class="prompt-desc">开始新对话时的提示词</span>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-if="promptsEnabled.new_chat_prompt"
|
||||||
|
v-model="localData.new_chat_prompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="输入新对话提示词..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prompt-item">
|
||||||
|
<div class="prompt-header">
|
||||||
|
<el-checkbox v-model="promptsEnabled.new_group_chat_prompt">群聊提示词</el-checkbox>
|
||||||
|
<span class="prompt-desc">群组对话的提示词</span>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-if="promptsEnabled.new_group_chat_prompt"
|
||||||
|
v-model="localData.new_group_chat_prompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="输入群聊提示词..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prompt-item">
|
||||||
|
<div class="prompt-header">
|
||||||
|
<el-checkbox v-model="promptsEnabled.new_example_chat_prompt">示例对话提示词</el-checkbox>
|
||||||
|
<span class="prompt-desc">示例对话的提示词</span>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-if="promptsEnabled.new_example_chat_prompt"
|
||||||
|
v-model="localData.new_example_chat_prompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="输入示例对话提示词..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: any): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 本地数据
|
||||||
|
const localData = ref({
|
||||||
|
temperature: props.modelValue?.temperature ?? 1.0,
|
||||||
|
max_tokens: props.modelValue?.max_tokens ?? 2000,
|
||||||
|
top_p: props.modelValue?.top_p ?? 1.0,
|
||||||
|
frequency_penalty: props.modelValue?.frequency_penalty ?? 0,
|
||||||
|
presence_penalty: props.modelValue?.presence_penalty ?? 0,
|
||||||
|
main_prompt: props.modelValue?.main_prompt ?? '',
|
||||||
|
nsfw_prompt: props.modelValue?.nsfw_prompt ?? '',
|
||||||
|
jailbreak_prompt: props.modelValue?.jailbreak_prompt ?? '',
|
||||||
|
impersonation_prompt: props.modelValue?.impersonation_prompt ?? '',
|
||||||
|
wi_format: props.modelValue?.wi_format ?? '',
|
||||||
|
scenario_format: props.modelValue?.scenario_format ?? '',
|
||||||
|
persona_description: props.modelValue?.persona_description ?? '',
|
||||||
|
new_chat_prompt: props.modelValue?.new_chat_prompt ?? '',
|
||||||
|
new_group_chat_prompt: props.modelValue?.new_group_chat_prompt ?? '',
|
||||||
|
new_example_chat_prompt: props.modelValue?.new_example_chat_prompt ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 提示词启用状态
|
||||||
|
const promptsEnabled = ref({
|
||||||
|
main_prompt: !!props.modelValue?.main_prompt,
|
||||||
|
nsfw_prompt: !!props.modelValue?.nsfw_prompt,
|
||||||
|
jailbreak_prompt: !!props.modelValue?.jailbreak_prompt,
|
||||||
|
impersonation_prompt: !!props.modelValue?.impersonation_prompt,
|
||||||
|
wi_format: !!props.modelValue?.wi_format,
|
||||||
|
scenario_format: !!props.modelValue?.scenario_format,
|
||||||
|
persona_description: !!props.modelValue?.persona_description,
|
||||||
|
new_chat_prompt: !!props.modelValue?.new_chat_prompt,
|
||||||
|
new_group_chat_prompt: !!props.modelValue?.new_group_chat_prompt,
|
||||||
|
new_example_chat_prompt: !!props.modelValue?.new_example_chat_prompt
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听本地数据变化,同步到父组件
|
||||||
|
watch(localData, (newVal) => {
|
||||||
|
emit('update:modelValue', { ...newVal })
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 监听启用状态变化,清空未启用的提示词
|
||||||
|
watch(promptsEnabled, (newVal) => {
|
||||||
|
Object.keys(newVal).forEach(key => {
|
||||||
|
if (!newVal[key as keyof typeof newVal]) {
|
||||||
|
localData.value[key as keyof typeof localData.value] = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 监听外部数据变化
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
localData.value = {
|
||||||
|
temperature: newVal.temperature ?? 1.0,
|
||||||
|
max_tokens: newVal.max_tokens ?? 2000,
|
||||||
|
top_p: newVal.top_p ?? 1.0,
|
||||||
|
frequency_penalty: newVal.frequency_penalty ?? 0,
|
||||||
|
presence_penalty: newVal.presence_penalty ?? 0,
|
||||||
|
main_prompt: newVal.main_prompt ?? '',
|
||||||
|
nsfw_prompt: newVal.nsfw_prompt ?? '',
|
||||||
|
jailbreak_prompt: newVal.jailbreak_prompt ?? '',
|
||||||
|
impersonation_prompt: newVal.impersonation_prompt ?? '',
|
||||||
|
wi_format: newVal.wi_format ?? '',
|
||||||
|
scenario_format: newVal.scenario_format ?? '',
|
||||||
|
persona_description: newVal.persona_description ?? '',
|
||||||
|
new_chat_prompt: newVal.new_chat_prompt ?? '',
|
||||||
|
new_group_chat_prompt: newVal.new_group_chat_prompt ?? '',
|
||||||
|
new_example_chat_prompt: newVal.new_example_chat_prompt ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.openai-preset-form {
|
||||||
|
.param-desc {
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-item {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.prompt-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.prompt-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-slider) {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,42 +4,85 @@
|
|||||||
<el-header class="layout-header">
|
<el-header class="layout-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1 class="logo" @click="router.push('/')">云酒馆</h1>
|
<h1 class="logo" @click="router.push('/')">云酒馆</h1>
|
||||||
|
|
||||||
<!-- 主导航菜单 -->
|
<!-- 主导航菜单 - 改为按钮触发抽屉 -->
|
||||||
<el-menu
|
<div class="header-menu">
|
||||||
:default-active="activeMenu"
|
<el-button
|
||||||
mode="horizontal"
|
text
|
||||||
:ellipsis="false"
|
:class="{ active: activeMenu === '/' }"
|
||||||
class="header-menu"
|
@click="router.push('/')"
|
||||||
@select="handleMenuSelect"
|
>
|
||||||
>
|
|
||||||
<el-menu-item index="/">
|
|
||||||
<el-icon><Grid /></el-icon>
|
<el-icon><Grid /></el-icon>
|
||||||
<span>角色广场</span>
|
<span>角色广场</span>
|
||||||
</el-menu-item>
|
</el-button>
|
||||||
<el-menu-item v-if="authStore.isLoggedIn" index="/my-characters">
|
|
||||||
<el-icon><Files /></el-icon>
|
<template v-if="authStore.isLoggedIn">
|
||||||
<span>我的角色卡</span>
|
<el-button
|
||||||
</el-menu-item>
|
text
|
||||||
<el-menu-item v-if="authStore.isLoggedIn" index="/worldbook">
|
:class="{ active: activeMenu === '/my-characters' }"
|
||||||
<el-icon><Reading /></el-icon>
|
@click="openDrawer('my-characters')"
|
||||||
<span>世界书</span>
|
>
|
||||||
</el-menu-item>
|
<el-icon><Files /></el-icon>
|
||||||
<el-menu-item v-if="authStore.isLoggedIn" index="/regex">
|
<span>我的角色卡</span>
|
||||||
<el-icon><MagicStick /></el-icon>
|
</el-button>
|
||||||
<span>正则脚本</span>
|
|
||||||
</el-menu-item>
|
<el-button
|
||||||
<el-menu-item v-if="authStore.isLoggedIn" index="/chats">
|
text
|
||||||
<el-icon><ChatDotRound /></el-icon>
|
:class="{ active: activeMenu === '/worldbook' }"
|
||||||
<span>对话</span>
|
@click="openDrawer('worldbook')"
|
||||||
</el-menu-item>
|
>
|
||||||
<el-menu-item v-if="authStore.isLoggedIn" index="/ai-config">
|
<el-icon><Reading /></el-icon>
|
||||||
<el-icon><Setting /></el-icon>
|
<span>世界书</span>
|
||||||
<span>AI 配置</span>
|
</el-button>
|
||||||
</el-menu-item>
|
|
||||||
</el-menu>
|
<el-button
|
||||||
|
text
|
||||||
|
:class="{ active: activeMenu === '/regex' }"
|
||||||
|
@click="openDrawer('regex')"
|
||||||
|
>
|
||||||
|
<el-icon><MagicStick /></el-icon>
|
||||||
|
<span>正则脚本</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
text
|
||||||
|
:class="{ active: activeMenu === '/scripts' }"
|
||||||
|
@click="openDrawer('scripts')"
|
||||||
|
>
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
<span>脚本管理</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
text
|
||||||
|
:class="{ active: activeMenu === '/preset' }"
|
||||||
|
@click="openDrawer('preset')"
|
||||||
|
>
|
||||||
|
<el-icon><Memo /></el-icon>
|
||||||
|
<span>AI 预设</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
text
|
||||||
|
:class="{ active: activeMenu === '/chats' }"
|
||||||
|
@click="router.push('/chats')"
|
||||||
|
>
|
||||||
|
<el-icon><ChatDotRound /></el-icon>
|
||||||
|
<span>对话</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
text
|
||||||
|
:class="{ active: activeMenu === '/ai-config' }"
|
||||||
|
@click="openDrawer('ai-config')"
|
||||||
|
>
|
||||||
|
<el-icon><Setting /></el-icon>
|
||||||
|
<span>AI 配置</span>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<!-- 未登录状态 -->
|
<!-- 未登录状态 -->
|
||||||
<div v-if="!authStore.isLoggedIn" class="auth-buttons">
|
<div v-if="!authStore.isLoggedIn" class="auth-buttons">
|
||||||
@@ -50,7 +93,7 @@
|
|||||||
注册
|
注册
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 已登录状态 -->
|
<!-- 已登录状态 -->
|
||||||
<el-dropdown v-else @command="handleCommand">
|
<el-dropdown v-else @command="handleCommand">
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
@@ -74,14 +117,30 @@
|
|||||||
<el-main class="layout-main">
|
<el-main class="layout-main">
|
||||||
<router-view />
|
<router-view />
|
||||||
</el-main>
|
</el-main>
|
||||||
|
|
||||||
|
<!-- 抽屉式面板 -->
|
||||||
|
<el-drawer
|
||||||
|
v-model="drawerVisible"
|
||||||
|
:title="drawerTitle"
|
||||||
|
:size="drawerSize"
|
||||||
|
direction="rtl"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
>
|
||||||
|
<DrawerContentWrapper
|
||||||
|
v-if="drawerComponent"
|
||||||
|
:initial-component="drawerComponent"
|
||||||
|
:initial-title="drawerTitle"
|
||||||
|
/>
|
||||||
|
</el-drawer>
|
||||||
</el-container>
|
</el-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, computed, shallowRef } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { Grid, Files, Reading, MagicStick, Setting, ChatDotRound } from '@element-plus/icons-vue'
|
import { Grid, Files, Reading, MagicStick, Document, Memo, Setting, ChatDotRound } from '@element-plus/icons-vue'
|
||||||
|
import DrawerContentWrapper from '@/components/DrawerContentWrapper.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -90,19 +149,69 @@ const authStore = useAuthStore()
|
|||||||
// 初始化用户信息
|
// 初始化用户信息
|
||||||
authStore.initUserInfo()
|
authStore.initUserInfo()
|
||||||
|
|
||||||
|
// 抽屉状态
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
const drawerTitle = ref('')
|
||||||
|
const drawerSize = ref('60%')
|
||||||
|
const drawerComponent = shallowRef<any>(null)
|
||||||
|
|
||||||
// 当前激活的菜单
|
// 当前激活的菜单
|
||||||
const activeMenu = computed(() => {
|
const activeMenu = computed(() => {
|
||||||
if (route.path.startsWith('/my-characters')) return '/my-characters'
|
if (route.path.startsWith('/my-characters')) return '/my-characters'
|
||||||
if (route.path.startsWith('/worldbook')) return '/worldbook'
|
if (route.path.startsWith('/worldbook')) return '/worldbook'
|
||||||
if (route.path.startsWith('/regex')) return '/regex'
|
if (route.path.startsWith('/regex')) return '/regex'
|
||||||
|
if (route.path.startsWith('/scripts')) return '/scripts'
|
||||||
|
if (route.path.startsWith('/preset')) return '/preset'
|
||||||
if (route.path.startsWith('/chats') || route.path.startsWith('/chat/')) return '/chats'
|
if (route.path.startsWith('/chats') || route.path.startsWith('/chat/')) return '/chats'
|
||||||
if (route.path.startsWith('/ai-config')) return '/ai-config'
|
if (route.path.startsWith('/ai-config')) return '/ai-config'
|
||||||
return '/'
|
return '/'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 菜单选择处理
|
// 打开抽屉
|
||||||
function handleMenuSelect(index: string) {
|
async function openDrawer(type: string) {
|
||||||
router.push(index)
|
// 如果抽屉已经打开,先关闭它以重置状态
|
||||||
|
if (drawerVisible.value) {
|
||||||
|
drawerVisible.value = false
|
||||||
|
// 等待抽屉关闭动画完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据类型设置抽屉内容
|
||||||
|
switch (type) {
|
||||||
|
case 'my-characters':
|
||||||
|
drawerTitle.value = '我的角色卡'
|
||||||
|
drawerSize.value = '70%'
|
||||||
|
drawerComponent.value = (await import('@/views/character/MyCharacters.vue')).default
|
||||||
|
break
|
||||||
|
case 'worldbook':
|
||||||
|
drawerTitle.value = '世界书管理'
|
||||||
|
drawerSize.value = '75%'
|
||||||
|
drawerComponent.value = (await import('@/views/worldbook/WorldBookListDrawer.vue')).default
|
||||||
|
break
|
||||||
|
case 'regex':
|
||||||
|
drawerTitle.value = '正则脚本管理'
|
||||||
|
drawerSize.value = '70%'
|
||||||
|
drawerComponent.value = (await import('@/views/regex/RegexScriptList.vue')).default
|
||||||
|
break
|
||||||
|
case 'scripts':
|
||||||
|
drawerTitle.value = '脚本管理'
|
||||||
|
drawerSize.value = '70%'
|
||||||
|
drawerComponent.value = (await import('@/views/script/ScriptManager.vue')).default
|
||||||
|
break
|
||||||
|
case 'preset':
|
||||||
|
drawerTitle.value = 'AI 预设管理'
|
||||||
|
drawerSize.value = '70%'
|
||||||
|
drawerComponent.value = (await import('@/views/preset/PresetList.vue')).default
|
||||||
|
break
|
||||||
|
case 'ai-config':
|
||||||
|
drawerTitle.value = 'AI 配置'
|
||||||
|
drawerSize.value = '60%'
|
||||||
|
drawerComponent.value = (await import('@/views/provider/ProviderList.vue')).default
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开抽屉
|
||||||
|
drawerVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下拉菜单命令处理
|
// 下拉菜单命令处理
|
||||||
@@ -155,8 +264,33 @@ function handleCommand(command: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-menu {
|
.header-menu {
|
||||||
border: none;
|
display: flex;
|
||||||
background: transparent;
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
:deep(.el-button) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
web-app-vue/src/types/preset.d.ts
vendored
Normal file
41
web-app-vue/src/types/preset.d.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// AI预设类型
|
||||||
|
export interface AIPreset {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
content: Record<string, any>
|
||||||
|
isSystem: boolean
|
||||||
|
isDefault: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建预设请求
|
||||||
|
export interface CreatePresetRequest {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
content: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新预设请求
|
||||||
|
export interface UpdatePresetRequest {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
content: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预设列表查询参数
|
||||||
|
export interface PresetListParams {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
name?: string
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预设类型选项
|
||||||
|
export const PRESET_TYPE_OPTIONS = [
|
||||||
|
{ label: 'OpenAI', value: 'openai' },
|
||||||
|
{ label: 'Claude', value: 'claude' },
|
||||||
|
{ label: 'Gemini', value: 'gemini' },
|
||||||
|
{ label: 'Custom', value: 'custom' }
|
||||||
|
]
|
||||||
@@ -143,6 +143,14 @@ import { ElMessageBox, ElMessage } from 'element-plus'
|
|||||||
import { ArrowLeft, MoreFilled, Promotion, Loading } from '@element-plus/icons-vue'
|
import { ArrowLeft, MoreFilled, Promotion, Loading } from '@element-plus/icons-vue'
|
||||||
import * as chatApi from '@/api/chat'
|
import * as chatApi from '@/api/chat'
|
||||||
import * as regexScriptApi from '@/api/regexScript'
|
import * as regexScriptApi from '@/api/regexScript'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
|
// 配置 marked
|
||||||
|
marked.setOptions({
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -319,16 +327,39 @@ function isFullHtmlDocument(text: string | undefined): boolean {
|
|||||||
return t.startsWith('<!doctype html') || t.startsWith('<html') || t.includes('<body>')
|
return t.startsWith('<!doctype html') || t.startsWith('<html') || t.includes('<body>')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 简单的 Markdown 渲染(粗体、斜体、代码、换行) */
|
/** 增强的 Markdown 渲染(支持完整 Markdown 语法和 HTML) */
|
||||||
function renderMarkdown(text: string): string {
|
function renderMarkdown(text: string): string {
|
||||||
if (!text) return ''
|
if (!text) return ''
|
||||||
return text
|
|
||||||
// 仅做最基本的 & 转义,保留 HTML 标签以支持角色卡/正则脚本输出的富文本
|
try {
|
||||||
.replace(/&/g, '&')
|
// 使用 marked 渲染 Markdown
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
let html = marked(text) as string
|
||||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
||||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
// 使用 DOMPurify 清理 HTML,保留常用标签
|
||||||
.replace(/\n/g, '<br>')
|
html = DOMPurify.sanitize(html, {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'ul', 'ol', 'li',
|
||||||
|
'blockquote',
|
||||||
|
'a', 'img',
|
||||||
|
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||||
|
'div', 'span', 'hr'
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'style', 'target', 'rel'],
|
||||||
|
ALLOW_DATA_ATTR: false
|
||||||
|
})
|
||||||
|
|
||||||
|
return html
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Markdown 渲染失败:', error)
|
||||||
|
// 降级到简单渲染
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\n/g, '<br>')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -436,16 +467,106 @@ function renderMarkdown(text: string): string {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|
||||||
|
:deep(p) {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:deep(code) {
|
:deep(code) {
|
||||||
background: var(--el-fill-color-darker);
|
background: var(--el-fill-color-darker);
|
||||||
padding: 1px 4px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(pre) {
|
||||||
|
background: var(--el-fill-color-darker);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(blockquote) {
|
||||||
|
border-left: 4px solid var(--el-color-primary);
|
||||||
|
padding-left: 12px;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(a) {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(table) {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(ul, ol) {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding-left: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(h1, h2, h3, h4, h5, h6) {
|
||||||
|
margin: 1em 0 0.5em 0;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(hr) {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
margin: 1em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(strong) {
|
:deep(strong) {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(em) {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-meta {
|
.msg-meta {
|
||||||
|
|||||||
340
web-app-vue/src/views/preset/PresetEdit.vue
Normal file
340
web-app-vue/src/views/preset/PresetEdit.vue
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
<template>
|
||||||
|
<div class="preset-edit">
|
||||||
|
<!-- 页面标题 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{{ isEdit ? '编辑预设' : '创建预设' }}</h2>
|
||||||
|
<div class="actions">
|
||||||
|
<el-button @click="handleCancel">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<el-card class="info-card">
|
||||||
|
<template #header>
|
||||||
|
<span>基本信息</span>
|
||||||
|
</template>
|
||||||
|
<el-form :model="formData" :rules="rules" ref="formRef" label-width="120px">
|
||||||
|
<el-form-item label="预设名称" prop="name">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.name"
|
||||||
|
placeholder="请输入预设名称"
|
||||||
|
maxlength="100"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="预设类型" prop="type">
|
||||||
|
<el-select
|
||||||
|
v-model="formData.type"
|
||||||
|
placeholder="请选择预设类型"
|
||||||
|
:disabled="isEdit"
|
||||||
|
@change="handleTypeChange"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="option in PRESET_TYPE_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 预设内容 -->
|
||||||
|
<el-card class="content-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>预设内容</span>
|
||||||
|
<el-radio-group v-model="editMode" size="small">
|
||||||
|
<el-radio-button label="form">表单模式</el-radio-button>
|
||||||
|
<el-radio-button label="json">JSON模式</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 表单模式 -->
|
||||||
|
<div v-if="editMode === 'form'" class="form-mode">
|
||||||
|
<component
|
||||||
|
:is="currentFormComponent"
|
||||||
|
v-model="formData.content"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JSON模式 -->
|
||||||
|
<div v-else class="json-mode">
|
||||||
|
<el-input
|
||||||
|
v-model="jsonContent"
|
||||||
|
type="textarea"
|
||||||
|
:rows="20"
|
||||||
|
placeholder="请输入JSON格式的预设内容"
|
||||||
|
/>
|
||||||
|
<div class="json-actions">
|
||||||
|
<el-button size="small" @click="formatJson">格式化</el-button>
|
||||||
|
<el-button size="small" @click="validateJson">验证</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed, inject, watch, shallowRef } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import * as presetApi from '@/api/preset'
|
||||||
|
import { PRESET_TYPE_OPTIONS } from '@/types/preset.d'
|
||||||
|
import type { AIPreset, CreatePresetRequest } from '@/types/preset'
|
||||||
|
import OpenAIPresetForm from '@/components/preset/OpenAIPresetForm.vue'
|
||||||
|
import GenericPresetForm from '@/components/preset/GenericPresetForm.vue'
|
||||||
|
|
||||||
|
// Props(用于抽屉模式)
|
||||||
|
const props = defineProps<{
|
||||||
|
presetId?: number
|
||||||
|
mode?: 'create' | 'edit'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 抽屉导航方法
|
||||||
|
const drawerBack = inject<any>('drawerBack', null)
|
||||||
|
|
||||||
|
// 表单引用
|
||||||
|
const formRef = ref()
|
||||||
|
|
||||||
|
// 本地加载状态
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// 编辑模式:form 或 json
|
||||||
|
const editMode = ref<'form' | 'json'>('form')
|
||||||
|
|
||||||
|
// 是否为编辑模式
|
||||||
|
const isEdit = computed(() => {
|
||||||
|
if (props.mode) {
|
||||||
|
return props.mode === 'edit'
|
||||||
|
}
|
||||||
|
return !!route.params.id
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取预设ID
|
||||||
|
const presetId = computed(() => {
|
||||||
|
if (props.presetId) {
|
||||||
|
return props.presetId
|
||||||
|
}
|
||||||
|
return route.params.id ? Number(route.params.id) : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref<CreatePresetRequest>({
|
||||||
|
name: '',
|
||||||
|
type: 'openai',
|
||||||
|
content: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// JSON内容
|
||||||
|
const jsonContent = ref('')
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const rules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入预设名称', trigger: 'blur' },
|
||||||
|
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
type: [
|
||||||
|
{ required: true, message: '请选择预设类型', trigger: 'change' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前表单组件
|
||||||
|
const currentFormComponent = shallowRef<any>(OpenAIPresetForm)
|
||||||
|
|
||||||
|
// 根据类型选择表单组件
|
||||||
|
function getFormComponent(type: string) {
|
||||||
|
switch (type) {
|
||||||
|
case 'openai':
|
||||||
|
return OpenAIPresetForm
|
||||||
|
default:
|
||||||
|
return GenericPresetForm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 类型变化处理
|
||||||
|
function handleTypeChange(type: string) {
|
||||||
|
currentFormComponent.value = getFormComponent(type)
|
||||||
|
// 重置内容
|
||||||
|
formData.value.content = {}
|
||||||
|
jsonContent.value = '{}'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步表单数据到JSON
|
||||||
|
watch(() => formData.value.content, (newVal) => {
|
||||||
|
if (editMode.value === 'form') {
|
||||||
|
jsonContent.value = JSON.stringify(newVal, null, 2)
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 同步JSON到表单数据
|
||||||
|
watch(jsonContent, (newVal) => {
|
||||||
|
if (editMode.value === 'json') {
|
||||||
|
try {
|
||||||
|
formData.value.content = JSON.parse(newVal)
|
||||||
|
} catch (error) {
|
||||||
|
// JSON格式错误,不更新
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 格式化JSON
|
||||||
|
function formatJson() {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(jsonContent.value)
|
||||||
|
jsonContent.value = JSON.stringify(obj, null, 2)
|
||||||
|
ElMessage.success('格式化成功')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('JSON格式错误')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证JSON
|
||||||
|
function validateJson() {
|
||||||
|
try {
|
||||||
|
JSON.parse(jsonContent.value)
|
||||||
|
ElMessage.success('JSON格式正确')
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(`JSON格式错误: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (saving.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
saving.value = true
|
||||||
|
|
||||||
|
// 验证表单
|
||||||
|
await formRef.value.validate()
|
||||||
|
|
||||||
|
// 如果是JSON模式,验证JSON格式
|
||||||
|
if (editMode.value === 'json') {
|
||||||
|
try {
|
||||||
|
formData.value.content = JSON.parse(jsonContent.value)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('JSON格式错误,请检查')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存
|
||||||
|
if (isEdit.value && presetId.value) {
|
||||||
|
await presetApi.updatePreset(presetId.value, {
|
||||||
|
name: formData.value.name,
|
||||||
|
type: formData.value.type,
|
||||||
|
content: formData.value.content
|
||||||
|
})
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await presetApi.createPreset(formData.value)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回
|
||||||
|
if (drawerBack) {
|
||||||
|
drawerBack()
|
||||||
|
} else {
|
||||||
|
router.push('/preset')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('保存失败:', error)
|
||||||
|
ElMessage.error(error.response?.data?.msg || '保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (drawerBack) {
|
||||||
|
drawerBack()
|
||||||
|
} else {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(async () => {
|
||||||
|
if (isEdit.value && presetId.value) {
|
||||||
|
try {
|
||||||
|
const res = await presetApi.getPreset(presetId.value)
|
||||||
|
const preset = res.data
|
||||||
|
formData.value = {
|
||||||
|
name: preset.name,
|
||||||
|
type: preset.type,
|
||||||
|
content: preset.content || {}
|
||||||
|
}
|
||||||
|
jsonContent.value = JSON.stringify(preset.content || {}, null, 2)
|
||||||
|
currentFormComponent.value = getFormComponent(preset.type)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('加载预设失败')
|
||||||
|
if (drawerBack) {
|
||||||
|
drawerBack()
|
||||||
|
} else {
|
||||||
|
router.push('/preset')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 新建时初始化JSON内容
|
||||||
|
jsonContent.value = '{}'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.preset-edit {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-mode {
|
||||||
|
.json-actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
304
web-app-vue/src/views/preset/PresetList.vue
Normal file
304
web-app-vue/src/views/preset/PresetList.vue
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
<template>
|
||||||
|
<div class="preset-list">
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="actions-bar">
|
||||||
|
<el-button type="primary" :icon="Plus" @click="handleCreate">
|
||||||
|
创建预设
|
||||||
|
</el-button>
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
:before-upload="handleImport"
|
||||||
|
accept=".json"
|
||||||
|
>
|
||||||
|
<el-button type="success" :icon="Upload">导入预设</el-button>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<el-card class="search-card">
|
||||||
|
<el-form :inline="true" :model="searchForm" @submit.prevent="handleSearch">
|
||||||
|
<el-form-item label="预设名称">
|
||||||
|
<el-input
|
||||||
|
v-model="searchForm.name"
|
||||||
|
placeholder="搜索预设"
|
||||||
|
clearable
|
||||||
|
@clear="handleSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="类型">
|
||||||
|
<el-select
|
||||||
|
v-model="searchForm.type"
|
||||||
|
placeholder="全部"
|
||||||
|
clearable
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="option in PRESET_TYPE_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</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="loading"
|
||||||
|
:data="presets"
|
||||||
|
stripe
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="name" label="预设名称" min-width="200" />
|
||||||
|
<el-table-column label="类型" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag>{{ getTypeLabel(row.type) }}</el-tag>
|
||||||
|
</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="240" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="handleEdit(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
@click="handleDuplicate(row)"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="info"
|
||||||
|
size="small"
|
||||||
|
@click="handleExport(row)"
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
@click="handleDelete(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="searchForm.page"
|
||||||
|
v-model:page-size="searchForm.pageSize"
|
||||||
|
:total="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, inject } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Upload } from '@element-plus/icons-vue'
|
||||||
|
import * as presetApi from '@/api/preset'
|
||||||
|
import { PRESET_TYPE_OPTIONS } from '@/types/preset.d'
|
||||||
|
import type { AIPreset } from '@/types/preset'
|
||||||
|
import { defineAsyncComponent } from 'vue'
|
||||||
|
|
||||||
|
const drawerNavigate = inject<any>('drawerNavigate')
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const presets = ref<AIPreset[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const searchForm = ref({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
name: '',
|
||||||
|
type: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载预设列表
|
||||||
|
async function loadPresets() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await presetApi.getPresetList(searchForm.value) as any
|
||||||
|
presets.value = res.data?.list || []
|
||||||
|
total.value = res.data?.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('加载预设列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露刷新方法
|
||||||
|
defineExpose({
|
||||||
|
refresh: loadPresets
|
||||||
|
})
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
function handleSearch() {
|
||||||
|
searchForm.value.page = 1
|
||||||
|
loadPresets()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
function handleReset() {
|
||||||
|
searchForm.value = {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
name: '',
|
||||||
|
type: ''
|
||||||
|
}
|
||||||
|
loadPresets()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建预设
|
||||||
|
function handleCreate() {
|
||||||
|
const PresetEdit = defineAsyncComponent(() => import('./PresetEdit.vue'))
|
||||||
|
drawerNavigate({
|
||||||
|
component: PresetEdit,
|
||||||
|
props: { mode: 'create' },
|
||||||
|
title: '创建AI预设',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑预设
|
||||||
|
function handleEdit(row: AIPreset) {
|
||||||
|
const PresetEdit = defineAsyncComponent(() => import('./PresetEdit.vue'))
|
||||||
|
drawerNavigate({
|
||||||
|
component: PresetEdit,
|
||||||
|
props: { presetId: row.id, mode: 'edit' },
|
||||||
|
title: `编辑预设 - ${row.name}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制预设
|
||||||
|
async function handleDuplicate(row: AIPreset) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要复制预设"${row.name}"吗?`,
|
||||||
|
'提示',
|
||||||
|
{ type: 'warning' }
|
||||||
|
)
|
||||||
|
await presetApi.duplicatePreset(row.id)
|
||||||
|
ElMessage.success('复制成功')
|
||||||
|
loadPresets()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出预设
|
||||||
|
async function handleExport(row: AIPreset) {
|
||||||
|
try {
|
||||||
|
const blob = new Blob([JSON.stringify(row.content, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${row.name}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
ElMessage.success('导出成功')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('导出失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除预设
|
||||||
|
async function handleDelete(row: AIPreset) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除预设"${row.name}"吗?此操作不可恢复!`,
|
||||||
|
'警告',
|
||||||
|
{ type: 'warning' }
|
||||||
|
)
|
||||||
|
await presetApi.deletePreset(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadPresets()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入预设
|
||||||
|
async function handleImport(file: File) {
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const content = JSON.parse(text)
|
||||||
|
await presetApi.createPreset({
|
||||||
|
name: content.name || file.name.replace('.json', ''),
|
||||||
|
type: content.type || 'openai',
|
||||||
|
content: content
|
||||||
|
})
|
||||||
|
ElMessage.success('导入成功')
|
||||||
|
loadPresets()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('导入失败')
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取类型标签
|
||||||
|
function getTypeLabel(type: string) {
|
||||||
|
const option = PRESET_TYPE_OPTIONS.find(o => o.value === type)
|
||||||
|
return option?.label || type
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
function formatDate(date: string) {
|
||||||
|
return new Date(date).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadPresets()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.preset-list {
|
||||||
|
.actions-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card {
|
||||||
|
.pagination {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
38
web-app-vue/src/views/script/ScriptManager.vue
Normal file
38
web-app-vue/src/views/script/ScriptManager.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<div class="script-manager">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>脚本管理</span>
|
||||||
|
<el-button type="primary" size="small" :icon="Plus">
|
||||||
|
添加脚本
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-empty description="脚本管理功能开发中..." />
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
// 暴露刷新方法
|
||||||
|
defineExpose({
|
||||||
|
refresh: () => {
|
||||||
|
console.log('刷新脚本列表')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.script-manager {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<h2>{{ isEdit ? '编辑世界书' : '创建世界书' }}</h2>
|
<h2>{{ isEdit ? '编辑世界书' : '创建世界书' }}</h2>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<el-button @click="handleCancel">取消</el-button>
|
<el-button @click="handleCancel">取消</el-button>
|
||||||
<el-button type="primary" :loading="worldInfoStore.loading" @click="handleSave">
|
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||||
保存
|
保存
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,24 +91,50 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed, inject } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, QuestionFilled } from '@element-plus/icons-vue'
|
import { Plus, QuestionFilled } from '@element-plus/icons-vue'
|
||||||
import { useWorldInfoStore } from '@/stores/worldInfo'
|
import { useWorldInfoStore } from '@/stores/worldInfo'
|
||||||
|
import * as worldInfoApi from '@/api/worldInfo'
|
||||||
import type { CreateWorldBookRequest, WorldInfoEntry } from '@/types/worldInfo'
|
import type { CreateWorldBookRequest, WorldInfoEntry } from '@/types/worldInfo'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import WorldInfoEntryForm from './WorldInfoEntryForm.vue'
|
import WorldInfoEntryForm from './WorldInfoEntryForm.vue'
|
||||||
|
|
||||||
|
// Props(用于抽屉模式)
|
||||||
|
const props = defineProps<{
|
||||||
|
bookId?: number
|
||||||
|
mode?: 'create' | 'edit'
|
||||||
|
}>()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const worldInfoStore = useWorldInfoStore()
|
const worldInfoStore = useWorldInfoStore()
|
||||||
|
|
||||||
|
// 抽屉导航方法
|
||||||
|
const drawerBack = inject<any>('drawerBack', null)
|
||||||
|
|
||||||
// 表单引用
|
// 表单引用
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
|
||||||
// 是否为编辑模式
|
// 本地加载状态
|
||||||
const isEdit = computed(() => !!route.params.id)
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// 是否为编辑模式(支持props和route两种方式)
|
||||||
|
const isEdit = computed(() => {
|
||||||
|
if (props.mode) {
|
||||||
|
return props.mode === 'edit'
|
||||||
|
}
|
||||||
|
return !!route.params.id
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取世界书ID(支持props和route两种方式)
|
||||||
|
const bookId = computed(() => {
|
||||||
|
if (props.bookId) {
|
||||||
|
return props.bookId
|
||||||
|
}
|
||||||
|
return route.params.id ? Number(route.params.id) : undefined
|
||||||
|
})
|
||||||
|
|
||||||
// 展开的条目
|
// 展开的条目
|
||||||
const activeEntries = ref<string[]>([])
|
const activeEntries = ref<string[]>([])
|
||||||
@@ -193,44 +219,80 @@ const handleDeleteEntry = async (index: number) => {
|
|||||||
|
|
||||||
// 保存
|
// 保存
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
if (saving.value) {
|
||||||
|
console.log('正在保存中,忽略重复点击')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
saving.value = true
|
||||||
|
console.log('开始保存世界书...')
|
||||||
|
console.log('isEdit:', isEdit.value)
|
||||||
|
console.log('bookId:', bookId.value)
|
||||||
|
console.log('formData:', formData.value)
|
||||||
|
|
||||||
// 验证表单
|
// 验证表单
|
||||||
await formRef.value.validate()
|
await formRef.value.validate()
|
||||||
|
console.log('表单验证通过')
|
||||||
|
|
||||||
// 验证至少有一个条目
|
// 验证至少有一个条目
|
||||||
if (formData.value.entries.length === 0) {
|
if (formData.value.entries.length === 0) {
|
||||||
ElMessage.warning('请至少添加一个条目')
|
ElMessage.warning('请至少添加一个条目')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
console.log('条目数量验证通过:', formData.value.entries.length)
|
||||||
|
|
||||||
// 保存
|
// 保存 - 直接调用API而不是通过store,避免store自动刷新列表
|
||||||
if (isEdit.value) {
|
if (isEdit.value && bookId.value) {
|
||||||
await worldInfoStore.updateWorldBook(Number(route.params.id), {
|
console.log('执行更新操作...')
|
||||||
|
const response = await worldInfoApi.updateWorldBook(bookId.value, {
|
||||||
bookName: formData.value.bookName,
|
bookName: formData.value.bookName,
|
||||||
isGlobal: formData.value.isGlobal,
|
isGlobal: formData.value.isGlobal,
|
||||||
entries: formData.value.entries,
|
entries: formData.value.entries,
|
||||||
linkedChars: formData.value.linkedChars
|
linkedChars: formData.value.linkedChars
|
||||||
})
|
})
|
||||||
|
console.log('更新成功', response)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
} else {
|
} else {
|
||||||
await worldInfoStore.createWorldBook(formData.value)
|
console.log('执行创建操作...')
|
||||||
|
const response = await worldInfoApi.createWorldBook(formData.value)
|
||||||
|
console.log('创建成功', response)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push('/worldbook')
|
// 保存成功后返回
|
||||||
} catch (error) {
|
console.log('准备返回...')
|
||||||
|
if (drawerBack) {
|
||||||
|
console.log('使用抽屉返回')
|
||||||
|
drawerBack()
|
||||||
|
} else {
|
||||||
|
console.log('使用路由返回')
|
||||||
|
router.push('/worldbook')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
console.error('保存失败:', error)
|
console.error('保存失败:', error)
|
||||||
|
ElMessage.error(error.response?.data?.msg || '保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消
|
// 取消
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
router.back()
|
// 如果在抽屉模式下,使用抽屉导航返回
|
||||||
|
if (drawerBack) {
|
||||||
|
drawerBack()
|
||||||
|
} else {
|
||||||
|
// 否则使用路由返回
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (isEdit.value) {
|
if (isEdit.value && bookId.value) {
|
||||||
try {
|
try {
|
||||||
const book = await worldInfoStore.fetchWorldBookDetail(Number(route.params.id))
|
const book = await worldInfoStore.fetchWorldBookDetail(bookId.value)
|
||||||
formData.value = {
|
formData.value = {
|
||||||
bookName: book.bookName,
|
bookName: book.bookName,
|
||||||
isGlobal: book.isGlobal,
|
isGlobal: book.isGlobal,
|
||||||
@@ -239,7 +301,11 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('加载世界书失败')
|
ElMessage.error('加载世界书失败')
|
||||||
router.push('/worldbook')
|
if (drawerBack) {
|
||||||
|
drawerBack()
|
||||||
|
} else {
|
||||||
|
router.push('/worldbook')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
293
web-app-vue/src/views/worldbook/WorldBookListDrawer.vue
Normal file
293
web-app-vue/src/views/worldbook/WorldBookListDrawer.vue
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
<template>
|
||||||
|
<div class="world-book-list-drawer">
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="actions-bar">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<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="loading"
|
||||||
|
:data="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 }">
|
||||||
|
{{ row.entryCount || 0 }}
|
||||||
|
</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"
|
||||||
|
@click="handleEdit(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
@click="handleDuplicate(row)"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="info"
|
||||||
|
size="small"
|
||||||
|
@click="handleExport(row)"
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
@click="handleDelete(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="searchForm.page"
|
||||||
|
v-model:page-size="searchForm.pageSize"
|
||||||
|
:total="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, inject } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Upload } from '@element-plus/icons-vue'
|
||||||
|
import * as worldInfoApi from '@/api/worldInfo'
|
||||||
|
import type { WorldBook } from '@/types/worldInfo'
|
||||||
|
import { defineAsyncComponent } from 'vue'
|
||||||
|
|
||||||
|
const drawerNavigate = inject<any>('drawerNavigate')
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const worldBooks = ref<WorldBook[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const searchForm = ref({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
bookName: '',
|
||||||
|
isGlobal: undefined as boolean | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载世界书列表
|
||||||
|
async function loadWorldBooks() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await worldInfoApi.getWorldBookList(searchForm.value) as any
|
||||||
|
worldBooks.value = res.data?.list || []
|
||||||
|
total.value = res.data?.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('加载世界书列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露刷新方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
refresh: loadWorldBooks
|
||||||
|
})
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
function handleSearch() {
|
||||||
|
searchForm.value.page = 1
|
||||||
|
loadWorldBooks()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
function handleReset() {
|
||||||
|
searchForm.value = {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
bookName: '',
|
||||||
|
isGlobal: undefined,
|
||||||
|
}
|
||||||
|
loadWorldBooks()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建世界书
|
||||||
|
function handleCreate() {
|
||||||
|
const WorldBookEdit = defineAsyncComponent(() => import('./WorldBookEdit.vue'))
|
||||||
|
drawerNavigate({
|
||||||
|
component: WorldBookEdit,
|
||||||
|
props: { mode: 'create' },
|
||||||
|
title: '创建世界书',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑世界书
|
||||||
|
function handleEdit(row: WorldBook) {
|
||||||
|
const WorldBookEdit = defineAsyncComponent(() => import('./WorldBookEdit.vue'))
|
||||||
|
drawerNavigate({
|
||||||
|
component: WorldBookEdit,
|
||||||
|
props: { bookId: row.id, mode: 'edit' },
|
||||||
|
title: `编辑世界书 - ${row.bookName}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制世界书
|
||||||
|
async function handleDuplicate(row: WorldBook) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要复制世界书"${row.bookName}"吗?`,
|
||||||
|
'提示',
|
||||||
|
{ type: 'warning' }
|
||||||
|
)
|
||||||
|
await worldInfoApi.duplicateWorldBook(row.id)
|
||||||
|
ElMessage.success('复制成功')
|
||||||
|
loadWorldBooks()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出世界书
|
||||||
|
async function handleExport(row: WorldBook) {
|
||||||
|
try {
|
||||||
|
await worldInfoApi.downloadWorldBookJSON(row.id)
|
||||||
|
ElMessage.success('导出成功')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('导出失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除世界书
|
||||||
|
async function handleDelete(row: WorldBook) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除世界书"${row.bookName}"吗?此操作不可恢复!`,
|
||||||
|
'警告',
|
||||||
|
{ type: 'warning' }
|
||||||
|
)
|
||||||
|
await worldInfoApi.deleteWorldBook(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadWorldBooks()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入世界书
|
||||||
|
async function handleImport(file: File) {
|
||||||
|
try {
|
||||||
|
await worldInfoApi.importWorldBook(file)
|
||||||
|
ElMessage.success('导入成功')
|
||||||
|
loadWorldBooks()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('导入失败')
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
function formatDate(date: string) {
|
||||||
|
return new Date(date).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadWorldBooks()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.world-book-list-drawer {
|
||||||
|
.actions-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card {
|
||||||
|
.pagination {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
664
word_info/word1.json
Normal file
664
word_info/word1.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user