394 lines
14 KiB
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
|
|
}
|