feat(*): 生图与视频工作台 - 新增独立生成流程并整合平台配置
This commit is contained in:
1 parent
4c1d2b4012
commit
1f3f2c6020
69 files changed
+7108
-87
No files matched your search
@@ -0,0 +1,393 @@
|
||||
package workbench
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"yingce/backend/internal/kernel"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
type TaskInput struct {
|
||||
Kind string `json:"kind"`
|
||||
ToolID string `json:"toolId"`
|
||||
PresetID string `json:"presetId,omitempty"`
|
||||
Workbench *TaskWorkbench `json:"workbench,omitempty"`
|
||||
Prompt string `json:"prompt"`
|
||||
Model string `json:"model,omitempty"`
|
||||
LogicalModelID string `json:"logicalModelId,omitempty"`
|
||||
ChannelID string `json:"channelId,omitempty"`
|
||||
ModelChoice *ModelChoice `json:"modelChoice,omitempty"`
|
||||
Parameters map[string]any `json:"parameters,omitempty"`
|
||||
References []TaskReference `json:"references,omitempty"`
|
||||
}
|
||||
|
||||
type TaskWorkbench struct {
|
||||
Kind string `json:"kind"`
|
||||
ToolID string `json:"toolId"`
|
||||
PresetID string `json:"presetId,omitempty"`
|
||||
}
|
||||
|
||||
type ModelChoice struct {
|
||||
LogicalModelID string `json:"logicalModelId,omitempty"`
|
||||
UserChannelID string `json:"userChannelId,omitempty"`
|
||||
ModelName string `json:"modelName,omitempty"`
|
||||
}
|
||||
|
||||
type TaskReference struct {
|
||||
ResourceID string `json:"resourceId"`
|
||||
Kind string `json:"kind"`
|
||||
Role string `json:"role,omitempty"`
|
||||
SlotKey string `json:"slotKey,omitempty"`
|
||||
}
|
||||
|
||||
type TaskResolution struct {
|
||||
TaskType string
|
||||
Operation string
|
||||
Input map[string]any
|
||||
Parameters map[string]any
|
||||
Prompt string
|
||||
Model string
|
||||
LogicalModelID string
|
||||
ChannelID string
|
||||
Tool *model.WorkbenchTool
|
||||
Preset *model.WorkbenchPreset
|
||||
}
|
||||
|
||||
func (s *Service) ResolveTask(userID string, req TaskInput) (*TaskResolution, error) {
|
||||
toolID := strings.TrimSpace(req.ToolID)
|
||||
presetID := strings.TrimSpace(req.PresetID)
|
||||
if req.Workbench != nil && (req.Workbench.Kind != req.Kind || strings.TrimSpace(req.Workbench.ToolID) != toolID || (strings.TrimSpace(req.Workbench.PresetID) != "" && strings.TrimSpace(req.Workbench.PresetID) != presetID)) {
|
||||
return nil, kernel.BadAuthRequest("工作台任务范围字段不一致")
|
||||
}
|
||||
if !validKind(req.Kind) {
|
||||
return nil, kernel.BadAuthRequest("工作台类型无效")
|
||||
}
|
||||
if toolID == "" && presetID != "" {
|
||||
return nil, kernel.BadAuthRequest("使用提示词预设前必须选择创作工具")
|
||||
}
|
||||
var tool *model.WorkbenchTool
|
||||
var preset *model.WorkbenchPreset
|
||||
if toolID != "" {
|
||||
var err error
|
||||
tool, preset, err = s.repo.WorkbenchToolAndPreset(toolID, presetID, true)
|
||||
if err != nil {
|
||||
return nil, kernel.BadAuthRequest("工具或预设未开放")
|
||||
}
|
||||
if tool.Kind != req.Kind {
|
||||
return nil, kernel.BadAuthRequest("所选工具与工作台类型不匹配")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(req.Prompt) == "" && (tool == nil || toolRequiresPrompt(tool)) {
|
||||
return nil, kernel.BadAuthRequest("当前工具需要填写提示词")
|
||||
}
|
||||
modelName, logicalModelID, channelID, providerConfig, err := resolveModelInput(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(providerConfig) == 0 {
|
||||
return nil, kernel.BadAuthRequest("必须提供可执行模型配置")
|
||||
}
|
||||
if containsSensitiveURL(providerConfig) {
|
||||
return nil, kernel.BadAuthRequest("浏览器不能直接提交模型配置、URL 或凭据")
|
||||
}
|
||||
if len(req.References) > 20 {
|
||||
return nil, kernel.BadAuthRequest("参考素材数量超出限制")
|
||||
}
|
||||
parameters, err := normalizeTaskParameters(req.Kind, req.Parameters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refs := make([]TaskReference, 0, len(req.References))
|
||||
frameRoles := map[string]bool{}
|
||||
for _, ref := range req.References {
|
||||
if strings.TrimSpace(ref.ResourceID) == "" || !validReferenceKind(ref.Kind) {
|
||||
return nil, kernel.BadAuthRequest("参考素材无效")
|
||||
}
|
||||
role := strings.TrimSpace(ref.Role)
|
||||
if !validReferenceRole(req.Kind, ref.Kind, role) {
|
||||
return nil, kernel.BadAuthRequest("参考素材角色无效")
|
||||
}
|
||||
if role == "first_frame" || role == "last_frame" {
|
||||
if frameRoles[role] {
|
||||
return nil, kernel.BadAuthRequest("首帧和尾帧各只能指定一个素材")
|
||||
}
|
||||
frameRoles[role] = true
|
||||
}
|
||||
resource, err := s.repo.ResourceForUser(userID, strings.TrimSpace(ref.ResourceID))
|
||||
if err != nil || resource.Status != model.ResourceStatusReady || resource.Kind != ref.Kind {
|
||||
return nil, kernel.BadAuthRequest("参考素材不存在、未就绪或类型不匹配")
|
||||
}
|
||||
refs = append(refs, TaskReference{ResourceID: resource.ID, Kind: resource.Kind, Role: role, SlotKey: strings.TrimSpace(ref.SlotKey)})
|
||||
}
|
||||
if tool != nil {
|
||||
capability := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(tool.CapabilityJSON), &capability); err != nil {
|
||||
return nil, errors.New("工具能力配置无效")
|
||||
}
|
||||
if err := validateTaskCapabilities(tool.Kind, capability, refs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
metadata := map[string]any{"source": "workbench", "workbenchKind": req.Kind}
|
||||
workbenchSnapshot := map[string]any{"kind": req.Kind, "parameters": parameters}
|
||||
snapshotReferences := make([]map[string]any, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
reference := map[string]any{"resourceId": ref.ResourceID, "kind": ref.Kind}
|
||||
if ref.Role != "" {
|
||||
reference["role"] = ref.Role
|
||||
}
|
||||
if ref.SlotKey != "" {
|
||||
reference["slotKey"] = ref.SlotKey
|
||||
}
|
||||
snapshotReferences = append(snapshotReferences, reference)
|
||||
}
|
||||
workbenchSnapshot["references"] = snapshotReferences
|
||||
if tool != nil {
|
||||
metadata["workbenchToolId"], metadata["workbenchToolName"], metadata["workbenchToolVersion"] = tool.ID, tool.Name, tool.Version
|
||||
workbenchSnapshot["toolId"], workbenchSnapshot["toolName"], workbenchSnapshot["toolVersion"] = tool.ID, tool.Name, tool.Version
|
||||
}
|
||||
metadata["workbench"] = workbenchSnapshot
|
||||
input := map[string]any{"mode": req.Kind, "prompt": strings.TrimSpace(req.Prompt), "config": providerConfig, "metadata": metadata}
|
||||
if len(refs) > 0 {
|
||||
imageRefs, videoRefs, audioRefs := []map[string]any{}, []map[string]any{}, []map[string]any{}
|
||||
for _, ref := range refs {
|
||||
value := map[string]any{"resourceId": ref.ResourceID, "storageKey": "resource:" + ref.ResourceID}
|
||||
if ref.Role != "" {
|
||||
value["role"] = ref.Role
|
||||
}
|
||||
if ref.SlotKey != "" {
|
||||
value["slotKey"] = ref.SlotKey
|
||||
}
|
||||
switch ref.Kind {
|
||||
case "image":
|
||||
imageRefs = append(imageRefs, value)
|
||||
case "video":
|
||||
videoRefs = append(videoRefs, value)
|
||||
case "audio":
|
||||
audioRefs = append(audioRefs, value)
|
||||
}
|
||||
}
|
||||
if len(imageRefs) > 0 {
|
||||
input["referenceImages"] = imageRefs
|
||||
}
|
||||
if len(videoRefs) > 0 {
|
||||
input["referenceVideos"] = videoRefs
|
||||
}
|
||||
if len(audioRefs) > 0 {
|
||||
input["referenceAudios"] = audioRefs
|
||||
}
|
||||
}
|
||||
if preset != nil {
|
||||
meta := input["metadata"].(map[string]any)
|
||||
meta["workbenchPresetId"], meta["workbenchPresetVersion"], meta["workbenchPresetName"] = preset.ID, preset.Version, preset.Name
|
||||
meta["workbenchPresetPrompt"] = preset.PromptTemplate
|
||||
workbenchSnapshot := meta["workbench"].(map[string]any)
|
||||
workbenchSnapshot["presetId"], workbenchSnapshot["presetName"], workbenchSnapshot["presetVersion"] = preset.ID, preset.Name, preset.Version
|
||||
workbenchSnapshot["presetPrompt"] = preset.PromptTemplate
|
||||
}
|
||||
return &TaskResolution{TaskType: map[string]string{KindImage: "canvas_image", KindVideo: "canvas_video"}[req.Kind], Operation: map[string]string{KindImage: "image", KindVideo: "video"}[req.Kind], Input: input, Parameters: parameters, Prompt: strings.TrimSpace(req.Prompt), Model: modelName, LogicalModelID: logicalModelID, ChannelID: channelID, Tool: tool, Preset: preset}, nil
|
||||
}
|
||||
|
||||
func normalizeTaskParameters(kind string, params map[string]any) (map[string]any, error) {
|
||||
allowed := map[string]map[string]bool{
|
||||
KindImage: {"size": true, "quality": true, "count": true},
|
||||
KindVideo: {"seconds": true, "ratio": true, "resolution": true, "generateAudio": true, "watermark": true},
|
||||
}[kind]
|
||||
if allowed == nil {
|
||||
return nil, kernel.BadAuthRequest("工作台类型无效")
|
||||
}
|
||||
normalized := make(map[string]any, len(params))
|
||||
for key, value := range params {
|
||||
if !allowed[key] {
|
||||
return nil, kernel.BadAuthRequest("生成参数包含不支持字段: " + key)
|
||||
}
|
||||
switch key {
|
||||
case "size", "quality", "ratio", "resolution":
|
||||
text, ok := value.(string)
|
||||
text = strings.TrimSpace(text)
|
||||
if !ok || len(text) > 64 || strings.Contains(text, "://") || strings.HasPrefix(strings.ToLower(text), "data:") {
|
||||
return nil, kernel.BadAuthRequest("生成参数格式无效: " + key)
|
||||
}
|
||||
if text != "" {
|
||||
normalized[key] = text
|
||||
}
|
||||
case "count", "seconds":
|
||||
number, ok := taskParameterInteger(value)
|
||||
max := 20
|
||||
if key == "seconds" {
|
||||
max = 3600
|
||||
}
|
||||
if !ok || number < 1 || number > max {
|
||||
return nil, kernel.BadAuthRequest("生成参数数值无效: " + key)
|
||||
}
|
||||
normalized[key] = number
|
||||
case "generateAudio", "watermark":
|
||||
flag, ok := value.(bool)
|
||||
if !ok {
|
||||
return nil, kernel.BadAuthRequest("生成参数格式无效: " + key)
|
||||
}
|
||||
normalized[key] = flag
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func taskParameterInteger(value any) (int, bool) {
|
||||
switch number := value.(type) {
|
||||
case int:
|
||||
return number, true
|
||||
case int64:
|
||||
return int(number), int64(int(number)) == number
|
||||
case float64:
|
||||
if math.IsNaN(number) || math.IsInf(number, 0) || math.Trunc(number) != number {
|
||||
return 0, false
|
||||
}
|
||||
return int(number), float64(int(number)) == number
|
||||
case json.Number:
|
||||
parsed, err := strconv.Atoi(number.String())
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func validReferenceRole(kind, referenceKind, role string) bool {
|
||||
if role == "" || role == "reference" {
|
||||
return kind == KindVideo || referenceKind == "image"
|
||||
}
|
||||
return kind == KindVideo && referenceKind == "image" && (role == "first_frame" || role == "last_frame")
|
||||
}
|
||||
|
||||
func resolveModelInput(req TaskInput) (string, string, string, map[string]any, error) {
|
||||
modelName := strings.TrimSpace(req.Model)
|
||||
logicalModelID := strings.TrimSpace(req.LogicalModelID)
|
||||
channelID := strings.TrimSpace(req.ChannelID)
|
||||
if req.ModelChoice != nil {
|
||||
choice := req.ModelChoice
|
||||
if choice.UserChannelID != "" {
|
||||
return "", "", "", nil, kernel.BadAuthRequest("工作台暂不接受浏览器自定义渠道选择")
|
||||
}
|
||||
if value := strings.TrimSpace(choice.LogicalModelID); value != "" {
|
||||
if logicalModelID != "" && logicalModelID != value {
|
||||
return "", "", "", nil, kernel.BadAuthRequest("模型选择字段不一致")
|
||||
}
|
||||
logicalModelID = value
|
||||
}
|
||||
if value := strings.TrimSpace(choice.ModelName); value != "" {
|
||||
if modelName != "" && modelName != value {
|
||||
return "", "", "", nil, kernel.BadAuthRequest("模型名称字段不一致")
|
||||
}
|
||||
modelName = value
|
||||
}
|
||||
}
|
||||
if modelName == "" {
|
||||
return "", "", "", nil, kernel.BadAuthRequest("必须选择一个模型")
|
||||
}
|
||||
// 创作页的系统目录模型多数只有渠道 ID + 模型名,没有 logicalModelId。
|
||||
// 两者都空才拒绝;渠道是否为系统渠道由后续 admission 再校验。
|
||||
if logicalModelID == "" && channelID == "" {
|
||||
return "", "", "", nil, kernel.BadAuthRequest("工作台任务必须选择系统模型目录中的模型")
|
||||
}
|
||||
config := map[string]any{"mode": req.Kind, "model": modelName}
|
||||
if channelID != "" {
|
||||
config["channelId"] = channelID
|
||||
}
|
||||
return modelName, logicalModelID, channelID, config, nil
|
||||
}
|
||||
|
||||
func validReferenceKind(kind string) bool {
|
||||
return kind == "image" || kind == "video" || kind == "audio"
|
||||
}
|
||||
|
||||
func containsSensitiveURL(value any) bool {
|
||||
switch item := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range item {
|
||||
lower := strings.ToLower(key)
|
||||
if lower == "api_key" || lower == "apikey" || lower == "authorization" || lower == "headers" || lower == "baseurl" || lower == "base_url" || lower == "url" {
|
||||
return true
|
||||
}
|
||||
if containsSensitiveURL(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range item {
|
||||
if containsSensitiveURL(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateTaskCapabilities(kind string, capability map[string]any, refs []TaskReference) error {
|
||||
allowedRefs, _ := capability["referenceKinds"].([]any)
|
||||
allowedSet := map[string]bool{}
|
||||
for _, value := range allowedRefs {
|
||||
if text, ok := value.(string); ok {
|
||||
allowedSet[text] = true
|
||||
}
|
||||
}
|
||||
var contract InputContract
|
||||
if raw, ok := capability["inputContract"]; ok {
|
||||
encoded, _ := json.Marshal(raw)
|
||||
_ = json.Unmarshal(encoded, &contract)
|
||||
}
|
||||
slots := map[string]ReferenceSlot{}
|
||||
for _, slot := range contract.Slots {
|
||||
slots[slot.Key] = slot
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, ref := range refs {
|
||||
if len(allowedSet) > 0 && !allowedSet[ref.Kind] {
|
||||
return kernel.BadAuthRequest("当前工具不支持该类型参考素材")
|
||||
}
|
||||
if kind == KindImage && ref.Kind != "image" {
|
||||
return kernel.BadAuthRequest("生图任务只允许使用图片参考素材")
|
||||
}
|
||||
if kind == KindVideo && ref.Kind != "image" && ref.Kind != "video" && ref.Kind != "audio" {
|
||||
return kernel.BadAuthRequest("视频任务参考素材类型无效")
|
||||
}
|
||||
if len(contract.Slots) == 0 {
|
||||
continue
|
||||
}
|
||||
slotKey := strings.TrimSpace(ref.SlotKey)
|
||||
slot, ok := slots[slotKey]
|
||||
if !ok {
|
||||
return kernel.BadAuthRequest("参考素材未指定有效的输入槽位")
|
||||
}
|
||||
mediaAllowed := false
|
||||
for _, mediaType := range slot.MediaTypes {
|
||||
if mediaType == ref.Kind {
|
||||
mediaAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !mediaAllowed {
|
||||
return kernel.BadAuthRequest("参考素材类型与输入槽位不匹配")
|
||||
}
|
||||
counts[slotKey]++
|
||||
if counts[slotKey] > slot.MaxCount {
|
||||
return kernel.BadAuthRequest("输入槽位的参考素材数量超出上限")
|
||||
}
|
||||
}
|
||||
for _, slot := range contract.Slots {
|
||||
if counts[slot.Key] < slot.MinCount || (slot.Required && counts[slot.Key] == 0) {
|
||||
return kernel.BadAuthRequest("请补齐必需的输入素材:" + slot.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toolRequiresPrompt(tool *model.WorkbenchTool) bool {
|
||||
if tool == nil {
|
||||
return true
|
||||
}
|
||||
config, err := parseCatalogConfig(tool.CapabilityJSON, tool.GuideJSON)
|
||||
return err != nil || config.InputContract.PromptRequired || config.InputContract.MinTextReferences > 0
|
||||
}
|
||||
Reference in new issue
Block a user