406 lines
14 KiB
Go
406 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"infinite-canvas/backend/internal/model"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
promptCustomizationInherit = "inherit"
|
|
promptCustomizationAppend = "append"
|
|
promptCustomizationRewrite = "rewrite"
|
|
)
|
|
|
|
var promptPlaceholderPattern = regexp.MustCompile(`\{\{[^{}]+\}\}`)
|
|
|
|
type PromptTemplateVariable struct {
|
|
Label string `json:"label"`
|
|
Placeholder string `json:"placeholder"`
|
|
}
|
|
|
|
type PromptOperationDefinition struct {
|
|
Operation string `json:"operation"`
|
|
Label string `json:"label"`
|
|
Category string `json:"category"`
|
|
Description string `json:"description"`
|
|
OutputType string `json:"outputType"`
|
|
SchemaKey string `json:"schemaKey,omitempty"`
|
|
Variables []PromptTemplateVariable `json:"variables"`
|
|
OutputContract string `json:"outputContract"`
|
|
DefaultContent string `json:"-"`
|
|
}
|
|
|
|
type PromptTemplateRequest struct {
|
|
Operation string `json:"operation"`
|
|
Name string `json:"name"`
|
|
Content string `json:"content"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
|
|
type UserPromptCustomizationRequest struct {
|
|
Mode string `json:"mode"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type UserPromptPreference struct {
|
|
Definition PromptOperationDefinition `json:"definition"`
|
|
Template *model.PromptTemplate `json:"template"`
|
|
Customization *model.UserPromptCustomization `json:"customization,omitempty"`
|
|
Outdated bool `json:"outdated"`
|
|
}
|
|
|
|
type CompiledPrompt struct {
|
|
Content string
|
|
TemplateID string
|
|
TemplateVersion int
|
|
CustomizationID string
|
|
CustomizationUpdated string
|
|
}
|
|
|
|
func promptDefinitions() []PromptOperationDefinition {
|
|
definitions := defaultPromptDefinitions()
|
|
for index := range definitions {
|
|
definitions[index].OutputContract = promptOutputContract(definitions[index].Operation)
|
|
}
|
|
return definitions
|
|
}
|
|
|
|
func promptDefinition(operation string) (PromptOperationDefinition, bool) {
|
|
for _, definition := range promptDefinitions() {
|
|
if definition.Operation == operation {
|
|
return definition, true
|
|
}
|
|
}
|
|
return PromptOperationDefinition{}, false
|
|
}
|
|
|
|
func (s *Service) EnsureDefaultPromptTemplates() error {
|
|
for _, definition := range promptDefinitions() {
|
|
count, err := s.repo.PromptTemplateCount(definition.Operation)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
continue
|
|
}
|
|
if err := s.repo.SavePromptTemplate(&model.PromptTemplate{
|
|
ID: newID(), Operation: definition.Operation, Name: "默认" + definition.Label + "模板", Version: 1,
|
|
Content: definition.DefaultContent, OutputType: definition.OutputType, Enabled: true,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) AdminPromptTemplates(actor *model.User) ([]model.PromptTemplate, []PromptOperationDefinition, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
templates, err := s.repo.PromptTemplates()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return templates, promptDefinitions(), nil
|
|
}
|
|
|
|
func (s *Service) CreatePromptTemplate(actor *model.User, req PromptTemplateRequest) (*model.PromptTemplate, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
definition, ok := promptDefinition(strings.TrimSpace(req.Operation))
|
|
if !ok {
|
|
return nil, BadAuthRequest("不支持的提示词模板类型")
|
|
}
|
|
name, content, err := validatePromptTemplateContent(definition, req.Name, req.Content)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
version, err := s.repo.NextPromptTemplateVersion(definition.Operation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
template := &model.PromptTemplate{
|
|
ID: newID(), Operation: definition.Operation, Name: name, Version: version, Content: content,
|
|
OutputType: definition.OutputType, Enabled: req.Enabled != nil && *req.Enabled, CreatedBy: actor.ID,
|
|
}
|
|
if err := s.repo.SavePromptTemplate(template); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.appendAdminAudit(actor, "prompt_template.create", "prompt_template", template.ID, "创建提示词模板版本", map[string]any{"operation": template.Operation, "version": template.Version, "enabled": template.Enabled}); err != nil {
|
|
return nil, err
|
|
}
|
|
return template, nil
|
|
}
|
|
|
|
func (s *Service) UpdatePromptTemplate(actor *model.User, id string, req PromptTemplateRequest) (*model.PromptTemplate, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
template, err := s.repo.PromptTemplate(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
definition, ok := promptDefinition(template.Operation)
|
|
if !ok {
|
|
return nil, BadAuthRequest("提示词模板类型已经失效")
|
|
}
|
|
if template.Enabled && (strings.TrimSpace(req.Content) != strings.TrimSpace(template.Content) || strings.TrimSpace(req.Name) != template.Name) {
|
|
return nil, BadAuthRequest("启用中的版本不可直接修改,请基于它新建版本")
|
|
}
|
|
if req.Enabled != nil && !*req.Enabled && template.Enabled {
|
|
return nil, BadAuthRequest("启用中的版本不能直接停用,请先启用同类型的其他版本")
|
|
}
|
|
name, content, err := validatePromptTemplateContent(definition, req.Name, req.Content)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
template.Name = name
|
|
template.Content = content
|
|
if req.Enabled != nil {
|
|
template.Enabled = *req.Enabled
|
|
}
|
|
if err := s.repo.SavePromptTemplate(template); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.appendAdminAudit(actor, "prompt_template.update", "prompt_template", template.ID, "更新提示词模板版本", map[string]any{"operation": template.Operation, "version": template.Version, "enabled": template.Enabled}); err != nil {
|
|
return nil, err
|
|
}
|
|
return template, nil
|
|
}
|
|
|
|
func (s *Service) DeletePromptTemplate(actor *model.User, id string) error {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return err
|
|
}
|
|
template, err := s.repo.PromptTemplate(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if template.Enabled {
|
|
return BadAuthRequest("启用中的版本不能删除,请先启用同类型的其他版本")
|
|
}
|
|
if err := s.repo.DeletePromptTemplate(id); err != nil {
|
|
return err
|
|
}
|
|
return s.appendAdminAudit(actor, "prompt_template.delete", "prompt_template", template.ID, "删除提示词模板版本", map[string]any{"operation": template.Operation, "version": template.Version})
|
|
}
|
|
|
|
func (s *Service) UserPromptPreferences(user *model.User) ([]UserPromptPreference, error) {
|
|
if user == nil || user.ID == "" {
|
|
return nil, BadAuthRequest("请先登录")
|
|
}
|
|
customizations, err := s.repo.UserPromptCustomizations(user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
customizationByOperation := make(map[string]*model.UserPromptCustomization, len(customizations))
|
|
for index := range customizations {
|
|
customization := &customizations[index]
|
|
customizationByOperation[customization.Operation] = customization
|
|
}
|
|
preferences := make([]UserPromptPreference, 0)
|
|
for _, definition := range promptDefinitions() {
|
|
template, err := s.repo.ActivePromptTemplate(definition.Operation)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
customization := customizationByOperation[definition.Operation]
|
|
preference := UserPromptPreference{Definition: definition, Template: template, Customization: customization}
|
|
preference.Outdated = customization != nil && customization.Mode == promptCustomizationRewrite && template != nil && customization.BaseTemplateID != template.ID
|
|
preferences = append(preferences, preference)
|
|
}
|
|
return preferences, nil
|
|
}
|
|
|
|
func (s *Service) UpdateUserPromptCustomization(user *model.User, operation string, req UserPromptCustomizationRequest) (*model.UserPromptCustomization, error) {
|
|
if user == nil || user.ID == "" {
|
|
return nil, BadAuthRequest("请先登录")
|
|
}
|
|
definition, ok := promptDefinition(strings.TrimSpace(operation))
|
|
if !ok {
|
|
return nil, BadAuthRequest("不支持的提示词模板类型")
|
|
}
|
|
mode := strings.TrimSpace(req.Mode)
|
|
if mode != promptCustomizationInherit && mode != promptCustomizationAppend && mode != promptCustomizationRewrite {
|
|
return nil, BadAuthRequest("不支持的提示词定制方式")
|
|
}
|
|
content := strings.TrimSpace(req.Content)
|
|
if mode == promptCustomizationInherit {
|
|
content = ""
|
|
}
|
|
if mode != promptCustomizationInherit && content == "" {
|
|
return nil, BadAuthRequest("请填写个人提示词要求")
|
|
}
|
|
if len([]rune(content)) > 12_000 {
|
|
return nil, BadAuthRequest("个人提示词最多 12000 个字符")
|
|
}
|
|
if err := validatePromptPlaceholders(definition, content); err != nil {
|
|
return nil, err
|
|
}
|
|
active, err := s.repo.ActivePromptTemplate(definition.Operation)
|
|
if err != nil {
|
|
return nil, errors.New("当前模板类型没有启用版本")
|
|
}
|
|
customization, err := s.repo.UserPromptCustomization(user.ID, definition.Operation)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
customization = &model.UserPromptCustomization{ID: newID(), UserID: user.ID, Operation: definition.Operation}
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
customization.Mode = mode
|
|
customization.Content = content
|
|
customization.BaseTemplateID = active.ID
|
|
if err := s.repo.SaveUserPromptCustomization(customization); err != nil {
|
|
return nil, err
|
|
}
|
|
return customization, nil
|
|
}
|
|
|
|
func (s *Service) ResetUserPromptCustomization(user *model.User, operation string) error {
|
|
if user == nil || user.ID == "" {
|
|
return BadAuthRequest("请先登录")
|
|
}
|
|
if _, ok := promptDefinition(strings.TrimSpace(operation)); !ok {
|
|
return BadAuthRequest("不支持的提示词模板类型")
|
|
}
|
|
return s.repo.DeleteUserPromptCustomization(user.ID, operation)
|
|
}
|
|
|
|
func (s *Service) compilePrompt(userID string, operation string, values map[string]string) (CompiledPrompt, error) {
|
|
definition, ok := promptDefinition(operation)
|
|
if !ok {
|
|
return CompiledPrompt{}, fmt.Errorf("不支持的提示词模板类型:%s", operation)
|
|
}
|
|
template, err := s.repo.ActivePromptTemplate(operation)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
template = &model.PromptTemplate{Operation: operation, Name: "内置默认", Version: 1, Content: definition.DefaultContent, OutputType: definition.OutputType}
|
|
} else if err != nil {
|
|
return CompiledPrompt{}, err
|
|
}
|
|
creative := template.Content
|
|
compiled := CompiledPrompt{TemplateID: template.ID, TemplateVersion: template.Version}
|
|
customization, customizationErr := s.repo.UserPromptCustomization(userID, operation)
|
|
if customizationErr == nil {
|
|
compiled.CustomizationID = customization.ID
|
|
compiled.CustomizationUpdated = customization.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")
|
|
switch customization.Mode {
|
|
case promptCustomizationAppend:
|
|
creative += "\n\n【用户个性化创作要求】\n" + customization.Content
|
|
case promptCustomizationRewrite:
|
|
creative = customization.Content
|
|
}
|
|
} else if !errors.Is(customizationErr, gorm.ErrRecordNotFound) {
|
|
return CompiledPrompt{}, customizationErr
|
|
}
|
|
rendered, err := renderPromptTemplate(definition, creative, values)
|
|
if err != nil {
|
|
return CompiledPrompt{}, err
|
|
}
|
|
parts := []string{strings.TrimSpace(rendered)}
|
|
if protected := strings.TrimSpace(protectedPromptContext(operation, values)); protected != "" {
|
|
parts = append(parts, protected)
|
|
}
|
|
compiled.Content = strings.Join(parts, "\n\n")
|
|
return compiled, nil
|
|
}
|
|
|
|
func validatePromptTemplateContent(definition PromptOperationDefinition, name string, content string) (string, string, error) {
|
|
name = strings.TrimSpace(name)
|
|
content = strings.TrimSpace(content)
|
|
if name == "" {
|
|
return "", "", BadAuthRequest("请填写版本名称")
|
|
}
|
|
if content == "" {
|
|
return "", "", BadAuthRequest("请填写提示词模板")
|
|
}
|
|
if len([]rune(content)) > 30_000 {
|
|
return "", "", BadAuthRequest("提示词模板最多 30000 个字符")
|
|
}
|
|
if err := validatePromptPlaceholders(definition, content); err != nil {
|
|
return "", "", err
|
|
}
|
|
return name, content, nil
|
|
}
|
|
|
|
func validatePromptPlaceholders(definition PromptOperationDefinition, content string) error {
|
|
allowed := make(map[string]bool, len(definition.Variables))
|
|
for _, variable := range definition.Variables {
|
|
allowed[variable.Placeholder] = true
|
|
}
|
|
unknown := make([]string, 0)
|
|
for _, placeholder := range promptPlaceholderPattern.FindAllString(content, -1) {
|
|
if !allowed[placeholder] {
|
|
unknown = append(unknown, placeholder)
|
|
}
|
|
}
|
|
if len(unknown) == 0 {
|
|
return nil
|
|
}
|
|
sort.Strings(unknown)
|
|
return BadAuthRequest("模板包含不支持的变量:" + strings.Join(uniqueStrings(unknown), "、"))
|
|
}
|
|
|
|
func renderPromptTemplate(definition PromptOperationDefinition, content string, values map[string]string) (string, error) {
|
|
if err := validatePromptPlaceholders(definition, content); err != nil {
|
|
return "", err
|
|
}
|
|
rendered := content
|
|
for _, variable := range definition.Variables {
|
|
key := strings.TrimSuffix(strings.TrimPrefix(variable.Placeholder, "{{"), "}}")
|
|
rendered = strings.ReplaceAll(rendered, variable.Placeholder, strings.TrimSpace(values[key]))
|
|
}
|
|
return strings.TrimSpace(rendered), nil
|
|
}
|
|
|
|
func validatePromptTemplateResult(operation string, result map[string]interface{}) error {
|
|
definition, ok := promptDefinition(operation)
|
|
if !ok || definition.OutputType != "json" {
|
|
return nil
|
|
}
|
|
text, _ := result["text"].(string)
|
|
jsonText, err := extractJSONText(text)
|
|
if err != nil {
|
|
return fmt.Errorf("%s 返回内容不符合受保护 JSON 契约:%w", definition.Label, err)
|
|
}
|
|
if operation != promptOperationCharacterExtract {
|
|
return nil
|
|
}
|
|
var payload struct {
|
|
Characters *[]map[string]interface{} `json:"characters"`
|
|
}
|
|
if err := json.Unmarshal([]byte(jsonText), &payload); err != nil {
|
|
return fmt.Errorf("角色卡提取返回的 JSON 无法解析:%w", err)
|
|
}
|
|
required := []string{"name", "aliases", "role", "appearance", "clothing", "physique", "personality", "props", "consistencyPrompt", "multiViewPrompt", "voiceLanguage", "voiceAge", "voiceTimbre"}
|
|
if payload.Characters == nil {
|
|
return errors.New("角色卡提取结果缺少 characters 数组")
|
|
}
|
|
for index, character := range *payload.Characters {
|
|
for _, field := range required {
|
|
if _, exists := character[field]; !exists {
|
|
return fmt.Errorf("角色卡提取结果中第 %d 个角色缺少字段 %s", index+1, field)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func uniqueStrings(values []string) []string {
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if len(result) == 0 || result[len(result)-1] != value {
|
|
result = append(result, value)
|
|
}
|
|
}
|
|
return result
|
|
}
|