Files
ttstd-infinite-canvas/backend/internal/app/provider_style.go

242 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 生成任务的风格档案(Style Profile)解析与应用:把项目/画布风格合成进提示词与参考素材,
// 并校验风格素材对所选模型可用。
package app
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
"gorm.io/gorm"
)
type styleExecutionPlanDocument struct {
SchemaVersion int `json:"schemaVersion"`
ProfilePresetID string `json:"profilePresetId"`
ProfileRevision int `json:"profileRevision"`
Mode string `json:"mode"`
Model string `json:"model"`
InterfaceType string `json:"interfaceType"`
Status string `json:"status"`
Prompt string `json:"prompt"`
}
func (s *Service) applyGenerationStyleProfile(userID string, taskProjectID string, input *canvasGenerationInput) error {
styleProfileJSON := metadataString(input.Metadata, "styleProfileJson")
if strings.TrimSpace(styleProfileJSON) == "" {
return nil
}
if _, err := validateStyleProfileJSON(styleProfileJSON); err != nil {
return fmt.Errorf("项目画风执行配置无效:%w", err)
}
var profile styleProfileDocument
if err := json.Unmarshal([]byte(styleProfileJSON), &profile); err != nil {
return fmt.Errorf("项目画风执行配置解析失败:%w", err)
}
storedProfileJSON, storedPresetID, belongsToProject, err := s.taskProjectStyleProfile(userID, taskProjectID)
if err != nil {
return fmt.Errorf("读取项目画风失败:%w", err)
}
if belongsToProject {
if strings.TrimSpace(storedProfileJSON) == "" {
// 旧项目只有 preset ID,允许画布把该预设编译为结构化快照;仍需锁定同一预设,不能借降级路径换画风。
if strings.TrimSpace(storedPresetID) == "" || strings.TrimSpace(profile.PresetID) != strings.TrimSpace(storedPresetID) {
return errors.New("项目画风已发生变化,请返回项目列表后重新打开当前项目再生成")
}
} else {
matches, compareErr := equivalentStyleProfileJSON(styleProfileJSON, storedProfileJSON)
if compareErr != nil {
return errors.New("项目画风配置暂时无法读取,请在项目设置中重新保存画风后重试")
}
if !matches {
// 项目画风可能在另一个页面更新;保存路径以服务端快照为准,生成时自动采用最新版本。
validatedStoredProfileJSON, validateErr := validateStyleProfileJSON(storedProfileJSON)
if validateErr != nil || json.Unmarshal([]byte(validatedStoredProfileJSON), &profile) != nil {
return errors.New("项目画风配置暂时无法读取,请在项目设置中重新保存画风后重试")
}
}
}
}
// 执行计划是前端为即时预览生成的派生数据。平台模型入队后可能改选真实供应线路,
// 因此后端必须以最终模型重新编译,不能要求用户手动“刷新配置”来同步内部路由。
plan, _ := decodeStyleExecutionPlan(input.Metadata["styleExecutionPlan"])
stylePrompt, expectedStatus, warnings := resolveGenerationStyleExecution(profile, input.Config.Model, firstNonEmpty(input.Config.InterfaceType, input.Config.APIFormat))
input.Prompt = reconcileGenerationStylePrompt(input.Prompt, plan.Prompt, stylePrompt)
if expectedStatus == "blocked" {
return fmt.Errorf("当前图片模型无法完整执行项目画风:%s。请切换图片模型,或在项目设置中停用对应画风资产", strings.Join(warnings, ";"))
}
return nil
}
func reconcileGenerationStylePrompt(prompt string, previousStylePrompt string, currentStylePrompt string) string {
content := strings.TrimSpace(prompt)
previous := strings.TrimSpace(previousStylePrompt)
if previous != "" {
previousBlock := "【项目画风执行规范】\n" + previous
if strings.HasSuffix(content, previousBlock) {
content = strings.TrimSpace(strings.TrimSuffix(content, previousBlock))
}
}
current := strings.TrimSpace(currentStylePrompt)
if current == "" || strings.HasSuffix(content, "【项目画风执行规范】\n"+current) {
return content
}
return strings.TrimSpace(content + "\n\n【项目画风执行规范】\n" + current)
}
func (s *Service) taskProjectStyleProfile(userID string, canvasOrProjectID string) (string, string, bool, error) {
id := strings.TrimSpace(canvasOrProjectID)
if id == "" {
return "", "", false, nil
}
if canvas, err := s.repo.CanvasProjectForUser(userID, id); err == nil {
if strings.TrimSpace(canvas.ProjectID) == "" {
return "", "", false, nil
}
project, projectErr := s.repo.ProjectForUser(userID, canvas.ProjectID)
if projectErr != nil {
return "", "", true, projectErr
}
return project.StyleProfileJSON, project.StylePresetID, true, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return "", "", false, err
}
project, err := s.repo.ProjectForUser(userID, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", "", false, nil
}
return "", "", false, err
}
return project.StyleProfileJSON, project.StylePresetID, true, nil
}
func equivalentStyleProfileJSON(left string, right string) (bool, error) {
var leftValue interface{}
if err := json.Unmarshal([]byte(left), &leftValue); err != nil {
return false, err
}
var rightValue interface{}
if err := json.Unmarshal([]byte(right), &rightValue); err != nil {
return false, err
}
leftCanonical, err := json.Marshal(leftValue)
if err != nil {
return false, err
}
rightCanonical, err := json.Marshal(rightValue)
if err != nil {
return false, err
}
return bytes.Equal(leftCanonical, rightCanonical), nil
}
func decodeStyleExecutionPlan(value interface{}) (styleExecutionPlanDocument, error) {
if value == nil {
return styleExecutionPlanDocument{}, errors.New("项目画风执行计划缺失")
}
raw, err := json.Marshal(value)
if err != nil {
return styleExecutionPlanDocument{}, errors.New("项目画风执行计划格式无效")
}
var plan styleExecutionPlanDocument
if err := json.Unmarshal(raw, &plan); err != nil {
return styleExecutionPlanDocument{}, errors.New("项目画风执行计划格式无效")
}
return plan, nil
}
func resolveGenerationStyleExecution(profile styleProfileDocument, generationModel string, interfaceType string) (string, string, []string) {
fragments := []string{strings.TrimSpace(profile.Prompt)}
if negative := strings.TrimSpace(profile.NegativePrompt); negative != "" {
fragments = append(fragments, "【全局负面 Prompt】\n"+negative)
}
warnings := make([]string, 0)
for _, asset := range profile.Assets {
if asset.Enabled != nil && !*asset.Enabled {
continue
}
if asset.Status != "validated" {
reason := "资产尚未验证"
if asset.Status == "unavailable" {
reason = "资产当前不可用"
}
warnings = append(warnings, asset.Title+":"+reason)
continue
}
if len(asset.BaseModels) > 0 && !styleAssetSupportsModel(asset.BaseModels, generationModel) {
warnings = append(warnings, asset.Title+":仅兼容 "+strings.Join(asset.BaseModels, "、"))
continue
}
switch asset.Kind {
case "prompt", "template":
fragments = append(fragments, strings.TrimSpace(asset.PromptFragment))
fragments = append(fragments, nonEmptyStyleProfileStrings(asset.TriggerWords)...)
case "reference":
warnings = append(warnings, asset.Title+":项目参考图自动注入适配器尚未启用")
case "lora":
warnings = append(warnings, asset.Title+":当前 "+firstNonEmpty(interfaceType, "图片")+" 协议未启用 LoRA 适配器")
}
}
normalizedFragments := nonEmptyStyleProfileStrings(fragments)
status := "ready"
if len(warnings) > 0 {
status = "degraded"
if profile.ExecutionPolicy == "strict-assets" {
status = "blocked"
}
}
return strings.Join(normalizedFragments, "\n"), status, warnings
}
func styleAssetSupportsModel(baseModels []string, generationModel string) bool {
for _, baseModel := range baseModels {
if strings.EqualFold(strings.TrimSpace(baseModel), strings.TrimSpace(generationModel)) {
return true
}
}
return false
}
func (s *Service) validateResolvedImageCapability(input *canvasGenerationInput) error {
fallback := DefaultImageCapabilityConfig(input.Config.InterfaceType, input.Config.Model)
channelID := strings.TrimSpace(input.Config.ChannelID)
if channelID == "" {
if input.Config.CapabilityConfig != nil && input.Config.CapabilityConfig.Image != nil {
input.ImageCapability = input.Config.CapabilityConfig.Image
} else {
input.ImageCapability = fallback
}
return validateImageTask(input.ImageCapability, *input)
}
item, err := s.repo.ChannelModelByKey(channelID, providerChannelModelKey(input.Config))
if err != nil {
return errors.New("当前系统渠道模型未配置或已停用")
}
profile, err := DecodeModelCapabilityConfig(item.CapabilityConfigJSON)
if err != nil {
return errors.New("当前图片模型能力参数无效")
}
if profile != nil && profile.Image != nil {
input.ImageCapability = profile.Image
} else {
input.ImageCapability = fallback
}
return validateImageTask(input.ImageCapability, *input)
}
func metadataStringValues(value any) map[string]string {
values := map[string]string{}
raw, ok := value.(map[string]interface{})
if !ok {
return values
}
for key, item := range raw {
values[key] = strings.TrimSpace(fmt.Sprint(item))
}
return values
}