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

614 lines
24 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.
package app
import (
"encoding/json"
"errors"
"fmt"
"strings"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
// Internal admission constraints are not JSON fields. Callers cannot select a
// task ID or bypass the quoted-charge ceiling through the public tasks API.
type taskAdmission struct {
ID string
MaxCharge int64
AgentRunID string
GenerationID string
ApprovalID string
NonBillable bool
}
// CreateTask 收敛任务进入系统前的 admission 流程:输入标准化、逻辑模型路由、
// 能力/额度校验和持久化。执行阶段由 worker 与 provider 相关模块负责。
// CreateTask 校验并创建一条生成任务。
// 这是常规模型生成任务的写入口:客户端只提交创作意图,模型、渠道、协议和计价信息必须由服务端目录重新解析,
// 以保证“可展示的模型”与“实际执行及扣费的模型”来自同一份有效配置。
func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, error) {
if req.admission == nil && (strings.HasPrefix(req.Operation, "cloud_agent") || req.Input["cloudAgent"] != nil) {
return nil, BadAuthRequest("Agent 任务必须通过 Agent 接口创建")
}
if s.IsDraining() {
return nil, &AppError{Status: 503, Code: 503, Message: "服务正在维护,暂不接受新的生成任务", Retryable: true}
}
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
return nil, errors.New("prompt is required")
}
taskType := strings.TrimSpace(req.Type)
if err := validateTaskType(taskType); err != nil {
return nil, err
}
normalizedInput, err := normalizeTaskInput(req.Input)
if err != nil {
return nil, err
}
// Fail admission before queueing or charging; the worker validates again in
// case a tool is deleted or its visibility changes while queued.
toolMode, _ := normalizedInput["mode"].(string)
if toolMode == "" {
if strings.HasPrefix(taskType, "video_") || taskType == "canvas_video" {
toolMode = "video"
} else if taskType == "canvas_image" {
toolMode = "image"
}
}
if _, err := s.ResolveToolMentionTokens(userID, toolMode, prompt); err != nil {
return nil, err
}
if inputPrompt, ok := normalizedInput["prompt"].(string); ok && inputPrompt != prompt {
if _, err := s.ResolveToolMentionTokens(userID, toolMode, inputPrompt); err != nil {
return nil, err
}
}
var routed *RoutedModel
logicalModelID := strings.TrimSpace(req.LogicalModelID)
workflowProviderTask := taskInputUsesWorkflowProvider(normalizedInput)
frontendEnabled := false
if workflowProviderTask {
config, _ := normalizedInput["config"].(map[string]any)
if err := s.RequireWorkflowPluginForUser(userID, strings.TrimSpace(fmt.Sprint(config["interfaceType"]))); err != nil {
return nil, err
}
} else {
// 工作流是独立执行器;普通模型仍严格使用主线的目录和路由校验。
frontendEnabled, err = s.FeatureEnabled(FeatureFrontendModels)
if err != nil {
return nil, err
}
}
if !workflowProviderTask {
routed, normalizedInput, err = s.resolveTaskModelSelection(normalizedInput, logicalModelID, taskType, req.Operation, frontendEnabled)
if err != nil {
return nil, err
}
}
if strings.HasPrefix(taskType, "video_") && !hasExecutableProviderVideoConfig(normalizedInput) {
if mode, _ := normalizedInput["mode"].(string); mode != "video" {
return nil, errors.New("视频任务必须使用 video 模式")
}
return nil, errors.New("视频任务缺少可执行的模型配置")
}
// 前端自管的文本持久化任务:直连模型生成、增量上报 text-deltas,不排入 worker 队列生成。
if isTextReplayTaskRequest(normalizedInput) {
return s.createTextReplayTask(userID, req, normalizedInput)
}
if err := s.requireCustomChannelsForTaskInput(normalizedInput); err != nil {
return nil, err
}
if err := s.ValidateTaskCapability(normalizedInput); err != nil {
return nil, err
}
if containsInlineMediaDataURL(normalizedInput) {
return nil, BadAuthRequest("任务输入不能包含内嵌媒体,请先上传到资源存储")
}
policy, err := s.RuntimePolicy()
if err != nil {
return nil, err
}
activeTasks, err := s.repo.ActiveTaskCountForUser(userID)
if err != nil {
return nil, err
}
if activeTasks >= int64(policy.Task.ActiveTaskLimit) {
return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit))
}
task := model.Task{ID: newID(), UserID: userID, TraceID: req.TraceID, RequestID: req.RequestID, ProjectID: req.ProjectID, Type: taskType, Status: model.TaskStatusQueued, Stage: "等待队列调度", Progress: 5, Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: req.Model}
if req.admission != nil {
task.ID = req.admission.ID
task.AgentRunID = req.admission.AgentRunID
task.GenerationID = req.admission.GenerationID
task.ApprovalID = req.admission.ApprovalID
}
if routed != nil {
task.LogicalModelID = routed.LogicalModel.ID
task.LogicalModelRevisionID = routed.Revision.ID
task.RouteID = routed.Route.ID
task.ChannelModelID = routed.ChannelModel.ID
task.RouteRun = 1
task.Model = routed.LogicalModel.Code
task.Provider = "managed"
}
if err := s.ensureTaskProjectActive(userID, req.ProjectID); err != nil {
return nil, err
}
// Agent roots are durable control-plane carriers, not model calls. They
// must not reserve credits or enter the worker queue; Pi creates the
// billable cloud_agent_step task for each actual model request instead.
if req.admission != nil && req.admission.NonBillable && !s.legacyCloudAgentRootTask {
if err := s.protectTaskSecrets(normalizedInput); err != nil {
return nil, err
}
inputJSON, err := json.Marshal(normalizedInput)
if err != nil {
return nil, fmt.Errorf("序列化任务输入失败:%w", err)
}
task.Status = model.TaskStatusTextReplay
task.Stage = "Agent 控制面载体"
task.Progress = 0
task.InputJSON = string(inputJSON)
if err := s.createTaskWithinStorageQuota(&task, nil, policy); err != nil {
return nil, err
}
return taskForOutput(task), nil
}
billingOrder, err := s.taskBillingOrder(userID, &task, normalizedInput)
if err != nil {
return nil, err
}
if req.admission != nil && billingOrder != nil {
if billingOrder.AmountMicrocredits > req.admission.MaxCharge {
return nil, BadAuthRequest("模型调用报价超过本轮 Agent 积分上限,尚未创建任务或扣费")
}
switch billingOrder.BillingMode {
case "fixed_request", "per_second", "token":
billingOrder.ChargeLimitSet = true
billingOrder.ChargeLimitMicrocredits = billingOrder.AmountMicrocredits
task.AuthorizedChargeMicrocredits = billingOrder.AmountMicrocredits
default:
return nil, BadAuthRequest("Agent 暂不支持当前模型计费方式")
}
}
if req.creationPrepare != nil {
encoded, encodeErr := json.Marshal(normalizedInput)
if encodeErr != nil {
return nil, encodeErr
}
task.InputJSON = string(encoded)
req.creationPrepare.Order = billingOrder
return &task, nil
}
if err := s.protectTaskSecrets(normalizedInput); err != nil {
return nil, err
}
inputJSON, err := json.Marshal(normalizedInput)
if err != nil {
return nil, fmt.Errorf("序列化任务输入失败:%w", err)
}
task.InputJSON = string(inputJSON)
if billingOrder != nil {
task.BillingOrderID = billingOrder.ID
}
err = s.createTaskWithinStorageQuota(&task, billingOrder, policy)
if errors.Is(err, repository.ErrActiveTaskLimit) {
return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit))
}
if errors.Is(err, repository.ErrInsufficientCredits) {
return nil, BadAuthRequest("积分不足,请先使用兑换码充值")
}
if errors.Is(err, repository.ErrLogicalModelUnavailable) {
return nil, BadAuthRequest("所选模型已停用、归档或配置已更新,请重新选择")
}
if err != nil {
return nil, err
}
s.recordActivity(userID, "task", 1)
_ = s.log(userID, task.ID, "info", "任务已进入队列", "")
return taskForOutput(task), nil
}
// resolveTaskModelSelection 根据请求实际携带的模型选择决定路由方式。
// 显式系统渠道和用户自定义渠道请求不能被全局前台模型开关误判;
// 它们仍分别进入系统目录校验或自定义渠道的功能、能力与安全校验。
func (s *Service) resolveTaskModelSelection(input map[string]any, logicalModelID string, taskType string, operation string, frontendEnabled bool) (*RoutedModel, map[string]any, error) {
customChannelTask := taskInputUsesCustomChannel(input)
if frontendEnabled && !taskInputUsesSystemChannel(input) && !customChannelTask {
if logicalModelID == "" {
return nil, input, InvalidModelSelection("前台模型模式下必须指定 logicalModelId")
}
intent := ModelRequestIntentFromTaskInput(input, taskType, operation)
routed, err := s.ResolveLogicalModel(logicalModelID, intent)
if err != nil {
return nil, input, err
}
return routed, applyRoutedProviderSelection(input, routed), nil
}
if logicalModelID != "" {
return nil, input, ModelCatalogMismatch("模型目录已更新,请重新选择")
}
// 自定义渠道没有系统 channelId;它会在后续由自定义渠道功能开关、
// 能力校验和 provider 配置校验共同处理,不能误报为“缺少系统渠道”。
if !customChannelTask {
resolvedInput, err := s.resolveSystemChannelModelSelection(input, taskType, operation)
if err != nil {
return nil, input, err
}
return nil, resolvedInput, nil
}
return nil, input, nil
}
func applyRoutedProviderSelection(input map[string]any, routed *RoutedModel) map[string]any {
config, _ := input["config"].(map[string]any)
nextConfig := make(map[string]any, len(config)+2)
for key, value := range config {
switch key {
case "channelId", "channelModelKey", "priceTierId", "providerModelKey", "apiFormat", "interfaceType", "baseUrl", "apiKey", "secretKey", "headers", "model", "capabilityConfig":
continue
default:
nextConfig[key] = value
}
}
for key, value := range routed.Defaults {
canonical := canonicalCapabilityOptionName(key)
if existing, exists := nextConfig[canonical]; !exists || existing == nil || strings.TrimSpace(fmt.Sprint(existing)) == "" {
nextConfig[canonical] = providerConfigOptionValue(value)
}
}
// 路由匹配和真实请求必须使用同一组参数;逻辑能力参数覆盖空的页面配置,但不携带供应链字段。
if options, ok := input["capabilityOptions"].(map[string]any); ok {
for key, value := range options {
canonical := canonicalCapabilityOptionName(key)
if isProviderCapabilityOption(canonical) {
nextConfig[canonical] = providerConfigOptionValue(value)
}
}
}
nextConfig["channelId"] = routed.ChannelModel.ChannelID
nextConfig["model"] = routed.ChannelModel.ModelKey
nextConfig["channelModelKey"] = routed.ChannelModel.ModelKey
providerModelKey := routed.ChannelModel.ProviderModelKey
if routed.PriceTier != nil {
nextConfig["priceTierId"] = routed.PriceTier.ID
providerModelKey = firstNonEmpty(routed.PriceTier.ProviderModelKey, providerModelKey)
}
nextConfig["providerModelKey"] = firstNonEmpty(providerModelKey, routed.ChannelModel.ModelKey)
input["config"] = nextConfig
return input
}
func providerConfigOptionValue(value any) string {
switch typed := value.(type) {
case string:
return typed
case json.Number:
return typed.String()
default:
return fmt.Sprint(value)
}
}
// 所有任务输入先收敛为 JSON 对象,确保计费与密钥保护不会因 Go 结构体类型不同而被绕过。
func normalizeTaskInput(input map[string]any) (map[string]any, error) {
if input == nil {
return map[string]any{}, nil
}
encoded, err := json.Marshal(input)
if err != nil {
return nil, BadAuthRequest("任务输入格式无效")
}
var normalized map[string]any
if err := json.Unmarshal(encoded, &normalized); err != nil {
return nil, BadAuthRequest("任务输入格式无效")
}
if snapshot, ok := normalized["canvasSnapshot"]; ok {
normalized["canvasSnapshot"] = compactPersistedValue(snapshot)
}
return normalized, nil
}
// createTextReplayTask 创建前端自管的文本持久化任务:状态为 text_replay,
// 不排队执行、不计 active 队列、不产生计费,仅作为正文增量(text-deltas)的存储容器。
func (s *Service) createTextReplayTask(userID string, req CreateTaskRequest, normalizedInput map[string]any) (*model.Task, error) {
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
prompt = strings.TrimSpace(fmt.Sprint(normalizedInput["prompt"]))
}
if prompt == "" {
return nil, errors.New("prompt is required")
}
taskType := strings.TrimSpace(req.Type)
if err := validateTaskType(taskType); err != nil {
return nil, err
}
task := model.Task{
ID: newID(), UserID: userID, TraceID: req.TraceID, RequestID: req.RequestID, ProjectID: req.ProjectID,
Type: taskType, Status: model.TaskStatusTextReplay, Stage: "文本持久化(前端自管)", Progress: 5,
Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: strings.TrimSpace(req.Model),
}
if err := s.protectTaskSecrets(normalizedInput); err != nil {
return nil, err
}
inputJSON, _ := json.Marshal(normalizedInput)
task.InputJSON = string(inputJSON)
policy, err := s.RuntimePolicy()
if err != nil {
return nil, err
}
if err := s.createTaskWithinStorageQuota(&task, nil, policy); err != nil {
return nil, err
}
_ = s.log(userID, task.ID, "info", "文本持久化任务已创建(前端自管)", "")
return taskForOutput(task), nil
}
// validateTaskType 是任务进入队列前的边界校验。视频任务允许携带具体操作后缀,
// 其他任务类型必须是已实现的执行分支,避免未知类型落入假成功工作流。
func validateTaskType(taskType string) error {
switch taskType {
case "text", "canvas_text", "canvas_image", "canvas_video", "canvas_audio":
return nil
}
if strings.HasPrefix(taskType, "video_") && strings.TrimPrefix(taskType, "video_") != "" {
return nil
}
if taskType == "" {
return errors.New("task type is required")
}
return fmt.Errorf("不支持的任务类型:%s", taskType)
}
func (s *Service) requireCustomChannelsForTaskInput(input map[string]any) error {
if !taskInputUsesCustomChannel(input) {
return nil
}
return s.RequireFeature(FeatureCustomChannels)
}
// resolveSystemChannelModelSelection 是系统渠道任务的 admission 边界。
// 客户端只负责表达创作参数;模型协议、能力合同、价格档和上游模型标识必须从服务端记录重建,
// 避免出现“按一个规格校验/计费,却按另一个规格执行”的跨阶段漂移。
func (s *Service) resolveSystemChannelModelSelection(input map[string]any, taskType string, operation string) (map[string]any, error) {
config, ok := input["config"].(map[string]any)
if !ok {
return input, InvalidModelSelection("缺少模型配置")
}
channelID := strings.TrimSpace(stringValue(config["channelId"]))
modelKey := strings.TrimPrefix(strings.TrimSpace(stringValue(config["model"])), "models/")
if channelID == "" || modelKey == "" {
return input, InvalidModelSelection("必须指定系统渠道和模型")
}
channel, err := s.repo.SystemChannel(channelID)
if err != nil {
return input, InvalidModelSelection("指定的渠道不存在")
}
if !channel.Enabled || channel.Scope != model.ChannelScopeSystem {
return input, InvalidModelSelection("指定的渠道不可用")
}
channelModel, err := s.repo.ChannelModelByKey(channelID, modelKey)
if err != nil {
return input, InvalidModelSelection("指定的模型不存在")
}
if !channelModel.Enabled {
return input, InvalidModelSelection("指定的模型已停用")
}
if channelModel.Protocol == "" {
return input, InvalidModelSelection("指定的模型未配置请求协议")
}
nextConfig := make(map[string]any, len(config)+6)
for key, value := range config {
switch key {
case "channelId", "channelModelKey", "priceTierId", "providerModelKey", "apiFormat", "interfaceType", "baseUrl", "apiKey", "secretKey", "headers", "model", "capabilityConfig":
continue
default:
nextConfig[key] = value
}
}
// capabilityOptions 是路由、校验和计价共同使用的请求规格;存在时必须覆盖 config 中的同名字段,
// 不能让两份客户端数据分别驱动计费与真实上游请求。
if options, ok := input["capabilityOptions"].(map[string]any); ok {
for key, value := range options {
canonical := canonicalCapabilityOptionName(key)
if isCapabilityOptionFor(channelModel.Capability, canonical) {
nextConfig[canonical] = value
}
}
}
capabilityConfig, err := normalizedChannelModelCapability(channelModel)
if err != nil {
return input, InvalidModelSelection("指定的模型能力配置无效,请联系管理员")
}
// 只有真实能力配置声明过的参数才能进入路由意图。客户端 config 可能保留
// 旧模型的质量/分辨率值;若直接重新汇总,会把已关闭的参数误报为“不支持”。
var capabilitySpec *CapabilitySpec
if normalizeCapability(channelModel.Capability) != "audio" {
spec, specErr := CapabilitySpecFromModelCapabilityConfig(capabilityConfig, channelModel.Capability)
if specErr != nil {
return input, InvalidModelSelection("指定的模型能力配置无效,请联系管理员")
}
capabilitySpec = &spec
}
applyChannelCapabilityDefaults(nextConfig, channelModel.Capability, capabilityConfig)
input["config"] = nextConfig
var declaredOptions map[string]OptionConstraint
if capabilitySpec != nil {
declaredOptions = capabilitySpec.Options
}
input["capabilityOptions"] = capabilityOptionsFromConfig(channelModel.Capability, nextConfig, declaredOptions)
intent := ModelRequestIntentFromTaskInput(input, taskType, operation)
if normalizeCapability(intent.Capability) != normalizeCapability(channelModel.Capability) {
return input, ModelCapabilityNotSupported("所选模型与任务能力不匹配")
}
if normalizeCapability(channelModel.Capability) != "audio" {
if capabilitySpec == nil {
return input, InvalidModelSelection("指定的模型能力配置无效,请联系管理员")
}
if match := MatchCapability(*capabilitySpec, intent); !match.Matched {
return input, ModelCapabilityNotSupported("所选模型不支持当前请求:" + strings.Join(match.Reasons, ";"))
}
}
pricingIntent := intent
if normalizeCapability(channelModel.Capability) == "image" {
pricingOptions := make(map[string]any, len(intent.Options)+2)
for k, v := range intent.Options {
pricingOptions[k] = v
}
rawQuality := strings.ToLower(strings.TrimSpace(fmt.Sprint(nextConfig["quality"])))
if rawQuality != "" && rawQuality != "<nil>" && rawQuality != "auto" && rawQuality != "any" {
pricingOptions["quality"] = rawQuality
} else if pricingOptions["quality"] == nil || pricingOptions["quality"] == "" || pricingOptions["quality"] == "auto" {
pricingOptions["quality"] = "1k"
}
if rawSize := strings.ToLower(strings.TrimSpace(fmt.Sprint(nextConfig["size"]))); rawSize != "" && rawSize != "<nil>" && rawSize != "auto" {
pricingOptions["size"] = rawSize
}
pricingIntent.Options = pricingOptions
}
priceTier := channelModelPriceTierForIntent(*channelModel, pricingIntent)
if priceTier == nil {
priceTier = channelModelPriceTierForIntent(*channelModel, intent)
}
if priceTier == nil || !ValidatePriceTierPrice(priceTier, channelModel.Capability, channelModel.Protocol) {
return input, ModelPriceNotConfigured("指定的模型未配置当前规格的有效价格")
}
nextConfig["channelId"] = channel.ID
nextConfig["model"] = channelModel.ModelKey
nextConfig["channelModelKey"] = channelModel.ModelKey
nextConfig["priceTierId"] = priceTier.ID
nextConfig["providerModelKey"] = firstNonEmpty(priceTier.ProviderModelKey, channelModel.ProviderModelKey, channelModel.ModelKey)
nextConfig["interfaceType"] = string(channelModel.Protocol)
nextConfig["apiFormat"] = channelAPIFormatForProtocol(channel.APIFormat, channelModel.Protocol)
return input, nil
}
// applyChannelCapabilityDefaults 只采用管理员保存的能力默认值,且仅填补客户端未表达的参数。
// 这些值随后会写回 capabilityOptions,使能力校验、SKU 选择和 provider 执行看到同一份规格。
func applyChannelCapabilityDefaults(config map[string]any, capability string, profile *ModelCapabilityConfig) {
setDefault := func(key string, value any) {
if existing, exists := config[key]; !exists || existing == nil || strings.TrimSpace(fmt.Sprint(existing)) == "" {
// providerConfig uses string-valued controls, including booleans and counts.
config[key] = fmt.Sprint(value)
}
}
switch normalizeCapability(capability) {
case "image":
if profile == nil || profile.Image == nil {
return
}
if profile.Image.Size.Parameter != "none" {
setDefault("size", profile.Image.Size.Default)
}
if profile.Image.Quality.Supported {
setDefault("quality", profile.Image.Quality.Default)
}
setDefault("transparentBackground", profile.Image.TransparentBackground.Default)
setDefault("count", 1)
case "video":
if profile == nil || profile.Video == nil {
return
}
if videoDurationSupported(profile.Video) {
setDefault("videoSeconds", profile.Video.Duration.Default)
}
setDefault("size", profile.Video.DefaultRatio)
setDefault("vquality", profile.Video.DefaultResolution)
setDefault("videoGenerateAudio", profile.Video.GenerateAudio.Default)
setDefault("videoWatermark", profile.Video.Watermark.Default)
}
}
func capabilityOptionsFromConfig(capability string, config map[string]any, declared map[string]OptionConstraint) map[string]any {
options := map[string]any{}
for key, value := range config {
canonical := canonicalCapabilityOptionName(key)
if !isCapabilityOptionFor(capability, canonical) || value == nil || strings.TrimSpace(fmt.Sprint(value)) == "" {
continue
}
if declared != nil {
if _, ok := declared[canonical]; !ok {
continue
}
}
options[canonical] = value
}
return options
}
func taskInputUsesCustomChannel(input map[string]any) bool {
if taskInputUsesWorkflowProvider(input) {
return false
}
config, ok := input["config"].(map[string]any)
if !ok {
return false
}
channelID, _ := config["channelId"].(string)
baseURL, _ := config["baseUrl"].(string)
apiKey, _ := config["apiKey"].(string)
if strings.TrimSpace(channelID) != "" || systemChannelIDFromBaseURL(baseURL) != "" {
return false
}
return strings.TrimSpace(baseURL) != "" && strings.TrimSpace(apiKey) != ""
}
func taskInputUsesSystemChannel(input map[string]any) bool {
config, ok := input["config"].(map[string]any)
if !ok {
return false
}
channelID, _ := config["channelId"].(string)
return strings.TrimSpace(channelID) != ""
}
func taskInputUsesWorkflowProvider(input map[string]any) bool {
config, ok := input["config"].(map[string]any)
if !ok {
return false
}
// 系统渠道的 interfaceType 是客户端缓存,不是授权事实;必须先走系统模型 admission,
// 不能通过伪造工作流协议绕开渠道模型、能力和价格校验。
if strings.TrimSpace(stringValue(config["channelId"])) != "" {
return false
}
return isWorkflowProviderInterface(strings.TrimSpace(fmt.Sprint(config["interfaceType"])))
}
func compactPersistedValue(value interface{}) interface{} {
switch item := value.(type) {
case map[string]interface{}:
result := make(map[string]interface{}, len(item))
for key, child := range item {
if text, ok := child.(string); ok && strings.HasPrefix(text, "data:") {
result[key] = ""
continue
}
result[key] = compactPersistedValue(child)
}
return result
case []interface{}:
result := make([]interface{}, len(item))
for index, child := range item {
result[index] = compactPersistedValue(child)
}
return result
default:
return value
}
}