Files
ttstd-infinite-canvas/backend/internal/workbench/tasks.go

394 lines
14 KiB
Go

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, "imageResolution": 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", "imageResolution", "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
}