diff --git a/backend/internal/app/model_capability.go b/backend/internal/app/model_capability.go index 240ab5699..f0bc69a03 100644 --- a/backend/internal/app/model_capability.go +++ b/backend/internal/app/model_capability.go @@ -23,8 +23,13 @@ type ModelCapabilityConfig struct { type TextCapabilityConfig struct { // Streaming controls whether this model accepts upstream SSE text responses. // A nil value is treated as true for backwards compatibility with older configs. - Streaming *bool `json:"streaming,omitempty"` - References TextReferenceConfig `json:"references"` + Streaming *bool `json:"streaming,omitempty"` + // ContextWindowTokens is the provider's total input plus output context window. + // It is a model contract, not an application transport ceiling. + ContextWindowTokens int `json:"contextWindowTokens"` + // MaxOutputTokens is the provider's maximum completion/reasoning budget. + MaxOutputTokens int `json:"maxOutputTokens"` + References TextReferenceConfig `json:"references"` } type TextReferenceConfig struct { @@ -209,7 +214,7 @@ func legacyImageSizeValues() []string { func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *ModelCapabilityConfig { // 文本模型是否支持视觉输入不能从协议或模型名可靠推断,默认关闭,由管理员按真实上游能力开启。 streaming := true - text := &TextCapabilityConfig{Streaming: &streaming, References: TextReferenceConfig{PromptMaxChars: 32000}} + text := &TextCapabilityConfig{Streaming: &streaming, ContextWindowTokens: 128000, MaxOutputTokens: 16384, References: TextReferenceConfig{PromptMaxChars: 32000}} video := &VideoCapabilityConfig{ References: VideoReferenceConfig{PromptMaxChars: DefaultVideoPromptMaxChars, MinImages: 0, MaxImages: 9, MaxImageBytes: 30 * 1024 * 1024, MaxVideos: 0, MaxVideoBytes: 0, MaxVideoDuration: 0, MaxAudios: 0, MaxAudioBytes: 0, MaxAudioDuration: 0}, Duration: VideoDurationConfig{Selection: "range", Min: 1, Max: 15, Step: 1, Default: 6}, @@ -327,6 +332,12 @@ func NormalizeModelCapabilityConfigForModel(capability string, protocol string, streaming := true text.Streaming = &streaming } + if text.ContextWindowTokens == 0 { + text.ContextWindowTokens = 128000 + } + if text.MaxOutputTokens == 0 { + text.MaxOutputTokens = 16384 + } value := &ModelCapabilityConfig{Version: 1, Text: &text} if err := validateTextCapabilityConfig(value.Text); err != nil { return nil, err @@ -534,6 +545,12 @@ func addInputConstraint(inputs map[string]InputConstraint, name string, min int, } func validateTextCapabilityConfig(value *TextCapabilityConfig) error { + if value.ContextWindowTokens < 4096 || value.ContextWindowTokens > 10000000 { + return BadAuthRequest("文本模型上下文窗口必须在 4096-10000000 Token 之间") + } + if value.MaxOutputTokens < 256 || value.MaxOutputTokens > 1000000 || value.MaxOutputTokens >= value.ContextWindowTokens { + return BadAuthRequest("文本模型最大输出 Token 必须小于上下文窗口且在 256-1000000 之间") + } if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 { return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间") } diff --git a/backend/internal/app/provider.go b/backend/internal/app/provider.go index 3030ef1bf..c1502b199 100644 --- a/backend/internal/app/provider.go +++ b/backend/internal/app/provider.go @@ -352,6 +352,12 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string return nil, err } input.Config = config + if input.Mode == "text" && input.Config.CapabilityConfig != nil && input.Config.CapabilityConfig.Text != nil { + // The same capability contract drives provider output limits, billing + // estimates and Agent context budgeting. Never silently fall back to a + // transport-specific fixed token count when the model declares one. + input.MaxOutputTokens = input.Config.CapabilityConfig.Text.MaxOutputTokens + } var textPublisher *taskTextStreamPublisher if input.Mode == "text" && strings.HasPrefix(taskType, "canvas_text") { requestedStream := input.TextOptions.Stream == nil || *input.TextOptions.Stream diff --git a/backend/internal/canvas/contract/generate/main.go b/backend/internal/canvas/contract/generate/main.go new file mode 100644 index 000000000..16634be9c --- /dev/null +++ b/backend/internal/canvas/contract/generate/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "os" + "path/filepath" + + "infinite-canvas/backend/internal/canvas/contract" +) + +func main() { + path := filepath.Join("..", "..", "..", "..", "web", "src", "lib", "canvas", "generation-contract.generated.ts") + if err := os.WriteFile(path, []byte(contract.TypeScriptDeclaration()), 0644); err != nil { + panic(err) + } +} diff --git a/backend/internal/canvas/contract/generation.go b/backend/internal/canvas/contract/generation.go new file mode 100644 index 000000000..7ab0b45d1 --- /dev/null +++ b/backend/internal/canvas/contract/generation.go @@ -0,0 +1,317 @@ +// Package contract owns the credential-free canvas generation contract. +// Provider configuration remains an execution concern; it is never persisted here. +package contract + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "reflect" + "strconv" + "strings" + "unicode/utf8" +) + +//go:generate go run ./generate + +const ( + GenerationVersion = 1 + MaxNodeTitleRunes = 240 + MaxPromptRunes = 16000 + MaxCanvasOperations = 20 +) + +type ModelSelection struct { + Kind string `json:"kind" enum:"logical,channel"` + LogicalModelID string `json:"logicalModelId,omitempty"` + ChannelID string `json:"channelId,omitempty"` + ModelKey string `json:"modelKey,omitempty"` +} + +// Options contains public user choices, not resolved provider configuration. +// node/task tags are the only mapping between the persisted canvas and task wire names. +type Options struct { + Size *string `json:"size,omitempty" node:"size" task:"size" modes:"image,video"` + Quality *string `json:"quality,omitempty" node:"quality" task:"quality" modes:"image"` + Count *int `json:"count,omitempty" node:"count" task:"count" modes:"image"` + TransparentBackground *bool `json:"transparentBackground,omitempty" node:"transparentBackground" task:"transparentBackground" modes:"image"` + DurationSeconds *int `json:"durationSeconds,omitempty" node:"seconds" task:"videoSeconds" modes:"video"` + Resolution *string `json:"resolution,omitempty" node:"vquality" task:"vquality" modes:"video"` + GenerateAudio *bool `json:"generateAudio,omitempty" node:"generateAudio" task:"videoGenerateAudio" modes:"video"` + Watermark *bool `json:"watermark,omitempty" node:"watermark" task:"videoWatermark" modes:"video"` + AudioVoice *string `json:"audioVoice,omitempty" node:"audioVoice" task:"audioVoice" modes:"audio"` + AudioFormat *string `json:"audioFormat,omitempty" node:"audioFormat" task:"audioFormat" modes:"audio"` + AudioSpeed *string `json:"audioSpeed,omitempty" node:"audioSpeed" task:"audioSpeed" modes:"audio"` + AudioInstructions *string `json:"audioInstructions,omitempty" node:"audioInstructions" task:"audioInstructions" modes:"audio"` +} + +type ReferenceBinding struct { + ID string `json:"id"` + NodeID string `json:"nodeId,omitempty"` + ResourceID string `json:"resourceId,omitempty"` + TransientID string `json:"transientId,omitempty"` + MediaType string `json:"mediaType" enum:"image,video,audio,text"` + Role string `json:"role" enum:"reference,source-text,first-frame,last-frame,mask"` + Order int `json:"order"` + Resolution string `json:"resolution" enum:"latest,snapshot"` +} + +type GenerationSpec struct { + Version int `json:"version"` + Mode string `json:"mode" enum:"image,video,audio"` + Prompt string `json:"prompt"` + ModelSelection *ModelSelection `json:"modelSelection,omitempty"` + Options Options `json:"options"` + ReferenceBindings []ReferenceBinding `json:"referenceBindings"` + TextInputMode string `json:"textInputMode" enum:"prompt-only,append-sources"` +} + +type FieldError struct{ Path, Message string } + +func (e *FieldError) Error() string { return e.Path + ": " + e.Message } +func invalid(path, message string) error { return &FieldError{Path: path, Message: message} } + +func (s ModelSelection) Validate() error { + switch s.Kind { + case "logical": + if strings.TrimSpace(s.LogicalModelID) == "" || s.ChannelID != "" || s.ModelKey != "" { + return invalid("modelSelection", "逻辑模型必须且只能指定 logicalModelId") + } + case "channel": + if strings.TrimSpace(s.ChannelID) == "" || strings.TrimSpace(s.ModelKey) == "" || s.LogicalModelID != "" { + return invalid("modelSelection", "渠道模型必须且只能指定 channelId 和 modelKey") + } + default: + return invalid("modelSelection.kind", "未知模型选择类型") + } + return nil +} + +func (s GenerationSpec) Validate() error { + if s.Version != GenerationVersion { + return invalid("generationSpec.version", "生成合同版本不受支持") + } + if s.Mode != "image" && s.Mode != "video" && s.Mode != "audio" { + return invalid("generationSpec.mode", "未知生成模式") + } + if utf8.RuneCountInString(s.Prompt) > MaxPromptRunes { + return invalid("generationSpec.prompt", "提示词超过长度限制") + } + if s.ModelSelection != nil { + if err := s.ModelSelection.Validate(); err != nil { + return err + } + } + if s.TextInputMode != "prompt-only" && s.TextInputMode != "append-sources" { + return invalid("generationSpec.textInputMode", "未知文本输入模式") + } + value, typ := reflect.ValueOf(s.Options), reflect.TypeOf(s.Options) + for i := 0; i < typ.NumField(); i++ { + if value.Field(i).IsNil() { + continue + } + field := typ.Field(i) + if !strings.Contains(","+field.Tag.Get("modes")+",", ","+s.Mode+",") { + return invalid("options."+strings.Split(field.Tag.Get("json"), ",")[0], "该选项不适用于当前生成模式") + } + } + if s.Options.DurationSeconds != nil && *s.Options.DurationSeconds < 0 { + return invalid("options.durationSeconds", "时长不能为负数") + } + if s.Options.Count != nil && *s.Options.Count < 1 { + return invalid("options.count", "数量必须为正整数") + } + ids := map[string]bool{} + orders := map[int]bool{} + for i, binding := range s.ReferenceBindings { + path := fmt.Sprintf("referenceBindings[%d]", i) + if binding.ID == "" || ids[binding.ID] || binding.Order < 0 || orders[binding.Order] { + return invalid(path, "引用身份和顺序必须有效且唯一") + } + ids[binding.ID], orders[binding.Order] = true, true + if binding.NodeID == "" && binding.ResourceID == "" && binding.TransientID == "" { + return invalid(path, "引用缺少来源") + } + if binding.TransientID != "" && (binding.NodeID != "" || binding.ResourceID != "") { + return invalid(path, "临时引用不能混用其他来源") + } + if binding.Resolution != "latest" && binding.Resolution != "snapshot" { + return invalid(path+".resolution", "未知引用解析策略") + } + if binding.MediaType != "image" && binding.MediaType != "video" && binding.MediaType != "audio" && binding.MediaType != "text" { + return invalid(path+".mediaType", "未知参考类型") + } + if binding.Role != "reference" && binding.Role != "source-text" && binding.Role != "first-frame" && binding.Role != "last-frame" && binding.Role != "mask" { + return invalid(path+".role", "未知参考角色") + } + if binding.Role == "source-text" && binding.MediaType != "text" { + return invalid(path+".role", "文本来源必须引用文本") + } + if (binding.Role == "first-frame" || binding.Role == "last-frame" || binding.Role == "mask") && binding.MediaType != "image" { + return invalid(path+".role", "帧和遮罩必须引用图片") + } + } + return nil +} + +func Decode(data []byte) (GenerationSpec, error) { + var spec GenerationSpec + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&spec); err != nil { + return spec, invalid("generationSpec", err.Error()) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return spec, invalid("generationSpec", "必须是单个对象") + } + return spec, spec.Validate() +} + +// OptionsFromTaskConfig only copies public fields declared above. Credentials and +// provider routing fields cannot be admitted by adding a task config key. +func OptionsFromTaskConfig(mode string, config map[string]any) (Options, error) { + options := Options{} + value, typ := reflect.ValueOf(&options).Elem(), reflect.TypeOf(options) + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if !strings.Contains(","+field.Tag.Get("modes")+",", ","+mode+",") { + continue + } + raw, exists := config[field.Tag.Get("task")] + if !exists || raw == nil || raw == "" { + continue + } + parsed := reflect.New(field.Type.Elem()) + switch field.Type.Elem().Kind() { + case reflect.String: + text, ok := raw.(string) + if !ok { + return options, invalid("options."+field.Tag.Get("task"), "必须是字符串") + } + parsed.Elem().SetString(text) + case reflect.Bool: + boolean, err := strconv.ParseBool(fmt.Sprint(raw)) + if err != nil { + return options, invalid("options."+field.Tag.Get("task"), "必须是布尔值") + } + parsed.Elem().SetBool(boolean) + case reflect.Int: + number, err := strconv.Atoi(fmt.Sprint(raw)) + if err != nil { + return options, invalid("options."+field.Tag.Get("task"), "必须是整数") + } + parsed.Elem().SetInt(int64(number)) + } + value.Field(i).Set(parsed) + } + return options, nil +} + +func (o Options) TaskConfig() map[string]any { + result := map[string]any{} + value, typ := reflect.ValueOf(o), reflect.TypeOf(o) + for i := 0; i < typ.NumField(); i++ { + if !value.Field(i).IsNil() { + result[typ.Field(i).Tag.Get("task")] = fmt.Sprint(value.Field(i).Elem().Interface()) + } + } + return result +} + +// NodeMetadata projects the editable specification into the existing canvas wire +// format. The mirror fields keep existing renderers working; all conversions live here. +func (s GenerationSpec) NodeMetadata() (map[string]any, error) { + if err := s.Validate(); err != nil { + return nil, err + } + encoded, err := json.Marshal(s) + if err != nil { + return nil, err + } + var stored map[string]any + if err := json.Unmarshal(encoded, &stored); err != nil { + return nil, err + } + result := map[string]any{"generationSpec": stored, "prompt": s.Prompt, "composerContent": s.Prompt} + value, typ := reflect.ValueOf(s.Options), reflect.TypeOf(s.Options) + for i := 0; i < typ.NumField(); i++ { + if value.Field(i).IsNil() { + continue + } + fieldValue := value.Field(i).Elem().Interface() + if typ.Field(i).Tag.Get("node") != "count" { + fieldValue = fmt.Sprint(fieldValue) + } + result[typ.Field(i).Tag.Get("node")] = fieldValue + } + if selection := s.ModelSelection; selection != nil { + if selection.Kind == "logical" { + result["logicalModelId"] = selection.LogicalModelID + } else { + result["channelId"], result["channelModelKey"] = selection.ChannelID, selection.ModelKey + result["model"] = selection.ChannelID + "::" + selection.ModelKey + } + } + return result, nil +} + +// NodeGenerationProjection is the explicit dependency read-set of generation. +// Locks, task binding and authorization are checked separately by the caller. +// +// The projection keeps the canonical contract fields and also carries stable +// extension metadata. Canvas presentation fields are deliberately excluded so +// moving/resizing a node does not invalidate a prepared generation, while a +// newly-added generation input cannot silently bypass admission. +func NodeGenerationProjection(metadata map[string]any) (map[string]any, error) { + result := map[string]any{} + if raw, exists := metadata["generationSpec"]; exists { + encoded, err := json.Marshal(raw) + if err != nil { + return nil, err + } + spec, err := Decode(encoded) + if err != nil { + return nil, err + } + result["generationSpec"] = spec + } + known := map[string]struct{}{ + "generationSpec": {}, "prompt": {}, "composerContent": {}, "model": {}, + "logicalModelId": {}, "channelId": {}, "channelModelKey": {}, + "videoStartFrameNodeId": {}, "videoEndFrameNodeId": {}, + "videoEditOperation": {}, "referenceNodeIds": {}, + } + typ := reflect.TypeOf(Options{}) + for i := 0; i < typ.NumField(); i++ { + known[typ.Field(i).Tag.Get("node")] = struct{}{} + } + for key, value := range metadata { + if _, exists := known[key]; exists { + if key != "generationSpec" { + result[key] = value + } + continue + } + if isCanvasPresentationMetadataKey(key) { + continue + } + extensions, _ := result["extensions"].(map[string]any) + if extensions == nil { + extensions = map[string]any{} + result["extensions"] = extensions + } + extensions[key] = value + } + return result, nil +} + +func isCanvasPresentationMetadataKey(key string) bool { + switch key { + case "position", "x", "y", "width", "height", "createdAt", "updatedAt", "selected", "collapsed", "zIndex", "color", "background", "displayName", "label", "ui", "presentation", "layout", "screenPosition", "isSelected", "dragging", "resizing": + return true + default: + return false + } +} diff --git a/backend/internal/canvas/contract/typescript.go b/backend/internal/canvas/contract/typescript.go new file mode 100644 index 000000000..bf856423e --- /dev/null +++ b/backend/internal/canvas/contract/typescript.go @@ -0,0 +1,64 @@ +package contract + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// TypeScriptDeclaration is generated from the public Go contract, never from +// provider configuration or an OpenAPI client schema. +func TypeScriptDeclaration() string { + var out strings.Builder + out.WriteString("// Generated by go generate ./internal/canvas/contract. Do not edit.\n") + fmt.Fprintf(&out, "export const GENERATION_CONTRACT_VERSION = %d;\nexport const GENERATION_LIMITS = { nodeTitle: %d, prompt: %d, canvasOperations: %d } as const;\n\n", GenerationVersion, MaxNodeTitleRunes, MaxPromptRunes, MaxCanvasOperations) + out.WriteString("export type ModelSelection = { kind: \"logical\"; logicalModelId: string; channelId?: never; modelKey?: never } | { kind: \"channel\"; channelId: string; modelKey: string; logicalModelId?: never };\n\n") + for _, typ := range []reflect.Type{reflect.TypeOf(Options{}), reflect.TypeOf(ReferenceBinding{}), reflect.TypeOf(GenerationSpec{})} { + fmt.Fprintf(&out, "export type %s = {\n", typ.Name()) + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + parts := strings.Split(field.Tag.Get("json"), ",") + optional := "" + if len(parts) > 1 && parts[1] == "omitempty" { + optional = "?" + } + valueType := tsType(field.Type) + if values := field.Tag.Get("enum"); values != "" { + quoted := []string{} + for _, value := range strings.Split(values, ",") { + quoted = append(quoted, fmt.Sprintf("%q", value)) + } + valueType = strings.Join(quoted, " | ") + } + fmt.Fprintf(&out, " %s%s: %s;\n", parts[0], optional, valueType) + } + out.WriteString("};\n\n") + } + fields := []map[string]any{} + typ := reflect.TypeOf(Options{}) + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + fields = append(fields, map[string]any{"name": strings.Split(field.Tag.Get("json"), ",")[0], "node": field.Tag.Get("node"), "task": field.Tag.Get("task"), "kind": tsType(field.Type), "modes": strings.Split(field.Tag.Get("modes"), ",")}) + } + encoded, _ := json.MarshalIndent(fields, "", " ") + fmt.Fprintf(&out, "export const GENERATION_OPTION_FIELDS = %s as const;\n", encoded) + return out.String() +} + +func tsType(typ reflect.Type) string { + switch typ.Kind() { + case reflect.Pointer: + return tsType(typ.Elem()) + case reflect.Slice: + return tsType(typ.Elem()) + "[]" + case reflect.String: + return "string" + case reflect.Bool: + return "boolean" + case reflect.Int: + return "number" + default: + return typ.Name() + } +} diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx index 5c3c6c972..729a75224 100644 --- a/web/src/components/canvas/canvas-config-node-panel.tsx +++ b/web/src/components/canvas/canvas-config-node-panel.tsx @@ -11,6 +11,7 @@ import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-ge import { defaultModelCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, workflowFieldChoiceValues, workflowFieldCurrentValue, workflowFieldKey, workflowFieldNumberBounds, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoFieldsFromJson, type WorkflowVideoFieldLike } from "@/lib/model-capabilities"; import { defaultImageParamsForModel, modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection"; import { resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow"; +import { canonicalGenerationMetadata } from "@/lib/canvas/generation-contract"; import type { CanvasAudioSettingKey } from "./canvas-audio-settings-popover"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { workflowProviderPluginEnabled } from "@/lib/plugins/builtin/workflows"; @@ -66,6 +67,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC const runtimeStatuses = usePluginStore((state) => state.runtimeStatuses); const theme = canvasThemes[useActiveTheme()]; const mode = node.metadata?.generationMode === "video" || node.metadata?.generationMode === "audio" ? node.metadata.generationMode : "image"; + node = { ...node, metadata: canonicalGenerationMetadata(node, mode) }; const simpleMode = workspaceMode === "simple"; const resolvedProvider = resolveCanvasWorkflowProvider(node.metadata); const workflowProvider = resolvedProvider; @@ -74,16 +76,16 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC capability: mode, input: inputSummary, videoOperation: node.metadata?.videoEditOperation, - videoSeconds: mode === "video" ? node.metadata?.seconds || globalConfig.videoSeconds : undefined, + videoSeconds: mode === "video" ? node.metadata?.seconds ?? globalConfig.videoSeconds : undefined, options: modelRequestOptions({ ...globalConfig, size: node.metadata?.size || globalConfig.size, quality: node.metadata?.quality || globalConfig.quality, count: String(node.metadata?.count || globalConfig.count), - videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, + videoSeconds: node.metadata?.seconds ?? globalConfig.videoSeconds, vquality: node.metadata?.vquality || globalConfig.vquality, - videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio, - videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark, + videoGenerateAudio: node.metadata?.generateAudio ?? globalConfig.videoGenerateAudio, + videoWatermark: node.metadata?.watermark ?? globalConfig.videoWatermark, audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice, audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat, audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed, @@ -450,6 +452,7 @@ function WorkflowSelectedLabel({ kind, label }: { kind: "app" | "workflow"; labe } function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode, requirements: ModelRequirements): AiConfig { + node = { ...node, metadata: canonicalGenerationMetadata(node, mode) }; const workflowProvider = mode === "text" ? "model" : resolveCanvasWorkflowProvider(node.metadata); if (workflowProvider === "model") return buildModelNodeConfig(globalConfig, node, mode, requirements); const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel; @@ -476,7 +479,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can const workflowParameter = (source: string) => workflowParameters[`source:${source}`] === undefined ? "" : String(workflowParameters[`source:${source}`]); const normalizedImage = imageProfile ? normalizeImageValue(imageProfile, { size: workflowOutputSize || node.metadata?.size || globalConfig.size || defaultConfig.size, quality: node.metadata?.quality || workflowParameter("quality") || globalConfig.quality || defaultConfig.quality, transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground, count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count) }) : undefined; const videoProfile = mode === "video" ? capabilityProfile.video! : undefined; - const rawVideoSettings = { seconds: node.metadata?.seconds || workflowParameter("videoSeconds") || globalConfig.videoSeconds || defaultConfig.videoSeconds, ratio: workflowOutputSize || node.metadata?.size || globalConfig.size || defaultConfig.size, resolution: node.metadata?.vquality || workflowParameter("vquality") || globalConfig.vquality || defaultConfig.vquality }; + const rawVideoSettings = { seconds: node.metadata?.seconds ?? workflowParameter("videoSeconds") ?? globalConfig.videoSeconds ?? defaultConfig.videoSeconds, ratio: workflowOutputSize || node.metadata?.size || globalConfig.size || defaultConfig.size, resolution: node.metadata?.vquality || workflowParameter("vquality") || globalConfig.vquality || defaultConfig.vquality }; const normalizedVideo = videoProfile ? { seconds: String(rawVideoSettings.seconds), ratio: rawVideoSettings.ratio, resolution: rawVideoSettings.resolution } : undefined; @@ -489,10 +492,10 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can quality: workflowParameter("quality") || normalizedImage?.quality || node.metadata?.quality || globalConfig.quality || defaultConfig.quality, size: normalizedImage?.size || normalizedVideo?.ratio || node.metadata?.size || globalConfig.size || defaultConfig.size, transparentBackground: normalizedImage?.transparentBackground || ((node.metadata?.transparentBackground || globalConfig.transparentBackground) === "true" ? "true" : "false"), - videoSeconds: normalizedVideo?.seconds || normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds), + videoSeconds: normalizedVideo?.seconds ?? normalizeVideoDuration(node.metadata?.seconds ?? globalConfig.videoSeconds ?? defaultConfig.videoSeconds), vquality: String(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality), - videoGenerateAudio: videoProfile?.generateAudio.supported ? node.metadata?.generateAudio || globalConfig.videoGenerateAudio || String(videoProfile.generateAudio.default) : "false", - videoWatermark: videoProfile?.watermark.supported ? node.metadata?.watermark || globalConfig.videoWatermark || String(videoProfile.watermark.default) : "false", + videoGenerateAudio: videoProfile?.generateAudio.supported ? node.metadata?.generateAudio ?? globalConfig.videoGenerateAudio ?? String(videoProfile.generateAudio.default) : "false", + videoWatermark: videoProfile?.watermark.supported ? node.metadata?.watermark ?? globalConfig.videoWatermark ?? String(videoProfile.watermark.default) : "false", audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice, audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat, audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed, @@ -502,6 +505,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can } function buildModelNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode, requirements: ModelRequirements): AiConfig { + node = { ...node, metadata: canonicalGenerationMetadata(node, mode) }; const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel; const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel; const storedModel = node.metadata?.model; @@ -544,10 +548,10 @@ function buildModelNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode quality: generationDefaults.quality || globalConfig.quality || defaultConfig.quality, size: generationDefaults.size ?? globalConfig.size ?? defaultConfig.size, transparentBackground: generationDefaults.transparentBackground || "false", - videoSeconds: generationDefaults.videoSeconds || normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds), + videoSeconds: generationDefaults.videoSeconds ?? normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds), vquality: generationDefaults.vquality ?? normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality), - videoGenerateAudio: videoProfile?.generateAudio.supported ? generationDefaults.videoGenerateAudio || String(videoProfile.generateAudio.default) : "false", - videoWatermark: videoProfile?.watermark.supported ? generationDefaults.videoWatermark || String(videoProfile.watermark.default) : "false", + videoGenerateAudio: videoProfile?.generateAudio.supported ? generationDefaults.videoGenerateAudio ?? String(videoProfile.generateAudio.default) : "false", + videoWatermark: videoProfile?.watermark.supported ? generationDefaults.videoWatermark ?? String(videoProfile.watermark.default) : "false", audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice, audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat, audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed, diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 60ac2acee..34a23062e 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -6,6 +6,7 @@ import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, import { ModelPicker } from "@/components/model-picker"; import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation"; +import { canonicalGenerationMetadata } from "@/lib/canvas/generation-contract"; import { clampPromptEditorModalSize, PROMPT_EDITOR_VIEWPORT_MARGIN } from "@/lib/canvas/canvas-prompt-editor-size"; import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { canvasThemes } from "@/lib/canvas-theme"; @@ -78,6 +79,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled)); const simpleMode = workspaceMode === "simple"; const mode = defaultMode(node.type); + node = { ...node, metadata: canonicalGenerationMetadata(node, mode) }; const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim()); const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content); const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? ""; @@ -106,17 +108,17 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan characterCount: activeReferences.filter((item) => item.kind === "character").length, }, videoOperation: node.metadata?.videoEditOperation, - videoSeconds: mode === "video" ? node.metadata?.seconds || globalConfig.videoSeconds : undefined, + videoSeconds: mode === "video" ? node.metadata?.seconds ?? globalConfig.videoSeconds : undefined, options: modelRequestOptions({ ...globalConfig, size: node.metadata?.size || globalConfig.size, quality: node.metadata?.quality || globalConfig.quality, count: String(node.metadata?.count || globalConfig.count), transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground, - videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, + videoSeconds: node.metadata?.seconds ?? globalConfig.videoSeconds, vquality: node.metadata?.vquality || globalConfig.vquality, - videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio, - videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark, + videoGenerateAudio: node.metadata?.generateAudio ?? globalConfig.videoGenerateAudio, + videoWatermark: node.metadata?.watermark ?? globalConfig.videoWatermark, audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice, audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat, audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed, @@ -1034,6 +1036,7 @@ function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode { } export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode, requirements: ModelRequirements): AiConfig { + node = { ...node, metadata: canonicalGenerationMetadata(node, mode) }; const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel; const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel; const preferredModel = resolveCanvasGenerationModel(globalConfig, node.metadata?.model, mode) || resolveCanvasGenerationModel(globalConfig, defaultModel, mode) || fallbackModel; @@ -1074,7 +1077,7 @@ export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mo quality: defaults.quality ?? globalConfig.quality ?? defaultConfig.quality, size: defaults.size ?? globalConfig.size ?? defaultConfig.size, transparentBackground: defaults.transparentBackground ?? "false", - videoSeconds: defaults.videoSeconds || normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds), + videoSeconds: defaults.videoSeconds ?? normalizeVideoDuration(globalConfig.videoSeconds ?? defaultConfig.videoSeconds), vquality: defaults.vquality ?? normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality), videoGenerateAudio: defaults.videoGenerateAudio ?? globalConfig.videoGenerateAudio ?? defaultConfig.videoGenerateAudio, videoWatermark: defaults.videoWatermark ?? globalConfig.videoWatermark ?? defaultConfig.videoWatermark, diff --git a/web/src/components/model-capability-editor.tsx b/web/src/components/model-capability-editor.tsx index 29601757a..4e9dcad19 100644 --- a/web/src/components/model-capability-editor.tsx +++ b/web/src/components/model-capability-editor.tsx @@ -350,7 +350,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability = } function TextCapabilityEditor({ value, onChange, protocol, disabled, section }: Pick) { - const profile = value?.text || defaultModelCapabilityConfig(protocol).text!; + const profile = normalizeModelCapabilityConfig(value || defaultModelCapabilityConfig(protocol)).text!; const update = (patch: Partial) => { onChange?.({ version: 1, text: { ...profile, ...patch } }); }; @@ -369,8 +369,17 @@ function TextCapabilityEditor({ value, onChange, protocol, disabled, section }: } if (section === "references") { + const contextWindowFields = ( + +
+ update({ contextWindowTokens: next || 4_096 })} /> + update({ maxOutputTokens: next || 256 })} /> +
+
+ ); return (
+ {contextWindowFields}
updateReferences({ maxImages: next || 0 })} /> @@ -394,6 +403,12 @@ function TextCapabilityEditor({ value, onChange, protocol, disabled, section }:
文本理解能力
默认不假设支持图片或视频,只有明确配置后相关请求才会进入该模型。
+ +
+ update({ contextWindowTokens: next || 4_096 })} /> + update({ maxOutputTokens: next || 256 })} /> +
+
updateReferences({ maxImages: next || 0 })} /> diff --git a/web/src/lib/canvas/canvas-node-prompt.test.ts b/web/src/lib/canvas/canvas-node-prompt.test.ts index eeec54936..2ae2b2266 100644 --- a/web/src/lib/canvas/canvas-node-prompt.test.ts +++ b/web/src/lib/canvas/canvas-node-prompt.test.ts @@ -29,12 +29,12 @@ test("媒体节点已有结果时只更新下一版提示词草稿", () => { }); assert.equal(canvasNodeHasCommittedContent(current), true); - assert.deepEqual(writeCanvasNodePrompt(current, "下一版提示词").metadata, { - content: `https://example.com/${type}`, - prompt: "已提交提示词", - composerContent: "下一版提示词", - status: "success", - }); + const metadata = writeCanvasNodePrompt(current, "下一版提示词").metadata; + assert.equal(metadata?.content, `https://example.com/${type}`); + assert.equal(metadata?.prompt, "已提交提示词"); + assert.equal(metadata?.composerContent, "下一版提示词"); + assert.equal(metadata?.status, "success"); + assert.equal(metadata?.generationSpec?.prompt, "下一版提示词"); } }); @@ -42,11 +42,11 @@ test("空媒体草稿同时初始化提交提示词和编辑提示词", () => { const current = node(CanvasNodeType.Video, { status: "idle" }); assert.equal(canvasNodeHasCommittedContent(current), false); - assert.deepEqual(writeCanvasNodePrompt(current, "第一版提示词").metadata, { - status: "idle", - prompt: "第一版提示词", - composerContent: "第一版提示词", - }); + const metadata = writeCanvasNodePrompt(current, "第一版提示词").metadata; + assert.equal(metadata?.status, "idle"); + assert.equal(metadata?.prompt, "第一版提示词"); + assert.equal(metadata?.composerContent, "第一版提示词"); + assert.equal(metadata?.generationSpec?.prompt, "第一版提示词"); }); test("图片仅保留 storageKey 时仍视为已有媒体结果", () => { diff --git a/web/src/lib/canvas/canvas-node-prompt.ts b/web/src/lib/canvas/canvas-node-prompt.ts index 454c9597e..aa15065ab 100644 --- a/web/src/lib/canvas/canvas-node-prompt.ts +++ b/web/src/lib/canvas/canvas-node-prompt.ts @@ -1,5 +1,6 @@ import { getNodeResourceKind } from "@/lib/canvas/node-registry"; import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas"; +import { synchronizeGenerationSpec } from "@/lib/canvas/generation-contract"; export type WriteCanvasNodePromptOptions = { metadata?: CanvasNodeMetadata; @@ -28,10 +29,10 @@ export function writeCanvasNodePrompt(node: CanvasNodeData, prompt: string, opti const promptTemplateMetadata = clearPromptTemplate ? { promptTemplateOperation: undefined, promptTemplateVariables: undefined } : {}; - return { + return synchronizeGenerationSpec({ ...node, metadata: canvasNodeHasCommittedContent(node) ? { ...metadata, ...promptTemplateMetadata, composerContent: prompt } : { ...metadata, ...promptTemplateMetadata, prompt, composerContent: prompt }, - }; + }); } diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts index 090dcf6f9..e489e2290 100644 --- a/web/src/lib/canvas/canvas-project-domain.ts +++ b/web/src/lib/canvas/canvas-project-domain.ts @@ -13,6 +13,7 @@ import { batchReferenceHandleY } from "@/lib/canvas/canvas-batch-table"; import { reconcileImageBatchRoot } from "@/lib/canvas/canvas-image-batch-retry"; import { scopedLocalStorage } from "@/lib/user-scope"; import type { GenerationTask } from "@/services/api/task-center"; +import { synchronizeGenerationSpec } from "@/lib/canvas/generation-contract"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas"; export function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData { @@ -183,7 +184,7 @@ const NODE_MODEL_GENERATION_PARAMS: ReadonlyArray = [ export function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial) { const safePatch = patch || {}; const nextPatch = resetGenerationParamsOnModelSwitch(node, safePatch); - const next = { ...node, metadata: { ...node.metadata, ...nextPatch } }; + const next = synchronizeGenerationSpec(node, nextPatch); const spec = node.type === CanvasNodeType.Video ? NODE_DEFAULT_SIZE[CanvasNodeType.Video] : NODE_DEFAULT_SIZE[CanvasNodeType.Image]; const size = typeof safePatch.size === "string" && !node.metadata?.content ? nodeSizeFromRatio(safePatch.size, spec.width, spec.height) : null; return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next; diff --git a/web/src/lib/canvas/canvas-project-generation.ts b/web/src/lib/canvas/canvas-project-generation.ts index 799a00b46..a3f4fb057 100644 --- a/web/src/lib/canvas/canvas-project-generation.ts +++ b/web/src/lib/canvas/canvas-project-generation.ts @@ -17,6 +17,7 @@ import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-p import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasImageGenerationType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasVideoEditOperation } from "@/types/canvas"; import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; +import { generationSpecMetadata, readNodeGenerationSpec, resolveGenerationSelection } from "@/lib/canvas/generation-contract"; export async function runBackendCanvasGenerationTask( { @@ -416,12 +417,18 @@ export function generationWorkflowMetadata(config: AiConfig): Pick { + if (!value || typeof value !== "object" || Array.isArray(value)) return fieldError(path, "必须是对象"); + return value as Record; +} + +function allowKeys(value: Record, keys: string[], path: string) { + const unexpected = Object.keys(value).find((key) => !keys.includes(key)); + if (unexpected) fieldError(path, "包含不支持的字段,请更新生成配置"); +} + +function optionValue(value: unknown, kind: string, path: string) { + if (kind === "boolean") { + if (value === true || value === "true") return true; + if (value === false || value === "false") return false; + } else if (kind === "number") { + const number = typeof value === "number" ? value : typeof value === "string" && /^-?\d+$/.test(value) ? Number(value) : NaN; + if (Number.isSafeInteger(number)) return number; + } else if (typeof value === "string") return value; + return fieldError(path, `必须是${kind === "boolean" ? "布尔值" : kind === "number" ? "整数" : "文本"}`); +} + +export function validateGenerationSpec(value: unknown): GenerationSpec { + const raw = object(value, "generationSpec"); + allowKeys(raw, ["version", "mode", "prompt", "modelSelection", "options", "referenceBindings", "textInputMode"], "generationSpec"); + if (raw.version !== GENERATION_CONTRACT_VERSION) fieldError("version", "合同版本不受支持"); + const mode = typeof raw.mode === "string" ? canvasGenerationMode(raw.mode) : undefined; + if (!mode) fieldError("mode", "不支持的生成模式"); + if (typeof raw.prompt !== "string" || Array.from(raw.prompt).length > GENERATION_LIMITS.prompt) fieldError("prompt", "提示词无效或超出长度限制"); + if (raw.textInputMode !== "prompt-only" && raw.textInputMode !== "append-sources") fieldError("textInputMode", "未知文本输入模式"); + let modelSelection: ModelSelection | undefined; + if (raw.modelSelection !== undefined) { + const selection = object(raw.modelSelection, "modelSelection"); + if (selection.kind === "logical") { + allowKeys(selection, ["kind", "logicalModelId"], "modelSelection"); + if (typeof selection.logicalModelId !== "string" || !selection.logicalModelId.trim()) fieldError("modelSelection.logicalModelId", "不能为空"); + modelSelection = { kind: "logical", logicalModelId: selection.logicalModelId }; + } else if (selection.kind === "channel") { + allowKeys(selection, ["kind", "channelId", "modelKey"], "modelSelection"); + if (typeof selection.channelId !== "string" || !selection.channelId.trim() || typeof selection.modelKey !== "string" || !selection.modelKey.trim()) fieldError("modelSelection", "渠道与模型标识不能为空"); + modelSelection = { kind: "channel", channelId: selection.channelId, modelKey: selection.modelKey }; + } else fieldError("modelSelection.kind", "未知模型选择类型"); + } + const rawOptions = object(raw.options, "options"); + allowKeys(rawOptions, GENERATION_OPTION_FIELDS.map((field) => field.name), "options"); + const options: Record = {}; + for (const field of GENERATION_OPTION_FIELDS) { + const current = rawOptions[field.name]; + if (current === undefined) continue; + if (!(field.modes as readonly string[]).includes(mode)) fieldError(`options.${field.name}`, "不适用于当前模式"); + if (typeof current !== field.kind) fieldError(`options.${field.name}`, "合同类型不正确"); + options[field.name] = optionValue(current, field.kind, `options.${field.name}`); + } + if (typeof options.durationSeconds === "number" && options.durationSeconds < 0) fieldError("options.durationSeconds", "不能为负数"); + if (typeof options.count === "number" && options.count < 1) fieldError("options.count", "必须为正整数"); + if (!Array.isArray(raw.referenceBindings)) fieldError("referenceBindings", "必须是数组"); + const ids = new Set(); + const orders = new Set(); + const referenceBindings = raw.referenceBindings.map((value, index): ReferenceBinding => { + const ref = object(value, `referenceBindings[${index}]`); + allowKeys(ref, ["id", "nodeId", "resourceId", "transientId", "mediaType", "role", "order", "resolution"], "referenceBindings"); + if (typeof ref.id !== "string" || !ref.id || ids.has(ref.id)) fieldError("referenceBindings.id", "必须有效且唯一"); + if (typeof ref.order !== "number" || !Number.isSafeInteger(ref.order) || ref.order < 0 || orders.has(ref.order)) fieldError("referenceBindings.order", "必须有效且唯一"); + for (const key of ["nodeId", "resourceId", "transientId"]) if (ref[key] !== undefined && (typeof ref[key] !== "string" || !ref[key])) fieldError(`referenceBindings.${key}`, "必须是有效标识"); + if (!ref.nodeId && !ref.resourceId && !ref.transientId) fieldError("referenceBindings", "缺少引用来源"); + if (ref.transientId && (ref.nodeId || ref.resourceId)) fieldError("referenceBindings", "临时引用不能混用其他来源"); + if (!["image", "video", "audio", "text"].includes(String(ref.mediaType))) fieldError("referenceBindings.mediaType", "未知参考类型"); + if (!["reference", "source-text", "first-frame", "last-frame", "mask"].includes(String(ref.role))) fieldError("referenceBindings.role", "未知参考角色"); + if (ref.role === "source-text" && ref.mediaType !== "text") fieldError("referenceBindings.role", "文本来源必须引用文本"); + if (["first-frame", "last-frame", "mask"].includes(String(ref.role)) && ref.mediaType !== "image") fieldError("referenceBindings.role", "帧和遮罩必须引用图片"); + if (ref.resolution !== "latest" && ref.resolution !== "snapshot") fieldError("referenceBindings.resolution", "未知解析策略"); + ids.add(ref.id); orders.add(ref.order); + return ref as unknown as ReferenceBinding; + }); + return { version: GENERATION_CONTRACT_VERSION, mode, prompt: raw.prompt, options, modelSelection, referenceBindings, textInputMode: raw.textInputMode } as GenerationSpec; +} + +/** The single compatibility boundary for the existing flat canvas wire format. */ +export function readNodeGenerationSpec(node: Pick): GenerationSpec | undefined { + const mode = canvasGenerationMode(node.type); + if (!mode) return undefined; + const metadata = (node.metadata || {}) as Record; + const stored = metadata.generationSpec ? validateGenerationSpec(metadata.generationSpec) : undefined; + const options: Record = { ...stored?.options }; + // generationSpec is the canonical contract once present. Legacy node fields are only + // read for migration, otherwise a stale compatibility mirror can silently overwrite + // an explicitly stored false/zero value. + if (!stored) { + for (const field of GENERATION_OPTION_FIELDS) { + if (!(field.modes as readonly string[]).includes(mode)) continue; + if (!(field.node in metadata)) continue; + const value = metadata[field.node]; + if (value === undefined || value === null || value === "") delete options[field.name]; + else options[field.name] = optionValue(value, field.kind, field.node); + } + } + const modelSelection = stored?.modelSelection + || (typeof metadata.logicalModelId === "string" && metadata.logicalModelId ? { kind: "logical", logicalModelId: metadata.logicalModelId } as const : undefined) + || (typeof metadata.channelId === "string" && typeof metadata.channelModelKey === "string" ? { kind: "channel", channelId: metadata.channelId, modelKey: metadata.channelModelKey } as const : undefined) + || (typeof metadata.model === "string" ? (() => { + const model = decodeChannelModel(metadata.model); + return model ? { kind: "channel", channelId: model.channelId, modelKey: model.model } as const : undefined; + })() : undefined); + const prompt = stored?.prompt + ?? (typeof metadata.composerContent === "string" ? metadata.composerContent : typeof metadata.prompt === "string" ? metadata.prompt : ""); + return validateGenerationSpec({ version: GENERATION_CONTRACT_VERSION, mode, prompt, options, ...(modelSelection ? { modelSelection } : {}), referenceBindings: stored?.referenceBindings || [], textInputMode: stored?.textInputMode || (mode === "video" ? "prompt-only" : "append-sources") }); +} + + +export function canonicalGenerationMetadata(node: CanvasNodeData, mode: string): CanvasNodeMetadata { + if (!canvasGenerationMode(mode)) return { ...node.metadata }; + const spec = readNodeGenerationSpec(node); + return spec?.mode === mode ? { ...node.metadata, ...generationSpecMetadata(spec) } : { ...node.metadata }; +} + +export function resolveGenerationSelection(config: AiConfig, selection: ModelSelection | undefined): string { + if (!selection) return ""; + if (selection.kind === "channel") { + const channel = config.channels.find((item) => item.id === selection.channelId && item.enabled !== false); + return channel?.models.includes(selection.modelKey) ? encodeChannelModel(channel.id, selection.modelKey) : ""; + } + for (const channel of config.channels) { + if (channel.enabled === false) continue; + const model = channel.modelCosts?.find((item) => item.logicalModelId === selection.logicalModelId && channel.models.includes(item.model)); + if (model) return encodeChannelModel(channel.id, model.model); + } + return ""; +} + +export function selectionForConfig(config: AiConfig): ModelSelection | undefined { + const selected = decodeChannelModel(config.model); + if (!selected) return undefined; + const logicalModelId = config.channels.find((channel) => channel.id === selected.channelId)?.modelCosts?.find((item) => item.model === selected.model)?.logicalModelId; + return logicalModelId ? { kind: "logical", logicalModelId } : { kind: "channel", channelId: selected.channelId, modelKey: selected.model }; +} + +export function specFromConfig(mode: GenerationSpec["mode"], prompt: string, config: AiConfig, referenceBindings: ReferenceBinding[] = [], textInputMode: GenerationSpec["textInputMode"] = "prompt-only"): GenerationSpec { + const options: Record = {}; + for (const field of GENERATION_OPTION_FIELDS) { + if (!(field.modes as readonly string[]).includes(mode)) continue; + const value = config[field.task as keyof AiConfig]; + if (value !== undefined && value !== null && value !== "") options[field.name] = optionValue(value, field.kind, field.task); + } + return validateGenerationSpec({ version: GENERATION_CONTRACT_VERSION, mode, prompt, modelSelection: selectionForConfig(config), options, referenceBindings, textInputMode }); +} + +export function generationSpecMetadata(spec: GenerationSpec): CanvasNodeMetadata { + // model is always projected, including undefined. This clears a stale + // physical-channel mirror when the canonical contract selects a logical model. + const result: Record = { generationSpec: spec, composerContent: spec.prompt, model: undefined }; + for (const field of GENERATION_OPTION_FIELDS) { + if (!(field.modes as readonly string[]).includes(spec.mode)) continue; + const value = spec.options[field.name as keyof Options]; + result[field.node] = value === undefined ? undefined : field.node === "count" ? value : String(value); + } + if (spec.modelSelection?.kind === "channel") result.model = encodeChannelModel(spec.modelSelection.channelId, spec.modelSelection.modelKey); + return result as CanvasNodeMetadata; +} + +export function synchronizeGenerationSpec(node: CanvasNodeData, patch: Partial = {}): CanvasNodeData { + const metadata = { ...node.metadata, ...patch }; + if (!canvasGenerationMode(node.type)) return { ...node, metadata }; + const current = readNodeGenerationSpec(node); + if (!current) return { ...node, metadata }; + const next: GenerationSpec = { ...current, options: { ...current.options } }; + for (const field of GENERATION_OPTION_FIELDS) { + if (!(field.modes as readonly string[]).includes(current.mode) || !Object.prototype.hasOwnProperty.call(patch, field.node)) continue; + const value = patch[field.node as keyof CanvasNodeMetadata]; + if (value === undefined || value === null || value === "") delete next.options[field.name as keyof Options]; + else next.options[field.name as keyof Options] = optionValue(value, field.kind, field.node) as never; + } + if (Object.prototype.hasOwnProperty.call(patch, "composerContent") && typeof patch.composerContent === "string") next.prompt = patch.composerContent; + else if (Object.prototype.hasOwnProperty.call(patch, "prompt") && typeof patch.prompt === "string") next.prompt = patch.prompt; + if (Object.prototype.hasOwnProperty.call(patch, "model")) { + const model = patch.model ? decodeChannelModel(patch.model) : null; + next.modelSelection = model ? { kind: "channel", channelId: model.channelId, modelKey: model.model } : undefined; + delete (metadata as Record).logicalModelId; + delete (metadata as Record).channelId; + delete (metadata as Record).channelModelKey; + } + return { ...node, metadata: { ...metadata, ...generationSpecMetadata(validateGenerationSpec(next)) } }; +} + +export function bindingForNode(node: CanvasNodeData, order: number, previous?: ReferenceBinding): ReferenceBinding | undefined { + const mediaType = node.type === "text" || node.type === "markdown" ? "text" : canvasGenerationMode(node.type); + if (!mediaType) return undefined; + return { id: previous?.id || `node:${node.id}`, nodeId: node.id, mediaType, role: previous?.role || (mediaType === "text" ? "source-text" : "reference"), order, resolution: "latest" }; +} diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts index 3f2e93493..d3677720f 100644 --- a/web/src/lib/model-capabilities.ts +++ b/web/src/lib/model-capabilities.ts @@ -13,6 +13,10 @@ export type TextCapabilityConfig = { streaming?: boolean; /** Whether the model exposes a user-selectable reasoning/thinking mode. */ thinking?: boolean; + /** Total provider input plus output context window, in tokens. */ + contextWindowTokens: number; + /** Provider completion/reasoning output ceiling, in tokens. */ + maxOutputTokens: number; references: { promptMaxChars: number; maxImages: number; @@ -117,7 +121,14 @@ function normalizeCapabilityStrings(values: string[]) { export function normalizeModelCapabilityConfig(config: ModelCapabilityConfig): ModelCapabilityConfig { return { ...config, - text: config.text ? { ...config.text, streaming: config.text.streaming !== false } : config.text, + text: config.text + ? { + ...config.text, + streaming: config.text.streaming !== false, + contextWindowTokens: config.text.contextWindowTokens || 128_000, + maxOutputTokens: config.text.maxOutputTokens || 16_384, + } + : config.text, image: config.image ? { ...config.image, @@ -286,6 +297,8 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = " export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = ""): ModelCapabilityConfig { const text: TextCapabilityConfig = { streaming: true, + contextWindowTokens: 128_000, + maxOutputTokens: 16_384, // 文本模型的视觉能力必须由管理员明确开启,不能根据模型名猜测。 references: { promptMaxChars: 32000, maxImages: 0, maxImageBytes: 0, maxVideos: 0, maxVideoBytes: 0 }, }; diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 89e3f36e6..b26737d20 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -6,6 +6,7 @@ import type { StyleExecutionPlan } from "@/lib/canvas/style-profile"; import type { ArtCritiqueNodeState } from "@/lib/art-critique/contracts"; import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library"; import type { SrtEntry, SubtitleHighlight, SubtitleStyle } from "@/types/timeline"; +import type { GenerationSpec } from "@/lib/canvas/generation-contract.generated"; export type Position = { x: number; @@ -200,6 +201,8 @@ export type CanvasSkillSnapshot = { }; export type CanvasNodeMetadata = { + /** Credential-free editable generation contract; submitted recipes live with tasks. */ + generationSpec?: GenerationSpec; /** Namespaced extension ownership for nodes contributed by a unified plugin. */ pluginId?: string; pluginNodeId?: string; diff --git a/web/test/canvas-model-policy.test.ts b/web/test/canvas-model-policy.test.ts index 34ec3c760..955777bf5 100644 --- a/web/test/canvas-model-policy.test.ts +++ b/web/test/canvas-model-policy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-policy"; import { assertCanvasImageReferenceLimit, buildGenerationConfig, canvasImageReferenceLimitError, resolveCanvasGenerationModel } from "../src/lib/canvas/canvas-project-generation"; +import { readNodeGenerationSpec, synchronizeGenerationSpec } from "../src/lib/canvas/generation-contract"; import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; import { groupModelsByDisplayName, inferVideoOperation, modelCompatibilityError, modelGroupReferenceLimits, modelPromptLengthError, resolveCompatibleModel, resolveModelGenerationDefaults, resolveModelVideoBooleanOptions } from "../src/lib/model-selection"; import { defaultConfig, normalizeModelOptionValue, type AiConfig, type ModelChannel } from "../src/stores/use-config-store"; @@ -53,6 +54,87 @@ function node(id: string, type: CanvasNodeType, generationMode?: "image" | "vide } describe("逻辑模型选择", () => { + + test("generationSpec 的显式 false 和 0 覆盖陈旧镜像及全局默认", () => { + const config = policyConfig(); + config.videoSeconds = "9"; + config.videoGenerateAudio = "true"; + config.videoWatermark = "true"; + const videoNode: CanvasNodeData = { + ...node("video", CanvasNodeType.Video), + metadata: { + model: "relay::cinema-image", + seconds: "15", + generateAudio: "true", + watermark: "true", + generationSpec: { + version: 1, + mode: "video", + prompt: "canonical prompt", + modelSelection: { kind: "channel", channelId: "relay", modelKey: "cinema-text" }, + options: { durationSeconds: 0, generateAudio: false, watermark: false }, + referenceBindings: [], + textInputMode: "prompt-only", + }, + }, + }; + + const generation = buildGenerationConfig(config, videoNode, "video"); + + expect(generation.model).toBe("relay::cinema-text"); + expect(generation.videoSeconds).toBe("1"); + expect(generation.videoGenerateAudio).toBe("false"); + expect(generation.videoWatermark).toBe("false"); + }); + + test("编辑兼容字段时原子更新 canonical 合同和全部镜像", () => { + const original: CanvasNodeData = { + ...node("video", CanvasNodeType.Video), + metadata: { + seconds: "6", + generateAudio: "false", + generationSpec: { + version: 1, + mode: "video", + prompt: "before", + options: { durationSeconds: 6, generateAudio: false }, + referenceBindings: [], + textInputMode: "prompt-only", + }, + }, + }; + + const edited = synchronizeGenerationSpec(original, { seconds: "9", generateAudio: "true", composerContent: "after" }); + const spec = readNodeGenerationSpec(edited); + + expect(spec?.prompt).toBe("after"); + expect(spec?.options.durationSeconds).toBe(9); + expect(spec?.options.generateAudio).toBe(true); + expect(edited.metadata?.seconds).toBe("9"); + expect(edited.metadata?.generateAudio).toBe("true"); + }); + + test("逻辑模型合同不会被旧 metadata.model 覆盖", () => { + const config = policyConfig(); + config.channels[0]!.modelCosts![0]!.logicalModelId = "cinema-logical"; + const videoNode: CanvasNodeData = { + ...node("video", CanvasNodeType.Video), + metadata: { + model: "relay::cinema-image", + generationSpec: { + version: 1, + mode: "video", + prompt: "logical selection", + modelSelection: { kind: "logical", logicalModelId: "cinema-logical" }, + options: {}, + referenceBindings: [], + textInputMode: "prompt-only", + }, + }, + }; + + expect(buildGenerationConfig(config, videoNode, "video").model).toBe("relay::cinema-text"); + }); test("技能上下文展开后按最终视频模型字符上限拒绝提交", () => { const config = policyConfig(); const model = config.videoModels[0]!; diff --git a/web/test/canvas-node-generation-mentions.test.ts b/web/test/canvas-node-generation-mentions.test.ts index 1cbe40e71..e50f8157b 100644 --- a/web/test/canvas-node-generation-mentions.test.ts +++ b/web/test/canvas-node-generation-mentions.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { buildNodeGenerationContext } from "../src/components/canvas/canvas-node-generation"; +import { buildCanvasResourceReferences } from "../src/lib/canvas/canvas-resource-references"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas"; function node(id: string, type: CanvasNodeType, content: string): CanvasNodeData { @@ -32,6 +33,28 @@ function connection(fromNodeId: string): CanvasConnection { } describe("canvas node generation position mentions", () => { + test("Agent 保存的素材引用块在编辑器和再次提交时保持相同编号", () => { + const target = targetNode(); + const character = node("character", CanvasNodeType.Image, "data:image/png;base64,a"); + const wig = node("wig", CanvasNodeType.Image, "data:image/png;base64,b"); + const voice = node("voice", CanvasNodeType.Audio, "data:audio/mpeg;base64,c"); + const note = node("note", CanvasNodeType.Markdown, "导演方案"); + const prompt = "一镜到底\n\n【资产参考】\n人物:@图片1\n假发:@图片2\n声音:@音频1"; + target.metadata = { composerContent: prompt, prompt, referenceNodeIds: [character.id, voice.id, wig.id] }; + const nodes = [target, wig, voice, note, character]; + const connections = [character, voice, wig, note].map((source) => connection(source.id)); + const references = buildCanvasResourceReferences(nodes, connections, target.id).filter((reference) => reference.active); + + expect(Object.fromEntries(references.map((reference) => [reference.nodeId, reference.label]))).toEqual({ + character: "图片1", wig: "图片2", voice: "音频1", note: "文本1", + }); + const context = buildNodeGenerationContext(target.id, nodes, connections, prompt, [], true); + expect(context.referenceImages.map((reference) => reference.id)).toEqual([character.id, wig.id]); + expect(context.referenceAudios.map((reference) => reference.id)).toEqual([voice.id]); + expect(context.prompt).toBe(prompt); + expect(context.textCount).toBe(0); + }); + test("已有图片节点显式引用自身时作为图生图参考图提交", () => { const source = node("image-self", CanvasNodeType.Image, "data:image/png;base64,a"); source.metadata.composerContent = "将 @图片1 图片变清晰";