feat(canvas): 生成合同 - 统一节点参数并补充模型上下文能力
This commit is contained in:
@@ -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 之间")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
15
backend/internal/canvas/contract/generate/main.go
Normal file
15
backend/internal/canvas/contract/generate/main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
317
backend/internal/canvas/contract/generation.go
Normal file
317
backend/internal/canvas/contract/generation.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
64
backend/internal/canvas/contract/typescript.go
Normal file
64
backend/internal/canvas/contract/typescript.go
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -350,7 +350,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
|
||||
}
|
||||
|
||||
function TextCapabilityEditor({ value, onChange, protocol, disabled, section }: Pick<Props, "value" | "onChange" | "protocol" | "disabled" | "section">) {
|
||||
const profile = value?.text || defaultModelCapabilityConfig(protocol).text!;
|
||||
const profile = normalizeModelCapabilityConfig(value || defaultModelCapabilityConfig(protocol)).text!;
|
||||
const update = (patch: Partial<TextCapabilityConfig>) => {
|
||||
onChange?.({ version: 1, text: { ...profile, ...patch } });
|
||||
};
|
||||
@@ -369,8 +369,17 @@ function TextCapabilityEditor({ value, onChange, protocol, disabled, section }:
|
||||
}
|
||||
|
||||
if (section === "references") {
|
||||
const contextWindowFields = (
|
||||
<CapabilityGroup title="上下文能力" description="这是上游文本模型的能力合同,决定 Agent 本轮可保留的输入预算;不会改变运行时 checkpoint 的持久化上限。">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<NumberField label="上下文窗口 Token" value={profile.contextWindowTokens} min={4_096} max={10_000_000} disabled={Boolean(disabled)} onChange={(next) => update({ contextWindowTokens: next || 4_096 })} />
|
||||
<NumberField label="最大输出 Token" value={profile.maxOutputTokens} min={256} max={1_000_000} disabled={Boolean(disabled)} onChange={(next) => update({ maxOutputTokens: next || 256 })} />
|
||||
</div>
|
||||
</CapabilityGroup>
|
||||
);
|
||||
return (
|
||||
<div className="admin-capability-reference-editor">
|
||||
{contextWindowFields}
|
||||
<div className="admin-capability-reference-grid is-three">
|
||||
<ReferenceCard title="图片引用" description="文本模型可接收的图片范围">
|
||||
<NumberField label="最大参考图片数" value={profile.references.maxImages} min={0} max={100} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ maxImages: next || 0 })} />
|
||||
@@ -394,6 +403,12 @@ function TextCapabilityEditor({ value, onChange, protocol, disabled, section }:
|
||||
<div className="text-sm font-medium">文本理解能力</div>
|
||||
<div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/48">默认不假设支持图片或视频,只有明确配置后相关请求才会进入该模型。</div>
|
||||
</div>
|
||||
<CapabilityGroup title="上下文能力" description="这是上游文本模型的能力合同,决定 Agent 本轮可保留的输入预算;不会改变运行时 checkpoint 的持久化上限。">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<NumberField label="上下文窗口 Token" value={profile.contextWindowTokens} min={4_096} max={10_000_000} disabled={Boolean(disabled)} onChange={(next) => update({ contextWindowTokens: next || 4_096 })} />
|
||||
<NumberField label="最大输出 Token" value={profile.maxOutputTokens} min={256} max={1_000_000} disabled={Boolean(disabled)} onChange={(next) => update({ maxOutputTokens: next || 256 })} />
|
||||
</div>
|
||||
</CapabilityGroup>
|
||||
<CapabilityGroup title="图片" description="文本模型可接收的图片参考范围">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<NumberField label="最大参考图片数" value={profile.references.maxImages} min={0} max={100} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ maxImages: next || 0 })} />
|
||||
|
||||
@@ -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 时仍视为已有媒体结果", () => {
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<keyof CanvasNodeMetadata> = [
|
||||
export function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial<CanvasNodeMetadata>) {
|
||||
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;
|
||||
|
||||
@@ -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<CanvasNodeMet
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode, requirements?: ModelRequirements): AiConfig {
|
||||
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode, requirements?: ModelRequirements, displayOnly = false): AiConfig {
|
||||
const generationSpec = node ? readNodeGenerationSpec(node) : undefined;
|
||||
const selection = generationSpec?.mode === mode ? generationSpec.modelSelection : undefined;
|
||||
const selectedModel = resolveGenerationSelection(config, selection);
|
||||
if (selection && !selectedModel && !displayOnly) throw new Error("节点选择的模型或渠道已不可用,请重新选择模型后生成");
|
||||
if (node && generationSpec?.mode === mode) node = { ...node, metadata: { ...node.metadata, ...generationSpecMetadata(generationSpec) } };
|
||||
// 只有独立 Config 节点读取工作流元数据;普通图片/视频/音频节点始终按基础模型生成。
|
||||
const workflowProvider = mode !== "text" && node?.type === CanvasNodeType.Config && resolveCanvasWorkflowProvider(node.metadata) === "runninghub" ? "runninghub" : "model";
|
||||
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel;
|
||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
const storedModel = resolveCanvasGenerationModel(config, node?.metadata?.model, mode);
|
||||
const storedModel = resolveCanvasGenerationModel(config, selectedModel || node?.metadata?.model, mode);
|
||||
if (selection && selectedModel && !storedModel && !displayOnly) throw new Error("节点选择的模型不支持当前生成模式,请重新选择");
|
||||
const preferredModel = storedModel || resolveCanvasGenerationModel(config, defaultModel, mode) || fallbackModel;
|
||||
// 先合并节点上的实时选择,再做兼容性匹配。否则路由只看到全局默认值,节点改过的时长、分辨率或布尔能力无法参与分流。
|
||||
const workflowParameters = node?.metadata?.workflowParameters || {};
|
||||
@@ -507,10 +514,10 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
quality: generationDefaults.quality || requestedConfig.quality,
|
||||
size: generationDefaults.size ?? requestedConfig.size,
|
||||
transparentBackground: generationDefaults.transparentBackground || (requestedConfig.transparentBackground === "true" ? "true" : "false"),
|
||||
videoSeconds: generationDefaults.videoSeconds || requestedConfig.videoSeconds,
|
||||
videoSeconds: generationDefaults.videoSeconds ?? requestedConfig.videoSeconds,
|
||||
vquality: generationDefaults.vquality ?? requestedConfig.vquality,
|
||||
videoGenerateAudio: generationDefaults.videoGenerateAudio || requestedConfig.videoGenerateAudio,
|
||||
videoWatermark: generationDefaults.videoWatermark || requestedConfig.videoWatermark,
|
||||
videoGenerateAudio: generationDefaults.videoGenerateAudio ?? requestedConfig.videoGenerateAudio,
|
||||
videoWatermark: generationDefaults.videoWatermark ?? requestedConfig.videoWatermark,
|
||||
videoArkPrivateAssetUpload: requestedConfig.videoArkPrivateAssetUpload,
|
||||
count: generationDefaults.count || requestedConfig.count,
|
||||
};
|
||||
|
||||
153
web/src/lib/canvas/generation-contract.generated.ts
Normal file
153
web/src/lib/canvas/generation-contract.generated.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// Generated by go generate ./internal/canvas/contract. Do not edit.
|
||||
export const GENERATION_CONTRACT_VERSION = 1;
|
||||
export const GENERATION_LIMITS = { nodeTitle: 240, prompt: 16000, canvasOperations: 20 } as const;
|
||||
|
||||
export type ModelSelection = { kind: "logical"; logicalModelId: string; channelId?: never; modelKey?: never } | { kind: "channel"; channelId: string; modelKey: string; logicalModelId?: never };
|
||||
|
||||
export type Options = {
|
||||
size?: string;
|
||||
quality?: string;
|
||||
count?: number;
|
||||
transparentBackground?: boolean;
|
||||
durationSeconds?: number;
|
||||
resolution?: string;
|
||||
generateAudio?: boolean;
|
||||
watermark?: boolean;
|
||||
audioVoice?: string;
|
||||
audioFormat?: string;
|
||||
audioSpeed?: string;
|
||||
audioInstructions?: string;
|
||||
};
|
||||
|
||||
export type ReferenceBinding = {
|
||||
id: string;
|
||||
nodeId?: string;
|
||||
resourceId?: string;
|
||||
transientId?: string;
|
||||
mediaType: "image" | "video" | "audio" | "text";
|
||||
role: "reference" | "source-text" | "first-frame" | "last-frame" | "mask";
|
||||
order: number;
|
||||
resolution: "latest" | "snapshot";
|
||||
};
|
||||
|
||||
export type GenerationSpec = {
|
||||
version: number;
|
||||
mode: "image" | "video" | "audio";
|
||||
prompt: string;
|
||||
modelSelection?: ModelSelection;
|
||||
options: Options;
|
||||
referenceBindings: ReferenceBinding[];
|
||||
textInputMode: "prompt-only" | "append-sources";
|
||||
};
|
||||
|
||||
export const GENERATION_OPTION_FIELDS = [
|
||||
{
|
||||
"kind": "string",
|
||||
"modes": [
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"name": "size",
|
||||
"node": "size",
|
||||
"task": "size"
|
||||
},
|
||||
{
|
||||
"kind": "string",
|
||||
"modes": [
|
||||
"image"
|
||||
],
|
||||
"name": "quality",
|
||||
"node": "quality",
|
||||
"task": "quality"
|
||||
},
|
||||
{
|
||||
"kind": "number",
|
||||
"modes": [
|
||||
"image"
|
||||
],
|
||||
"name": "count",
|
||||
"node": "count",
|
||||
"task": "count"
|
||||
},
|
||||
{
|
||||
"kind": "boolean",
|
||||
"modes": [
|
||||
"image"
|
||||
],
|
||||
"name": "transparentBackground",
|
||||
"node": "transparentBackground",
|
||||
"task": "transparentBackground"
|
||||
},
|
||||
{
|
||||
"kind": "number",
|
||||
"modes": [
|
||||
"video"
|
||||
],
|
||||
"name": "durationSeconds",
|
||||
"node": "seconds",
|
||||
"task": "videoSeconds"
|
||||
},
|
||||
{
|
||||
"kind": "string",
|
||||
"modes": [
|
||||
"video"
|
||||
],
|
||||
"name": "resolution",
|
||||
"node": "vquality",
|
||||
"task": "vquality"
|
||||
},
|
||||
{
|
||||
"kind": "boolean",
|
||||
"modes": [
|
||||
"video"
|
||||
],
|
||||
"name": "generateAudio",
|
||||
"node": "generateAudio",
|
||||
"task": "videoGenerateAudio"
|
||||
},
|
||||
{
|
||||
"kind": "boolean",
|
||||
"modes": [
|
||||
"video"
|
||||
],
|
||||
"name": "watermark",
|
||||
"node": "watermark",
|
||||
"task": "videoWatermark"
|
||||
},
|
||||
{
|
||||
"kind": "string",
|
||||
"modes": [
|
||||
"audio"
|
||||
],
|
||||
"name": "audioVoice",
|
||||
"node": "audioVoice",
|
||||
"task": "audioVoice"
|
||||
},
|
||||
{
|
||||
"kind": "string",
|
||||
"modes": [
|
||||
"audio"
|
||||
],
|
||||
"name": "audioFormat",
|
||||
"node": "audioFormat",
|
||||
"task": "audioFormat"
|
||||
},
|
||||
{
|
||||
"kind": "string",
|
||||
"modes": [
|
||||
"audio"
|
||||
],
|
||||
"name": "audioSpeed",
|
||||
"node": "audioSpeed",
|
||||
"task": "audioSpeed"
|
||||
},
|
||||
{
|
||||
"kind": "string",
|
||||
"modes": [
|
||||
"audio"
|
||||
],
|
||||
"name": "audioInstructions",
|
||||
"node": "audioInstructions",
|
||||
"task": "audioInstructions"
|
||||
}
|
||||
] as const;
|
||||
201
web/src/lib/canvas/generation-contract.ts
Normal file
201
web/src/lib/canvas/generation-contract.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { decodeChannelModel, encodeChannelModel, type AiConfig } from "@/stores/use-config-store";
|
||||
import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
|
||||
import { GENERATION_CONTRACT_VERSION, GENERATION_LIMITS, GENERATION_OPTION_FIELDS, type GenerationSpec, type ModelSelection, type Options, type ReferenceBinding } from "./generation-contract.generated";
|
||||
|
||||
export type { GenerationSpec, ModelSelection, ReferenceBinding } from "./generation-contract.generated";
|
||||
|
||||
export function canvasGenerationMode(mode: string): GenerationSpec["mode"] | undefined {
|
||||
return mode === "image" || mode === "video" || mode === "audio" ? mode : undefined;
|
||||
}
|
||||
|
||||
function fieldError(path: string, message: string): never {
|
||||
throw new Error(`生成参数 ${path}:${message}`);
|
||||
}
|
||||
|
||||
function object(value: unknown, path: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return fieldError(path, "必须是对象");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function allowKeys(value: Record<string, unknown>, 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<string, unknown> = {};
|
||||
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<string>();
|
||||
const orders = new Set<number>();
|
||||
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<CanvasNodeData, "type" | "metadata">): GenerationSpec | undefined {
|
||||
const mode = canvasGenerationMode(node.type);
|
||||
if (!mode) return undefined;
|
||||
const metadata = (node.metadata || {}) as Record<string, unknown>;
|
||||
const stored = metadata.generationSpec ? validateGenerationSpec(metadata.generationSpec) : undefined;
|
||||
const options: Record<string, unknown> = { ...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<string, unknown> = {};
|
||||
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<string, unknown> = { 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<CanvasNodeMetadata> = {}): 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<string, unknown>).logicalModelId;
|
||||
delete (metadata as Record<string, unknown>).channelId;
|
||||
delete (metadata as Record<string, unknown>).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" };
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]!;
|
||||
|
||||
@@ -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 图片变清晰";
|
||||
|
||||
Reference in New Issue
Block a user