Compare commits
10 Commits
8e52b854ea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 65803a846f | |||
| 354e01992a | |||
| cc8d7d6134 | |||
| 8b9f4e97f5 | |||
| ce6bc8a37b | |||
| a0d98b4761 | |||
| ae73298bbf | |||
| 1526acc85f | |||
| f4ff763b78 | |||
| f8306be916 |
13
Dockerfile
13
Dockerfile
@@ -17,7 +17,13 @@ COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# 阶段2: 构建后端
|
||||
FROM golang:1.21-alpine as backend-builder
|
||||
FROM golang:1.23-alpine as backend-builder
|
||||
|
||||
# 设置环境变量允许 Go 自动下载工具链
|
||||
ENV GO111MODULE=on \
|
||||
GOPROXY=https://goproxy.cn,direct \
|
||||
CGO_ENABLED=0 \
|
||||
GOTOOLCHAIN=auto
|
||||
|
||||
WORKDIR /app/server
|
||||
|
||||
@@ -25,10 +31,7 @@ WORKDIR /app/server
|
||||
COPY server/go.mod server/go.sum ./
|
||||
|
||||
# 下载依赖
|
||||
RUN go env -w GO111MODULE=on \
|
||||
&& go env -w GOPROXY=https://goproxy.cn,direct \
|
||||
&& go env -w CGO_ENABLED=0 \
|
||||
&& go mod download
|
||||
RUN go mod download
|
||||
|
||||
# 复制后端源码
|
||||
COPY server/ ./
|
||||
|
||||
@@ -170,7 +170,7 @@ func (a *AiPresetApi) ImportAiPreset(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
preset, err := aiPresetService.ParseImportedPreset(rawData)
|
||||
preset, err := aiPresetService.ParseImportedPreset(rawData, "")
|
||||
if err != nil {
|
||||
response.FailWithMessage("解析预设失败:"+err.Error(), c)
|
||||
return
|
||||
@@ -222,7 +222,13 @@ func (a *AiPresetApi) ImportAiPresetFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
preset, err := aiPresetService.ParseImportedPreset(rawData)
|
||||
// 从文件名提取预设名称(去掉 .json 后缀)
|
||||
fileName := file.Filename
|
||||
if strings.HasSuffix(fileName, ".json") {
|
||||
fileName = fileName[:len(fileName)-5]
|
||||
}
|
||||
|
||||
preset, err := aiPresetService.ParseImportedPreset(rawData, fileName)
|
||||
if err != nil {
|
||||
response.FailWithMessage("解析预设失败:"+err.Error(), c)
|
||||
return
|
||||
|
||||
@@ -53,18 +53,6 @@ func (a *AiProxyApi) ChatCompletions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证预设权限
|
||||
if req.PresetName != "" && !aiApiKeyService.CheckPresetPermission(apiKey, req.PresetName) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "该API密钥无权使用此预设: " + req.PresetName,
|
||||
"type": "invalid_request_error",
|
||||
"code": "preset_not_allowed",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 处理流式响应
|
||||
if req.Stream {
|
||||
aiProxyService.ProcessChatCompletionStream(c, &req)
|
||||
@@ -145,18 +133,6 @@ func (a *AiProxyApi) ClaudeMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证预设权限
|
||||
if req.PresetName != "" && !aiApiKeyService.CheckPresetPermission(apiKey, req.PresetName) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "该API密钥无权使用此预设: " + req.PresetName,
|
||||
"type": "invalid_request_error",
|
||||
"code": "preset_not_allowed",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 处理流式响应
|
||||
if req.Stream {
|
||||
aiProxyService.ProcessClaudeMessageStream(c, &req)
|
||||
|
||||
@@ -72,11 +72,11 @@ email:
|
||||
|
||||
# system configuration
|
||||
system:
|
||||
env: local # 修改为public可以关闭路由日志输出
|
||||
addr: 8888
|
||||
db-type: mysql
|
||||
env: public # 修改为public可以关闭路由日志输出
|
||||
addr: 8989
|
||||
db-type: pgsql
|
||||
oss-type: local # 控制oss选择走本地还是 七牛等其他仓 自行增加其他oss仓可以在 server/utils/upload/upload.go 中 NewOss函数配置
|
||||
use-redis: false # 使用redis
|
||||
use-redis: true # 使用redis
|
||||
use-mongo: false # 使用mongo
|
||||
use-multipoint: false
|
||||
# IP限制次数 一个小时15000次
|
||||
@@ -84,7 +84,7 @@ system:
|
||||
# IP限制一个小时
|
||||
iplimit-time: 3600
|
||||
# 路由全局前缀
|
||||
router-prefix: ""
|
||||
router-prefix: /api
|
||||
# 严格角色模式 打开后权限将会存在上下级关系
|
||||
use-strict-auth: false
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ sqlite:
|
||||
system:
|
||||
db-type: pgsql
|
||||
oss-type: aliyun-oss
|
||||
router-prefix: ""
|
||||
router-prefix: "/api"
|
||||
addr: 8989
|
||||
iplimit-count: 15000
|
||||
iplimit-time: 3600
|
||||
|
||||
@@ -119,6 +119,9 @@ func Routers() *gin.Engine {
|
||||
// 注册业务路由
|
||||
initBizRouter(PrivateGroup, PublicGroup)
|
||||
|
||||
// 在根路径下注册 AI 通用接口路由(不带 /api 前缀,走 /v1/**)
|
||||
router.RouterGroupApp.App.InitAiProxyRootRouter(Router)
|
||||
|
||||
// 前端静态文件服务(放在最后,作为兜底路由)
|
||||
setupFrontendRoutes(Router)
|
||||
|
||||
@@ -143,14 +146,21 @@ func setupFrontendRoutes(router *gin.Engine) {
|
||||
// SPA 路由处理:所有非 API 请求都返回 index.html
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
// 如果是 API 请求,返回 404
|
||||
if strings.HasPrefix(path, global.GVA_CONFIG.System.RouterPrefix) {
|
||||
|
||||
// 如果是 API 请求(/api/* 或 /v1/*),返回 404
|
||||
routerPrefix := global.GVA_CONFIG.System.RouterPrefix
|
||||
if routerPrefix == "" {
|
||||
routerPrefix = "/api"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, routerPrefix) || strings.HasPrefix(path, "/v1") {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"msg": "接口不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 其他请求返回前端页面
|
||||
c.File("./dist/index.html")
|
||||
})
|
||||
|
||||
@@ -10,6 +10,8 @@ type ClaudeMessageResponse struct {
|
||||
StopReason string `json:"stop_reason,omitempty"` // end_turn, max_tokens, stop_sequence
|
||||
StopSequence string `json:"stop_sequence,omitempty"`
|
||||
Usage ClaudeUsage `json:"usage"`
|
||||
// 统一格式的用量统计(与 OpenAI chat.completions 对齐)
|
||||
StandardUsage *ChatCompletionUsage `json:"standard_usage,omitempty"`
|
||||
}
|
||||
|
||||
type ClaudeContentBlock struct {
|
||||
|
||||
@@ -7,7 +7,9 @@ type ChatCompletionResponse struct {
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []ChatCompletionChoice `json:"choices"`
|
||||
Usage ChatCompletionUsage `json:"usage"`
|
||||
Usage *ChatCompletionUsage `json:"usage,omitempty"`
|
||||
// 统一格式的用量统计(用于各种上游的标准化)
|
||||
StandardUsage *ChatCompletionUsage `json:"standard_usage,omitempty"`
|
||||
}
|
||||
|
||||
type ChatCompletionChoice struct {
|
||||
@@ -46,3 +48,19 @@ type ChatMessageDelta struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// RegexExecutionLogs 正则脚本执行日志
|
||||
type RegexExecutionLogs struct {
|
||||
InputScripts []RegexScriptLog `json:"input_scripts,omitempty"` // 输入前执行的脚本
|
||||
OutputScripts []RegexScriptLog `json:"output_scripts,omitempty"` // 输出后执行的脚本
|
||||
TotalMatches int `json:"total_matches"` // 总匹配次数
|
||||
}
|
||||
|
||||
// RegexScriptLog 单个正则脚本的执行日志
|
||||
type RegexScriptLog struct {
|
||||
ScriptName string `json:"script_name"` // 脚本名称
|
||||
ScriptID string `json:"script_id"` // 脚本ID
|
||||
Executed bool `json:"executed"` // 是否执行
|
||||
MatchCount int `json:"match_count"` // 匹配次数
|
||||
ErrorMessage string `json:"error_message,omitempty"` // 错误信息
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
type AiProxyRouter struct{}
|
||||
|
||||
// InitAiProxyRouter 使用业务路由组(通常带有 /api 前缀),供后台管理前端调用
|
||||
func (s *AiProxyRouter) InitAiProxyRouter(Router *gin.RouterGroup) {
|
||||
aiProxyRouter := Router.Group("v1").Use(middleware.AiAuth())
|
||||
aiProxyApi := v1.ApiGroupApp.AppApiGroup.AiProxyApi
|
||||
@@ -17,3 +18,15 @@ func (s *AiProxyRouter) InitAiProxyRouter(Router *gin.RouterGroup) {
|
||||
aiProxyRouter.POST("messages", aiProxyApi.ClaudeMessages) // Claude 兼容
|
||||
}
|
||||
}
|
||||
|
||||
// InitAiProxyRootRouter 在根路径下注册 AI 相关的通用接口,路径为 /v1/**
|
||||
// 这样可以对外提供标准的 OpenAI/Claude 兼容地址:/v1/models、/v1/chat/completions、/v1/messages
|
||||
func (s *AiProxyRouter) InitAiProxyRootRouter(engine *gin.Engine) {
|
||||
aiProxyRouter := engine.Group("/v1").Use(middleware.AiAuth())
|
||||
aiProxyApi := v1.ApiGroupApp.AppApiGroup.AiProxyApi
|
||||
{
|
||||
aiProxyRouter.GET("/models", aiProxyApi.ListModels) // 获取模型列表
|
||||
aiProxyRouter.POST("/chat/completions", aiProxyApi.ChatCompletions) // OpenAI 兼容
|
||||
aiProxyRouter.POST("/messages", aiProxyApi.ClaudeMessages) // Claude 兼容
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,13 @@ import (
|
||||
|
||||
// ProcessClaudeMessage 处理 Claude 消息请求
|
||||
func (s *AiProxyService) ProcessClaudeMessage(ctx context.Context, req *request.ClaudeMessageRequest) (*response.ClaudeMessageResponse, error) {
|
||||
// 记录请求参数
|
||||
global.GVA_LOG.Info("收到 Claude Messages 请求",
|
||||
zap.String("model", req.Model),
|
||||
zap.Any("messages", req.Messages),
|
||||
zap.Any("full_request", req),
|
||||
)
|
||||
|
||||
// 1. 根据模型获取配置
|
||||
if req.Model == "" {
|
||||
return nil, fmt.Errorf("model 参数不能为空")
|
||||
@@ -32,8 +39,9 @@ func (s *AiProxyService) ProcessClaudeMessage(ctx context.Context, req *request.
|
||||
}
|
||||
|
||||
// 2. 注入预设
|
||||
var injector *PresetInjector
|
||||
if preset != nil {
|
||||
injector := NewPresetInjector(preset)
|
||||
injector = NewPresetInjector(preset)
|
||||
req.Messages = s.convertClaudeMessages(injector.InjectMessages(s.convertToOpenAIMessages(req.Messages)))
|
||||
}
|
||||
|
||||
@@ -43,17 +51,74 @@ func (s *AiProxyService) ProcessClaudeMessage(ctx context.Context, req *request.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. 处理响应
|
||||
if preset != nil && len(resp.Content) > 0 {
|
||||
injector := NewPresetInjector(preset)
|
||||
resp.Content[0].Text = injector.ProcessResponse(resp.Content[0].Text)
|
||||
// 获取 AI 输出内容
|
||||
aiOutput := ""
|
||||
if len(resp.Content) > 0 {
|
||||
aiOutput = resp.Content[0].Text
|
||||
}
|
||||
|
||||
// 4. 处理响应(使用同一个 injector 实例)
|
||||
if injector != nil && len(resp.Content) > 0 {
|
||||
resp.Content[0].Text = injector.ProcessResponse(resp.Content[0].Text)
|
||||
aiOutput = resp.Content[0].Text
|
||||
}
|
||||
|
||||
// 5. 统一填充 standard_usage,转换为 OpenAI 风格的用量统计
|
||||
if resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0 {
|
||||
resp.StandardUsage = &response.ChatCompletionUsage{
|
||||
PromptTokens: resp.Usage.InputTokens,
|
||||
CompletionTokens: resp.Usage.OutputTokens,
|
||||
TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
|
||||
// 记录响应内容
|
||||
logFields := []zap.Field{
|
||||
zap.String("ai_output", aiOutput),
|
||||
zap.Any("usage", resp.Usage),
|
||||
}
|
||||
|
||||
// 添加正则脚本执行日志(使用同一个 injector 实例)
|
||||
if injector != nil {
|
||||
regexLogs := injector.GetRegexLogs()
|
||||
if regexLogs != nil && (regexLogs.TotalMatches > 0 || len(regexLogs.InputScripts) > 0 || len(regexLogs.OutputScripts) > 0) {
|
||||
// 收集触发的脚本名称
|
||||
triggeredScripts := make([]string, 0)
|
||||
for _, scriptLog := range regexLogs.InputScripts {
|
||||
if scriptLog.MatchCount > 0 {
|
||||
triggeredScripts = append(triggeredScripts, fmt.Sprintf("%s(输入:%d次)", scriptLog.ScriptName, scriptLog.MatchCount))
|
||||
}
|
||||
}
|
||||
for _, scriptLog := range regexLogs.OutputScripts {
|
||||
if scriptLog.MatchCount > 0 {
|
||||
triggeredScripts = append(triggeredScripts, fmt.Sprintf("%s(输出:%d次)", scriptLog.ScriptName, scriptLog.MatchCount))
|
||||
}
|
||||
}
|
||||
|
||||
if len(triggeredScripts) > 0 {
|
||||
logFields = append(logFields,
|
||||
zap.Strings("triggered_regex_scripts", triggeredScripts),
|
||||
zap.Int("total_matches", regexLogs.TotalMatches),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logFields = append(logFields, zap.Any("full_response", resp))
|
||||
global.GVA_LOG.Info("Claude Messages 响应", logFields...)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ProcessClaudeMessageStream 处理 Claude 流式消息请求
|
||||
func (s *AiProxyService) ProcessClaudeMessageStream(c *gin.Context, req *request.ClaudeMessageRequest) {
|
||||
// 记录请求参数
|
||||
global.GVA_LOG.Info("收到 Claude Messages 流式请求",
|
||||
zap.String("model", req.Model),
|
||||
zap.Any("messages", req.Messages),
|
||||
zap.Any("full_request", req),
|
||||
)
|
||||
|
||||
// 1. 根据模型获取配置
|
||||
if req.Model == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "model 参数不能为空"})
|
||||
@@ -135,9 +200,22 @@ func (s *AiProxyService) forwardClaudeStreamRequest(c *gin.Context, provider *ap
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(httpResp.Body)
|
||||
global.GVA_LOG.Error("Claude 流式请求上游返回错误",
|
||||
zap.Int("status_code", httpResp.StatusCode),
|
||||
zap.String("response_body", string(body)),
|
||||
)
|
||||
return fmt.Errorf("上游返回错误: %d - %s", httpResp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(httpResp.Body)
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
|
||||
// 聚合完整输出用于日志
|
||||
var fullContent bytes.Buffer
|
||||
var totalInputTokens, totalOutputTokens int
|
||||
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if err == io.EOF {
|
||||
@@ -150,10 +228,25 @@ func (s *AiProxyService) forwardClaudeStreamRequest(c *gin.Context, provider *ap
|
||||
if bytes.HasPrefix(line, []byte("data: ")) {
|
||||
data := bytes.TrimPrefix(line, []byte("data: "))
|
||||
var chunk response.ClaudeStreamResponse
|
||||
if json.Unmarshal(data, &chunk) == nil && chunk.Delta != nil {
|
||||
if injector != nil {
|
||||
chunk.Delta.Text = injector.ProcessResponse(chunk.Delta.Text)
|
||||
if json.Unmarshal(data, &chunk) == nil {
|
||||
// 收集 usage 信息
|
||||
if chunk.Usage != nil {
|
||||
totalInputTokens = chunk.Usage.InputTokens
|
||||
totalOutputTokens = chunk.Usage.OutputTokens
|
||||
}
|
||||
|
||||
// 收集文本内容并应用正则处理
|
||||
if chunk.Delta != nil && chunk.Delta.Text != "" {
|
||||
originalText := chunk.Delta.Text
|
||||
fullContent.WriteString(originalText)
|
||||
|
||||
// 应用输出正则处理
|
||||
if injector != nil {
|
||||
chunk.Delta.Text = injector.ProcessResponse(originalText)
|
||||
}
|
||||
}
|
||||
|
||||
// 重新序列化并转发处理后的响应
|
||||
processedData, _ := json.Marshal(chunk)
|
||||
c.Writer.Write([]byte("data: "))
|
||||
c.Writer.Write(processedData)
|
||||
@@ -162,6 +255,50 @@ func (s *AiProxyService) forwardClaudeStreamRequest(c *gin.Context, provider *ap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 流式结束后,对完整内容应用输出正则处理(用于日志记录)
|
||||
processedContent := fullContent.String()
|
||||
if injector != nil && processedContent != "" {
|
||||
processedContent = injector.ProcessResponse(processedContent)
|
||||
}
|
||||
|
||||
// 记录完整的流式响应日志
|
||||
logFields := []zap.Field{
|
||||
zap.String("ai_output_original", fullContent.String()),
|
||||
zap.String("ai_output_processed", processedContent),
|
||||
zap.Int("input_tokens", totalInputTokens),
|
||||
zap.Int("output_tokens", totalOutputTokens),
|
||||
zap.Int("total_tokens", totalInputTokens+totalOutputTokens),
|
||||
}
|
||||
|
||||
// 添加正则脚本执行日志
|
||||
if injector != nil {
|
||||
regexLogs := injector.GetRegexLogs()
|
||||
if regexLogs != nil && (regexLogs.TotalMatches > 0 || len(regexLogs.InputScripts) > 0 || len(regexLogs.OutputScripts) > 0) {
|
||||
// 收集触发的脚本名称
|
||||
triggeredScripts := make([]string, 0)
|
||||
for _, scriptLog := range regexLogs.InputScripts {
|
||||
if scriptLog.MatchCount > 0 {
|
||||
triggeredScripts = append(triggeredScripts, fmt.Sprintf("%s(输入:%d次)", scriptLog.ScriptName, scriptLog.MatchCount))
|
||||
}
|
||||
}
|
||||
for _, scriptLog := range regexLogs.OutputScripts {
|
||||
if scriptLog.MatchCount > 0 {
|
||||
triggeredScripts = append(triggeredScripts, fmt.Sprintf("%s(输出:%d次)", scriptLog.ScriptName, scriptLog.MatchCount))
|
||||
}
|
||||
}
|
||||
|
||||
if len(triggeredScripts) > 0 {
|
||||
logFields = append(logFields,
|
||||
zap.Strings("triggered_regex_scripts", triggeredScripts),
|
||||
zap.Int("total_matches", regexLogs.TotalMatches),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
global.GVA_LOG.Info("Claude Messages 流式响应完成", logFields...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ func (s *AiModelService) DeleteAiModel(id uint, userID uint) error {
|
||||
|
||||
// UpdateAiModel 更新模型
|
||||
func (s *AiModelService) UpdateAiModel(model *app.AiModel, userID uint) error {
|
||||
return global.GVA_DB.Where("user_id = ?", userID).Updates(model).Error
|
||||
// 使用 Select("*") 来更新所有字段,包括零值字段(如 enabled=false)
|
||||
return global.GVA_DB.Model(&app.AiModel{}).Where("id = ? AND user_id = ?", model.ID, userID).Select("*").Updates(model).Error
|
||||
}
|
||||
|
||||
// GetAiModel 查询模型
|
||||
|
||||
@@ -46,7 +46,8 @@ func (s *AiPresetService) GetAiPresetList(info request.PageInfo, userID uint) (l
|
||||
}
|
||||
|
||||
// ParseImportedPreset 解析导入的预设,支持 SillyTavern 格式
|
||||
func (s *AiPresetService) ParseImportedPreset(rawData map[string]interface{}) (*app.AiPreset, error) {
|
||||
// defaultName: 当 JSON 中没有名称时使用的默认名称(通常是文件名)
|
||||
func (s *AiPresetService) ParseImportedPreset(rawData map[string]interface{}, defaultName string) (*app.AiPreset, error) {
|
||||
preset := &app.AiPreset{
|
||||
Enabled: true,
|
||||
}
|
||||
@@ -58,6 +59,9 @@ func (s *AiPresetService) ParseImportedPreset(rawData map[string]interface{}) (*
|
||||
preset.Name = name
|
||||
} else if name, ok := rawData["presetName"].(string); ok && name != "" {
|
||||
preset.Name = name
|
||||
} else if defaultName != "" {
|
||||
// 使用默认名称(文件名)
|
||||
preset.Name = defaultName
|
||||
} else {
|
||||
return nil, fmt.Errorf("预设名称不能为空")
|
||||
}
|
||||
@@ -110,7 +114,8 @@ func (s *AiPresetService) ParseImportedPreset(rawData map[string]interface{}) (*
|
||||
json.Unmarshal(orderData, &preset.PromptOrder)
|
||||
}
|
||||
|
||||
// 处理正则脚本
|
||||
// 处理正则脚本 - 支持两种格式
|
||||
// 格式1: 顶层 regex_scripts
|
||||
if regexScripts, ok := rawData["regex_scripts"].([]interface{}); ok {
|
||||
scriptsData, _ := json.Marshal(regexScripts)
|
||||
json.Unmarshal(scriptsData, &preset.RegexScripts)
|
||||
@@ -118,6 +123,20 @@ func (s *AiPresetService) ParseImportedPreset(rawData map[string]interface{}) (*
|
||||
|
||||
// 处理扩展配置
|
||||
if extensions, ok := rawData["extensions"].(map[string]interface{}); ok {
|
||||
// 格式2: extensions.regex_scripts (SillyTavern 格式)
|
||||
if regexScripts, ok := extensions["regex_scripts"].([]interface{}); ok {
|
||||
scriptsData, _ := json.Marshal(regexScripts)
|
||||
// 同时填充到 RegexScripts 和 Extensions.RegexBinding.Regexes
|
||||
json.Unmarshal(scriptsData, &preset.RegexScripts)
|
||||
|
||||
// 确保 Extensions.RegexBinding 被初始化
|
||||
if preset.Extensions.RegexBinding == nil {
|
||||
preset.Extensions.RegexBinding = &app.RegexBindingConfig{}
|
||||
}
|
||||
json.Unmarshal(scriptsData, &preset.Extensions.RegexBinding.Regexes)
|
||||
}
|
||||
|
||||
// 解析其他扩展配置
|
||||
extData, _ := json.Marshal(extensions)
|
||||
json.Unmarshal(extData, &preset.Extensions)
|
||||
}
|
||||
|
||||
@@ -8,16 +8,29 @@ import (
|
||||
|
||||
"git.echol.cn/loser/ai_proxy/server/model/app"
|
||||
"git.echol.cn/loser/ai_proxy/server/model/app/request"
|
||||
"git.echol.cn/loser/ai_proxy/server/model/app/response"
|
||||
)
|
||||
|
||||
// PresetInjector 预设注入器
|
||||
type PresetInjector struct {
|
||||
preset *app.AiPreset
|
||||
preset *app.AiPreset
|
||||
regexLogs *response.RegexExecutionLogs
|
||||
}
|
||||
|
||||
// NewPresetInjector 创建预设注入器
|
||||
func NewPresetInjector(preset *app.AiPreset) *PresetInjector {
|
||||
return &PresetInjector{preset: preset}
|
||||
return &PresetInjector{
|
||||
preset: preset,
|
||||
regexLogs: &response.RegexExecutionLogs{
|
||||
InputScripts: []response.RegexScriptLog{},
|
||||
OutputScripts: []response.RegexScriptLog{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetRegexLogs 获取正则脚本执行日志
|
||||
func (p *PresetInjector) GetRegexLogs() *response.RegexExecutionLogs {
|
||||
return p.regexLogs
|
||||
}
|
||||
|
||||
// InjectMessages 注入预设到消息列表
|
||||
@@ -116,37 +129,61 @@ func (p *PresetInjector) buildInjectedMessages(messages []request.ChatMessage, p
|
||||
}
|
||||
|
||||
// injectRelativePrompts 将相对位置的提示词注入到对话历史中
|
||||
// 注入深度从前往后计算:depth=0 表示索引0(最前面),depth=1 表示索引1,以此类推
|
||||
func (p *PresetInjector) injectRelativePrompts(messages []request.ChatMessage, prompts []app.PresetPrompt) []request.ChatMessage {
|
||||
if len(prompts) == 0 {
|
||||
return messages
|
||||
}
|
||||
|
||||
result := make([]request.ChatMessage, 0, len(messages)+len(prompts))
|
||||
messageCount := len(messages)
|
||||
|
||||
// 按深度分组提示词
|
||||
// 按深度分组提示词,并按 injection_order 排序
|
||||
depthMap := make(map[int][]app.PresetPrompt)
|
||||
for _, prompt := range prompts {
|
||||
depthMap[prompt.InjectionDepth] = append(depthMap[prompt.InjectionDepth], prompt)
|
||||
}
|
||||
|
||||
// 遍历消息,在指定深度注入提示词
|
||||
for i, msg := range messages {
|
||||
// 计算当前位置的深度(从末尾开始计数)
|
||||
depth := messageCount - i
|
||||
// 对每个深度的提示词按 injection_order 排序(从大到小,优先级高的在前)
|
||||
for depth := range depthMap {
|
||||
sort.Slice(depthMap[depth], func(i, j int) bool {
|
||||
return depthMap[depth][i].InjectionOrder > depthMap[depth][j].InjectionOrder
|
||||
})
|
||||
}
|
||||
|
||||
// 在当前消息之前注入对应深度的提示词
|
||||
result := make([]request.ChatMessage, 0, len(messages)+len(prompts))
|
||||
totalInserted := 0
|
||||
|
||||
// 找出最大深度
|
||||
maxDepth := 0
|
||||
for depth := range depthMap {
|
||||
if depth > maxDepth {
|
||||
maxDepth = depth
|
||||
}
|
||||
}
|
||||
|
||||
// 从 depth=0 开始,逐个深度注入
|
||||
for depth := 0; depth <= maxDepth; depth++ {
|
||||
// 计算实际注入位置(考虑之前已注入的消息数量)
|
||||
injectIdx := depth + totalInserted
|
||||
|
||||
// 如果注入位置超出当前消息列表,先添加原始消息直到该位置
|
||||
for len(result) < injectIdx && len(result)-totalInserted < len(messages) {
|
||||
result = append(result, messages[len(result)-totalInserted])
|
||||
}
|
||||
|
||||
// 注入当前深度的所有提示词
|
||||
if promptsAtDepth, exists := depthMap[depth]; exists {
|
||||
for _, prompt := range promptsAtDepth {
|
||||
result = append(result, request.ChatMessage{
|
||||
Role: prompt.Role,
|
||||
Content: p.processPromptContent(prompt.Content),
|
||||
})
|
||||
totalInserted++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加当前消息
|
||||
result = append(result, msg)
|
||||
// 添加剩余的原始消息
|
||||
for len(result)-totalInserted < len(messages) {
|
||||
result = append(result, messages[len(result)-totalInserted])
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -196,8 +233,8 @@ func (p *PresetInjector) applyRegexScripts(messages []request.ChatMessage, place
|
||||
|
||||
// 检查 placement
|
||||
hasPlacement := false
|
||||
for _, p := range script.Placement {
|
||||
if p == placement {
|
||||
for _, pl := range script.Placement {
|
||||
if pl == placement {
|
||||
hasPlacement = true
|
||||
break
|
||||
}
|
||||
@@ -206,15 +243,34 @@ func (p *PresetInjector) applyRegexScripts(messages []request.ChatMessage, place
|
||||
continue
|
||||
}
|
||||
|
||||
// 应用正则替换
|
||||
messages = p.applyRegexScript(messages, script)
|
||||
// 应用正则替换并记录日志
|
||||
var matchCount int
|
||||
var err error
|
||||
messages, matchCount, err = p.applyRegexScriptWithLog(messages, script)
|
||||
|
||||
// 记录执行日志
|
||||
log := response.RegexScriptLog{
|
||||
ScriptName: script.ScriptName,
|
||||
ScriptID: script.ID,
|
||||
Executed: true,
|
||||
MatchCount: matchCount,
|
||||
}
|
||||
if err != nil {
|
||||
log.ErrorMessage = err.Error()
|
||||
}
|
||||
|
||||
// 根据 placement 添加到对应的日志列表
|
||||
if placement == 1 {
|
||||
p.regexLogs.InputScripts = append(p.regexLogs.InputScripts, log)
|
||||
}
|
||||
p.regexLogs.TotalMatches += matchCount
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// applyRegexScript 应用单个正则脚本
|
||||
func (p *PresetInjector) applyRegexScript(messages []request.ChatMessage, script app.RegexScript) []request.ChatMessage {
|
||||
// applyRegexScriptWithLog 应用单个正则脚本并返回匹配次数
|
||||
func (p *PresetInjector) applyRegexScriptWithLog(messages []request.ChatMessage, script app.RegexScript) ([]request.ChatMessage, int, error) {
|
||||
// 解析正则表达式
|
||||
pattern := script.FindRegex
|
||||
// 移除正则标志(如 /pattern/g)
|
||||
@@ -229,19 +285,25 @@ func (p *PresetInjector) applyRegexScript(messages []request.ChatMessage, script
|
||||
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return messages
|
||||
return messages, 0, fmt.Errorf("正则编译失败: %v", err)
|
||||
}
|
||||
|
||||
matchCount := 0
|
||||
// 对每条消息应用替换
|
||||
for i := range messages {
|
||||
if script.PromptOnly && messages[i].Role != "user" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 统计匹配次数
|
||||
matches := re.FindAllString(messages[i].Content, -1)
|
||||
matchCount += len(matches)
|
||||
|
||||
// 执行替换
|
||||
messages[i].Content = re.ReplaceAllString(messages[i].Content, script.ReplaceString)
|
||||
}
|
||||
|
||||
return messages
|
||||
return messages, matchCount, nil
|
||||
}
|
||||
|
||||
// ProcessResponse 处理AI响应(应用输出后的正则)
|
||||
@@ -280,10 +342,31 @@ func (p *PresetInjector) ProcessResponse(content string) string {
|
||||
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
// 记录错误日志
|
||||
p.regexLogs.OutputScripts = append(p.regexLogs.OutputScripts, response.RegexScriptLog{
|
||||
ScriptName: script.ScriptName,
|
||||
ScriptID: script.ID,
|
||||
Executed: false,
|
||||
ErrorMessage: fmt.Sprintf("正则编译失败: %v", err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 统计匹配次数
|
||||
matches := re.FindAllString(content, -1)
|
||||
matchCount := len(matches)
|
||||
|
||||
// 执行替换
|
||||
content = re.ReplaceAllString(content, script.ReplaceString)
|
||||
|
||||
// 记录执行日志
|
||||
p.regexLogs.OutputScripts = append(p.regexLogs.OutputScripts, response.RegexScriptLog{
|
||||
ScriptName: script.ScriptName,
|
||||
ScriptID: script.ID,
|
||||
Executed: true,
|
||||
MatchCount: matchCount,
|
||||
})
|
||||
p.regexLogs.TotalMatches += matchCount
|
||||
}
|
||||
|
||||
return content
|
||||
|
||||
@@ -23,6 +23,13 @@ type AiProxyService struct{}
|
||||
|
||||
// ProcessChatCompletion 处理聊天补全请求
|
||||
func (s *AiProxyService) ProcessChatCompletion(ctx context.Context, req *request.ChatCompletionRequest) (*response.ChatCompletionResponse, error) {
|
||||
// 记录请求参数
|
||||
global.GVA_LOG.Info("收到 ChatCompletion 请求",
|
||||
zap.String("model", req.Model),
|
||||
zap.Any("messages", req.Messages),
|
||||
zap.Any("full_request", req),
|
||||
)
|
||||
|
||||
// 1. 根据模型获取配置
|
||||
if req.Model == "" {
|
||||
return nil, fmt.Errorf("model 参数不能为空")
|
||||
@@ -34,8 +41,9 @@ func (s *AiProxyService) ProcessChatCompletion(ctx context.Context, req *request
|
||||
}
|
||||
|
||||
// 2. 注入预设
|
||||
var injector *PresetInjector
|
||||
if preset != nil {
|
||||
injector := NewPresetInjector(preset)
|
||||
injector = NewPresetInjector(preset)
|
||||
req.Messages = injector.InjectMessages(req.Messages)
|
||||
injector.ApplyPresetParameters(req)
|
||||
}
|
||||
@@ -46,17 +54,82 @@ func (s *AiProxyService) ProcessChatCompletion(ctx context.Context, req *request
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. 处理响应
|
||||
if preset != nil && len(resp.Choices) > 0 {
|
||||
injector := NewPresetInjector(preset)
|
||||
resp.Choices[0].Message.Content = injector.ProcessResponse(resp.Choices[0].Message.Content)
|
||||
// 获取 AI 输出内容
|
||||
aiOutput := ""
|
||||
if len(resp.Choices) > 0 {
|
||||
aiOutput = resp.Choices[0].Message.Content
|
||||
}
|
||||
|
||||
// 应用预设处理(使用同一个 injector 实例)
|
||||
if injector != nil && len(resp.Choices) > 0 {
|
||||
resp.Choices[0].Message.Content = injector.ProcessResponse(resp.Choices[0].Message.Content)
|
||||
aiOutput = resp.Choices[0].Message.Content
|
||||
}
|
||||
|
||||
// 4. 处理响应并收集正则日志
|
||||
if resp != nil && resp.Usage != nil {
|
||||
// 统一填充 standard_usage,方便上游使用统一格式解析
|
||||
if resp.Usage.PromptTokens > 0 || resp.Usage.CompletionTokens > 0 || resp.Usage.TotalTokens > 0 {
|
||||
resp.StandardUsage = &response.ChatCompletionUsage{
|
||||
PromptTokens: resp.Usage.PromptTokens,
|
||||
CompletionTokens: resp.Usage.CompletionTokens,
|
||||
TotalTokens: resp.Usage.TotalTokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 记录响应内容(统一日志输出)
|
||||
logFields := []zap.Field{
|
||||
zap.String("ai_output", aiOutput),
|
||||
}
|
||||
if resp.Usage != nil {
|
||||
logFields = append(logFields, zap.Any("usage", resp.Usage))
|
||||
}
|
||||
if resp.StandardUsage != nil {
|
||||
logFields = append(logFields, zap.Any("standard_usage", resp.StandardUsage))
|
||||
}
|
||||
|
||||
// 添加正则脚本执行日志
|
||||
if injector != nil {
|
||||
regexLogs := injector.GetRegexLogs()
|
||||
if regexLogs != nil && (regexLogs.TotalMatches > 0 || len(regexLogs.InputScripts) > 0 || len(regexLogs.OutputScripts) > 0) {
|
||||
// 收集触发的脚本名称
|
||||
triggeredScripts := make([]string, 0)
|
||||
for _, scriptLog := range regexLogs.InputScripts {
|
||||
if scriptLog.MatchCount > 0 {
|
||||
triggeredScripts = append(triggeredScripts, fmt.Sprintf("%s(输入:%d次)", scriptLog.ScriptName, scriptLog.MatchCount))
|
||||
}
|
||||
}
|
||||
for _, scriptLog := range regexLogs.OutputScripts {
|
||||
if scriptLog.MatchCount > 0 {
|
||||
triggeredScripts = append(triggeredScripts, fmt.Sprintf("%s(输出:%d次)", scriptLog.ScriptName, scriptLog.MatchCount))
|
||||
}
|
||||
}
|
||||
|
||||
if len(triggeredScripts) > 0 {
|
||||
logFields = append(logFields,
|
||||
zap.Strings("triggered_regex_scripts", triggeredScripts),
|
||||
zap.Int("total_matches", regexLogs.TotalMatches),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logFields = append(logFields, zap.Any("full_response", resp))
|
||||
global.GVA_LOG.Info("ChatCompletion 响应", logFields...)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ProcessChatCompletionStream 处理流式聊天补全请求
|
||||
func (s *AiProxyService) ProcessChatCompletionStream(c *gin.Context, req *request.ChatCompletionRequest) {
|
||||
// 记录请求参数
|
||||
global.GVA_LOG.Info("收到 ChatCompletion 流式请求",
|
||||
zap.String("model", req.Model),
|
||||
zap.Any("messages", req.Messages),
|
||||
zap.Any("full_request", req),
|
||||
)
|
||||
|
||||
// 1. 根据模型获取配置
|
||||
if req.Model == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "model 参数不能为空"})
|
||||
@@ -123,6 +196,13 @@ func (s *AiProxyService) forwardRequest(ctx context.Context, provider *app.AiPro
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 上游请求参数
|
||||
global.GVA_LOG.Info("转发 ChatCompletion 请求到上游",
|
||||
zap.String("provider", provider.Name),
|
||||
zap.String("model", req.Model),
|
||||
zap.Any("messages", req.Messages),
|
||||
)
|
||||
|
||||
url := strings.TrimRight(provider.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
@@ -165,6 +245,12 @@ func (s *AiProxyService) forwardStreamRequest(c *gin.Context, provider *app.AiPr
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 上游请求参数
|
||||
global.GVA_LOG.Info("转发 ChatCompletion 流式请求到上游",
|
||||
zap.String("provider", provider.Name),
|
||||
zap.String("model", req.Model),
|
||||
zap.Any("messages", req.Messages),
|
||||
)
|
||||
|
||||
url := strings.TrimRight(provider.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(c.Request.Context(), "POST", url, bytes.NewReader(reqBody))
|
||||
@@ -194,6 +280,9 @@ func (s *AiProxyService) forwardStreamRequest(c *gin.Context, provider *app.AiPr
|
||||
return fmt.Errorf("不支持流式响应")
|
||||
}
|
||||
|
||||
// 聚合 AI 输出内容用于日志和正则处理
|
||||
var fullContent bytes.Buffer
|
||||
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
@@ -226,12 +315,18 @@ func (s *AiProxyService) forwardStreamRequest(c *gin.Context, provider *app.AiPr
|
||||
continue
|
||||
}
|
||||
|
||||
// 应用输出正则处理
|
||||
if injector != nil && len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
||||
chunk.Choices[0].Delta.Content = injector.ProcessResponse(chunk.Choices[0].Delta.Content)
|
||||
// 收集原始内容并应用正则处理
|
||||
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
||||
originalContent := chunk.Choices[0].Delta.Content
|
||||
fullContent.WriteString(originalContent)
|
||||
|
||||
// 应用输出正则处理
|
||||
if injector != nil {
|
||||
chunk.Choices[0].Delta.Content = injector.ProcessResponse(originalContent)
|
||||
}
|
||||
}
|
||||
|
||||
// 重新序列化并发送
|
||||
// 重新序列化并转发处理后的响应
|
||||
processedData, _ := json.Marshal(chunk)
|
||||
c.Writer.Write([]byte("data: "))
|
||||
c.Writer.Write(processedData)
|
||||
@@ -240,6 +335,46 @@ func (s *AiProxyService) forwardStreamRequest(c *gin.Context, provider *app.AiPr
|
||||
}
|
||||
}
|
||||
|
||||
// 流式结束后,对完整内容应用输出正则处理(仅用于日志记录)
|
||||
processedContent := fullContent.String()
|
||||
if injector != nil && processedContent != "" {
|
||||
processedContent = injector.ProcessResponse(processedContent)
|
||||
}
|
||||
|
||||
// 流式请求结束后记录日志
|
||||
logFields := []zap.Field{
|
||||
zap.String("ai_output_original", fullContent.String()),
|
||||
zap.String("ai_output_processed", processedContent),
|
||||
}
|
||||
|
||||
// 添加正则脚本执行日志
|
||||
if injector != nil {
|
||||
regexLogs := injector.GetRegexLogs()
|
||||
if regexLogs != nil && (regexLogs.TotalMatches > 0 || len(regexLogs.InputScripts) > 0 || len(regexLogs.OutputScripts) > 0) {
|
||||
// 收集触发的脚本名称
|
||||
triggeredScripts := make([]string, 0)
|
||||
for _, scriptLog := range regexLogs.InputScripts {
|
||||
if scriptLog.MatchCount > 0 {
|
||||
triggeredScripts = append(triggeredScripts, fmt.Sprintf("%s(输入:%d次)", scriptLog.ScriptName, scriptLog.MatchCount))
|
||||
}
|
||||
}
|
||||
for _, scriptLog := range regexLogs.OutputScripts {
|
||||
if scriptLog.MatchCount > 0 {
|
||||
triggeredScripts = append(triggeredScripts, fmt.Sprintf("%s(输出:%d次)", scriptLog.ScriptName, scriptLog.MatchCount))
|
||||
}
|
||||
}
|
||||
|
||||
if len(triggeredScripts) > 0 {
|
||||
logFields = append(logFields,
|
||||
zap.Strings("triggered_regex_scripts", triggeredScripts),
|
||||
zap.Int("total_matches", regexLogs.TotalMatches),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
global.GVA_LOG.Info("ChatCompletion 流式响应完成", logFields...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"/src/view/ai/binding/index.vue": "Index",
|
||||
"/src/view/ai/model/index.vue": "Index",
|
||||
"/src/view/ai/preset/components/PromptEditor.vue": "PromptEditor",
|
||||
"/src/view/ai/preset/components/RegexEditor.vue": "RegexEditor",
|
||||
"/src/view/ai/preset/index.vue": "Index",
|
||||
"/src/view/ai/provider/index.vue": "Index",
|
||||
"/src/view/dashboard/components/banner.vue": "Banner",
|
||||
|
||||
526
web/src/view/ai/preset/components/RegexEditor.vue
Normal file
526
web/src/view/ai/preset/components/RegexEditor.vue
Normal file
@@ -0,0 +1,526 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="正则脚本编辑器"
|
||||
width="1200px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="regex-editor">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="addScript">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加脚本
|
||||
</el-button>
|
||||
<el-button type="success" size="small" @click="importScripts">
|
||||
<el-icon><Upload /></el-icon>
|
||||
导入脚本
|
||||
</el-button>
|
||||
<el-button type="warning" size="small" @click="testRegex" :disabled="!currentScript">
|
||||
<el-icon><View /></el-icon>
|
||||
测试当前脚本
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="editor-content">
|
||||
<div class="script-list">
|
||||
<div class="list-header">
|
||||
<span>脚本列表 ({{ scripts.length }})</span>
|
||||
</div>
|
||||
<el-scrollbar height="500px">
|
||||
<div
|
||||
v-for="(script, index) in scripts"
|
||||
:key="script.id || index"
|
||||
:class="['script-item', { active: currentIndex === index, disabled: script.disabled }]"
|
||||
@click="selectScript(index)"
|
||||
>
|
||||
<div class="script-info">
|
||||
<div class="script-name">
|
||||
<el-icon v-if="script.disabled"><CircleClose /></el-icon>
|
||||
<el-icon v-else><CircleCheck /></el-icon>
|
||||
{{ script.scriptName || '未命名脚本' }}
|
||||
</div>
|
||||
<div class="script-placement">
|
||||
<el-tag v-if="script.placement?.includes(1)" size="small" type="info">输入前</el-tag>
|
||||
<el-tag v-if="script.placement?.includes(2)" size="small" type="success">输出后</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="script-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click.stop="duplicateScript(index)"
|
||||
>
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
@click.stop="deleteScript(index)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<div class="script-editor" v-if="currentScript">
|
||||
<el-form :model="currentScript" label-width="120px" label-position="left">
|
||||
<el-form-item label="脚本名称">
|
||||
<el-input v-model="currentScript.scriptName" placeholder="请输入脚本名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="正则表达式">
|
||||
<el-input
|
||||
v-model="currentScript.findRegex"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入正则表达式,如: /<pattern>/flags"
|
||||
/>
|
||||
<div class="hint">支持 JavaScript 正则表达式格式,如: /pattern/gi</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="替换字符串">
|
||||
<el-input
|
||||
v-model="currentScript.replaceString"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="替换内容,支持 $1, $2 等捕获组引用"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="执行位置">
|
||||
<el-checkbox-group v-model="currentScript.placement">
|
||||
<el-checkbox :value="1">用户输入前处理</el-checkbox>
|
||||
<el-checkbox :value="2">AI输出后处理</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="应用深度">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="12">
|
||||
<el-input-number
|
||||
v-model="currentScript.minDepth"
|
||||
placeholder="最小深度"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="hint">最小深度 (null=不限制)</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-input-number
|
||||
v-model="currentScript.maxDepth"
|
||||
placeholder="最大深度"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="hint">最大深度 (null=不限制)</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选项">
|
||||
<el-checkbox v-model="currentScript.disabled">禁用此脚本</el-checkbox>
|
||||
<el-checkbox v-model="currentScript.markdownOnly">仅处理 Markdown</el-checkbox>
|
||||
<el-checkbox v-model="currentScript.promptOnly">仅处理提示词</el-checkbox>
|
||||
<el-checkbox v-model="currentScript.runOnEdit">编辑时运行</el-checkbox>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="修剪字符串">
|
||||
<el-select
|
||||
v-model="currentScript.trimStrings"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
placeholder="输入要修剪的字符串"
|
||||
style="width: 100%"
|
||||
>
|
||||
</el-select>
|
||||
<div class="hint">在应用正则前要删除的字符串列表</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<el-empty description="请选择或创建一个脚本" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 测试对话框 -->
|
||||
<el-dialog v-model="testDialogVisible" title="测试正则脚本" width="800px" append-to-body>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="测试文本">
|
||||
<el-input
|
||||
v-model="testInput"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="输入要测试的文本"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="匹配结果">
|
||||
<el-input
|
||||
v-model="testOutput"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
readonly
|
||||
placeholder="替换后的结果将显示在这里"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="testDialogVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="runTest">执行测试</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Plus,
|
||||
Upload,
|
||||
View,
|
||||
Delete,
|
||||
CopyDocument,
|
||||
CircleCheck,
|
||||
CircleClose
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
regexScripts: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save'])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const scripts = ref([])
|
||||
const currentIndex = ref(-1)
|
||||
const testDialogVisible = ref(false)
|
||||
const testInput = ref('')
|
||||
const testOutput = ref('')
|
||||
|
||||
const currentScript = computed(() => {
|
||||
if (currentIndex.value >= 0 && currentIndex.value < scripts.value.length) {
|
||||
return scripts.value[currentIndex.value]
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) {
|
||||
scripts.value = JSON.parse(JSON.stringify(props.regexScripts || []))
|
||||
if (scripts.value.length > 0) {
|
||||
currentIndex.value = 0
|
||||
} else {
|
||||
currentIndex.value = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const generateId = () => {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
const addScript = () => {
|
||||
const newScript = {
|
||||
id: generateId(),
|
||||
scriptName: '新建脚本',
|
||||
findRegex: '',
|
||||
replaceString: '',
|
||||
trimStrings: [],
|
||||
placement: [2],
|
||||
disabled: false,
|
||||
markdownOnly: false,
|
||||
promptOnly: false,
|
||||
runOnEdit: false,
|
||||
substituteRegex: 0,
|
||||
minDepth: null,
|
||||
maxDepth: null
|
||||
}
|
||||
scripts.value.push(newScript)
|
||||
currentIndex.value = scripts.value.length - 1
|
||||
ElMessage.success('已添加新脚本')
|
||||
}
|
||||
|
||||
const selectScript = (index) => {
|
||||
currentIndex.value = index
|
||||
}
|
||||
|
||||
const duplicateScript = (index) => {
|
||||
const original = scripts.value[index]
|
||||
const duplicate = {
|
||||
...JSON.parse(JSON.stringify(original)),
|
||||
id: generateId(),
|
||||
scriptName: original.scriptName + ' (副本)'
|
||||
}
|
||||
scripts.value.splice(index + 1, 0, duplicate)
|
||||
currentIndex.value = index + 1
|
||||
ElMessage.success('已复制脚本')
|
||||
}
|
||||
|
||||
const deleteScript = (index) => {
|
||||
ElMessageBox.confirm('确定要删除这个脚本吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
scripts.value.splice(index, 1)
|
||||
if (currentIndex.value >= scripts.value.length) {
|
||||
currentIndex.value = scripts.value.length - 1
|
||||
}
|
||||
ElMessage.success('已删除脚本')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const importScripts = () => {
|
||||
ElMessageBox.prompt('请粘贴正则脚本 JSON 数组', '导入脚本', {
|
||||
confirmButtonText: '导入',
|
||||
cancelButtonText: '取消',
|
||||
inputType: 'textarea'
|
||||
}).then(({ value }) => {
|
||||
try {
|
||||
const imported = JSON.parse(value)
|
||||
if (Array.isArray(imported)) {
|
||||
imported.forEach(script => {
|
||||
if (!script.id) {
|
||||
script.id = generateId()
|
||||
}
|
||||
})
|
||||
scripts.value.push(...imported)
|
||||
ElMessage.success(`成功导入 ${imported.length} 个脚本`)
|
||||
} else {
|
||||
ElMessage.error('导入的数据必须是数组格式')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('JSON 解析失败: ' + error.message)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const testRegex = () => {
|
||||
if (!currentScript.value) {
|
||||
ElMessage.warning('请先选择一个脚本')
|
||||
return
|
||||
}
|
||||
testInput.value = ''
|
||||
testOutput.value = ''
|
||||
testDialogVisible.value = true
|
||||
}
|
||||
|
||||
const runTest = () => {
|
||||
if (!testInput.value) {
|
||||
ElMessage.warning('请输入测试文本')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let text = testInput.value
|
||||
|
||||
// 应用 trimStrings
|
||||
if (currentScript.value.trimStrings && currentScript.value.trimStrings.length > 0) {
|
||||
currentScript.value.trimStrings.forEach(trim => {
|
||||
text = text.split(trim).join('')
|
||||
})
|
||||
}
|
||||
|
||||
// 解析正则表达式
|
||||
let regex
|
||||
const regexStr = currentScript.value.findRegex
|
||||
|
||||
// 尝试解析 /pattern/flags 格式
|
||||
const match = regexStr.match(/^\/(.+)\/([gimsuvy]*)$/)
|
||||
if (match) {
|
||||
regex = new RegExp(match[1], match[2])
|
||||
} else {
|
||||
// 直接作为正则表达式
|
||||
regex = new RegExp(regexStr, 'g')
|
||||
}
|
||||
|
||||
// 执行替换
|
||||
testOutput.value = text.replace(regex, currentScript.value.replaceString || '')
|
||||
|
||||
ElMessage.success('测试完成')
|
||||
} catch (error) {
|
||||
ElMessage.error('正则表达式错误: ' + error.message)
|
||||
testOutput.value = '错误: ' + error.message
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
// 验证脚本
|
||||
for (let i = 0; i < scripts.value.length; i++) {
|
||||
const script = scripts.value[i]
|
||||
if (!script.scriptName) {
|
||||
ElMessage.warning(`第 ${i + 1} 个脚本缺少名称`)
|
||||
currentIndex.value = i
|
||||
return
|
||||
}
|
||||
if (!script.findRegex) {
|
||||
ElMessage.warning(`脚本 "${script.scriptName}" 缺少正则表达式`)
|
||||
currentIndex.value = i
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
emit('save', scripts.value)
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.regex-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
height: 550px;
|
||||
}
|
||||
|
||||
.script-list {
|
||||
width: 280px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
padding: 10px 15px;
|
||||
background: #f5f7fa;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.script-item {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.script-item:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.script-item.active {
|
||||
background: #ecf5ff;
|
||||
border-left: 3px solid #409eff;
|
||||
}
|
||||
|
||||
.script-item.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.script-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.script-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.script-placement {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.script-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.script-item:hover .script-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.script-editor {
|
||||
flex: 1;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed #dcdfe6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -185,6 +185,13 @@
|
||||
@save="handlePromptSave"
|
||||
/>
|
||||
|
||||
<!-- 正则脚本编辑器 -->
|
||||
<RegexEditor
|
||||
v-model="regexEditorVisible"
|
||||
:regex-scripts="formData.regex_scripts"
|
||||
@save="handleRegexSave"
|
||||
/>
|
||||
|
||||
<!-- 查看预设对话框 -->
|
||||
<el-dialog v-model="viewDialogVisible" title="预设详情" width="900px">
|
||||
<el-descriptions :column="2" border>
|
||||
@@ -253,6 +260,7 @@ import {
|
||||
importAiPresetFile
|
||||
} from '@/api/aiPreset'
|
||||
import PromptEditor from './components/PromptEditor.vue'
|
||||
import RegexEditor from './components/RegexEditor.vue'
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
@@ -271,6 +279,7 @@ const importTabActive = ref('file')
|
||||
const uploadRef = ref(null)
|
||||
const uploadFile = ref(null)
|
||||
const promptEditorVisible = ref(false)
|
||||
const regexEditorVisible = ref(false)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
@@ -468,7 +477,12 @@ const handlePromptSave = (prompts) => {
|
||||
}
|
||||
|
||||
const openRegexEditor = () => {
|
||||
ElMessage.info('正则脚本编辑器功能开发中...')
|
||||
regexEditorVisible.value = true
|
||||
}
|
||||
|
||||
const handleRegexSave = (regexScripts) => {
|
||||
formData.value.regex_scripts = regexScripts
|
||||
ElMessage.success('正则脚本已更新')
|
||||
}
|
||||
|
||||
getTableData()
|
||||
|
||||
@@ -76,9 +76,8 @@ export default ({ mode }) => {
|
||||
[process.env.VITE_BASE_API]: {
|
||||
// 需要代理的路径 例如 '/api'
|
||||
target: `${process.env.VITE_BASE_PATH}:${process.env.VITE_SERVER_PORT}/`, // 代理到 目标路径
|
||||
changeOrigin: true,
|
||||
rewrite: (path) =>
|
||||
path.replace(new RegExp('^' + process.env.VITE_BASE_API), '')
|
||||
changeOrigin: true
|
||||
// 不需要 rewrite,保留 /api 前缀
|
||||
},
|
||||
"/plugin": {
|
||||
// 需要代理的路径 例如 '/api'
|
||||
|
||||
Reference in New Issue
Block a user