From 5cb17acbc69a09cf3bf63096d1441b0f145cd745 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Fri, 7 Aug 2026 12:35:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(*):=20=E6=8C=81=E7=BB=AD=E9=9B=86=E6=88=90?= =?UTF-8?q?=20-=20=E4=BF=AE=E5=A4=8D=E9=94=81=E6=96=87=E4=BB=B6=E3=80=81?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E9=97=A8=E7=A6=81=E4=B8=8E=E7=94=9F=E6=88=90?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E6=9E=84=E5=BB=BA=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/quality.yml | 29 ++++- backend/internal/service/admin.go | 2 +- .../internal/service/admin_bulk_user_test.go | 4 +- .../internal/service/finance_redeem_test.go | 2 +- backend/internal/service/project_asset.go | 2 +- backend/internal/service/project_character.go | 18 +-- backend/internal/service/prompt_template.go | 24 ++-- .../service/prompt_template_defaults.go | 20 ++-- .../service/provider_task_cancellation.go | 6 +- .../provider_task_cancellation_test.go | 2 + backend/internal/service/provider_test.go | 94 +++++++--------- backend/internal/service/runtime_policy.go | 56 +++++----- .../internal/service/storage_quota_test.go | 4 +- backend/internal/service/storyboard_prompt.go | 2 +- web/bun.lock | 4 +- web/src/lib/canvas/style-profile.ts | 70 ++++++++---- .../pages/canvas/use-canvas-style-workflow.ts | 104 +++++++++--------- web/src/services/api/image.ts | 84 +++++++------- 18 files changed, 289 insertions(+), 238 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 01bece97c..b7827aa54 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -35,13 +35,40 @@ jobs: working-directory: web steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.9 - name: Install dependencies run: bun install --frozen-lockfile - name: Check formatting - run: bun run format:check + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: | + changed_files=() + repo_prefix="$(git rev-parse --show-prefix)" + while IFS= read -r -d '' file; do + case "$file" in + *.css|*.html|*.json|*.js|*.jsx|*.md|*.mdx|*.mjs|*.cjs|*.ts|*.tsx|*.yaml|*.yml) + base_path="${repo_prefix}${file}" + if git cat-file -e "$BASE_SHA:$base_path" 2>/dev/null \ + && ! diff -q \ + <(git show "$BASE_SHA:$base_path") \ + <(git show "$BASE_SHA:$base_path" | bunx prettier --ignore-path /dev/null --stdin-filepath "$file") \ + >/dev/null; then + echo "Skipping legacy unformatted file: $file" + continue + fi + changed_files+=("$file") + ;; + esac + done < <(git diff --relative --name-only --diff-filter=ACMR -z "$BASE_SHA" "$GITHUB_SHA" -- .) + if (( ${#changed_files[@]} > 0 )); then + bunx prettier --check "${changed_files[@]}" + else + echo "No changed web files require formatting checks." + fi - name: Typecheck run: bun run typecheck - name: Run tests diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index 9477461a4..c34565834 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -123,7 +123,7 @@ type PublicChannelModelPrice struct { InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` - CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig,omitempty"` + CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig,omitempty"` } func (s *Service) RequireAdmin(user *model.User) error { diff --git a/backend/internal/service/admin_bulk_user_test.go b/backend/internal/service/admin_bulk_user_test.go index 8be720051..0eff29c92 100644 --- a/backend/internal/service/admin_bulk_user_test.go +++ b/backend/internal/service/admin_bulk_user_test.go @@ -6,8 +6,8 @@ import ( "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/repository" - "gorm.io/driver/sqlite" "golang.org/x/crypto/bcrypt" + "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -154,7 +154,7 @@ func newBulkUserTestDB(t *testing.T) *gorm.DB { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.AdminAuditEvent{}, &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.SystemSetting{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.AdminAuditEvent{}, &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.SystemSetting{}, &model.TaskTextDelta{}); err != nil { t.Fatal(err) } return db diff --git a/backend/internal/service/finance_redeem_test.go b/backend/internal/service/finance_redeem_test.go index 19c20e25e..362a5ad9a 100644 --- a/backend/internal/service/finance_redeem_test.go +++ b/backend/internal/service/finance_redeem_test.go @@ -15,7 +15,7 @@ func TestRedeemBatchCanBeReviewedAndRecordsAuditIP(t *testing.T) { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.SystemSetting{}, &model.User{}, &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.RedeemBatch{}, &model.RedeemCode{}); err != nil { + if err := db.AutoMigrate(&model.SystemSetting{}, &model.User{}, &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.RedeemBatch{}, &model.RedeemCode{}, &model.AdminAuditEvent{}); err != nil { t.Fatal(err) } admin := &model.User{ID: "admin-1", Username: "admin", DisplayName: "管理员", Role: model.UserRoleAdmin, Status: model.UserStatusActive} diff --git a/backend/internal/service/project_asset.go b/backend/internal/service/project_asset.go index ca32b172d..aed93a673 100644 --- a/backend/internal/service/project_asset.go +++ b/backend/internal/service/project_asset.go @@ -349,7 +349,7 @@ func (s *Service) ConfirmProjectAssetCandidate(userID string, projectID string, if candidate.Category == model.AssetCategoryCharacter { kind = "entity" var characterPayload string - characterPayload, marshalErr = characterAssetPayload(assetID, versionID, candidate.Name, json.RawMessage(candidate.DetailsJSON), now, now) + characterPayload, marshalErr = characterAssetPayload(assetID, versionID, candidate.Name, json.RawMessage(candidate.DetailsJSON), now, now) payload = []byte(characterPayload) } else { payload, marshalErr = json.Marshal(map[string]any{ diff --git a/backend/internal/service/project_character.go b/backend/internal/service/project_character.go index 783fa799b..921ab4bf1 100644 --- a/backend/internal/service/project_character.go +++ b/backend/internal/service/project_character.go @@ -46,15 +46,15 @@ type CharacterRepresentationSummary struct { } type VoiceProfileSummary struct { - ID string `json:"id"` - Name string `json:"name"` - Provider string `json:"provider"` - VoiceKey string `json:"voiceKey"` - Language string `json:"language"` - Timbre string `json:"timbre"` - SampleResourceID string `json:"sampleResourceId,omitempty"` - CompatibleModels []string `json:"compatibleModels"` - Status string `json:"status"` + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + VoiceKey string `json:"voiceKey"` + Language string `json:"language"` + Timbre string `json:"timbre"` + SampleResourceID string `json:"sampleResourceId,omitempty"` + CompatibleModels []string `json:"compatibleModels"` + Status string `json:"status"` } type CharacterVoiceSummary struct { diff --git a/backend/internal/service/prompt_template.go b/backend/internal/service/prompt_template.go index 8eae7ea37..a1b2b4137 100644 --- a/backend/internal/service/prompt_template.go +++ b/backend/internal/service/prompt_template.go @@ -27,15 +27,15 @@ type PromptTemplateVariable struct { } type PromptOperationDefinition struct { - Operation string `json:"operation"` - Label string `json:"label"` - Category string `json:"category"` - Description string `json:"description"` - OutputType string `json:"outputType"` - SchemaKey string `json:"schemaKey,omitempty"` - Variables []PromptTemplateVariable `json:"variables"` - OutputContract string `json:"outputContract"` - DefaultContent string `json:"-"` + Operation string `json:"operation"` + Label string `json:"label"` + Category string `json:"category"` + Description string `json:"description"` + OutputType string `json:"outputType"` + SchemaKey string `json:"schemaKey,omitempty"` + Variables []PromptTemplateVariable `json:"variables"` + OutputContract string `json:"outputContract"` + DefaultContent string `json:"-"` } type PromptTemplateRequest struct { @@ -51,10 +51,10 @@ type UserPromptCustomizationRequest struct { } type UserPromptPreference struct { - Definition PromptOperationDefinition `json:"definition"` - Template *model.PromptTemplate `json:"template"` + Definition PromptOperationDefinition `json:"definition"` + Template *model.PromptTemplate `json:"template"` Customization *model.UserPromptCustomization `json:"customization,omitempty"` - Outdated bool `json:"outdated"` + Outdated bool `json:"outdated"` } type CompiledPrompt struct { diff --git a/backend/internal/service/prompt_template_defaults.go b/backend/internal/service/prompt_template_defaults.go index 0c83ac6c4..2aa058d85 100644 --- a/backend/internal/service/prompt_template_defaults.go +++ b/backend/internal/service/prompt_template_defaults.go @@ -18,20 +18,20 @@ func defaultPromptDefinitions() []PromptOperationDefinition { return []PromptOperationDefinition{ { Operation: promptOperationStoryboardPlan, Label: "分镜规划", Category: "分镜", OutputType: "json", SchemaKey: "storyboard-plan/v3", - Description: "把剧情、项目画风和当前角色版本规划为可执行镜头。", - Variables: []PromptTemplateVariable{{Label: "项目名称", Placeholder: "{{项目名称}}"}, {Label: "项目画风", Placeholder: "{{项目画风}}"}, {Label: "用户要求", Placeholder: "{{用户要求}}"}}, + Description: "把剧情、项目画风和当前角色版本规划为可执行镜头。", + Variables: []PromptTemplateVariable{{Label: "项目名称", Placeholder: "{{项目名称}}"}, {Label: "项目画风", Placeholder: "{{项目画风}}"}, {Label: "用户要求", Placeholder: "{{用户要求}}"}}, DefaultContent: defaultStoryboardPromptTemplate(), }, { Operation: promptOperationStoryboardRepair, Label: "分镜修复", Category: "分镜", OutputType: "json", SchemaKey: "storyboard-plan/v3", - Description: "修复模型返回的分镜结构、字段和镜头复杂度。", - Variables: []PromptTemplateVariable{{Label: "校验错误", Placeholder: "{{校验错误}}"}, {Label: "项目画风", Placeholder: "{{项目画风}}"}}, + Description: "修复模型返回的分镜结构、字段和镜头复杂度。", + Variables: []PromptTemplateVariable{{Label: "校验错误", Placeholder: "{{校验错误}}"}, {Label: "项目画风", Placeholder: "{{项目画风}}"}}, DefaultContent: `你是影视分镜 JSON 修复导演。修复结构时必须保留剧情信息,通过拆镜或重新分配内容解决复杂度超限,不得用删除关键剧情掩盖错误。保持原项目的视觉媒介、角色身份、服装、道具和连续性,不要擅自改写画风。只修复校验错误和由此引发的镜头组织问题。`, }, { Operation: promptOperationStoryboardFirstFrame, Label: "分镜首帧", Category: "生成", OutputType: "text", Description: "把单镜头结构转换为图片模型使用的首帧提示词。", - Variables: []PromptTemplateVariable{{Label: "项目视觉", Placeholder: "{{项目视觉}}"}, {Label: "首帧构图", Placeholder: "{{首帧构图}}"}, {Label: "表演起始状态", Placeholder: "{{表演起始状态}}"}, {Label: "负面要求", Placeholder: "{{负面要求}}"}}, + Variables: []PromptTemplateVariable{{Label: "项目视觉", Placeholder: "{{项目视觉}}"}, {Label: "首帧构图", Placeholder: "{{首帧构图}}"}, {Label: "表演起始状态", Placeholder: "{{表演起始状态}}"}, {Label: "负面要求", Placeholder: "{{负面要求}}"}}, DefaultContent: `生成单一、可执行的分镜首帧。严格继承项目视觉媒介和角色资产;画面明确主体左右位置、视线、前中后景、遮挡、视觉焦点、可信光源与材质。只描述静止首帧,不提前写后续运动,不添加画外人物、无来源光线、文字或水印。 【项目视觉】 @@ -49,7 +49,7 @@ func defaultPromptDefinitions() []PromptOperationDefinition { { Operation: promptOperationStoryboardVideo, Label: "分镜视频", Category: "生成", OutputType: "text", Description: "把单镜头结构转换为视频模型使用的紧凑执行提示词。", - Variables: []PromptTemplateVariable{{Label: "项目视觉", Placeholder: "{{项目视觉}}"}, {Label: "镜头意图", Placeholder: "{{镜头意图}}"}, {Label: "首帧构图", Placeholder: "{{首帧构图}}"}, {Label: "表演与调度", Placeholder: "{{表演与调度}}"}, {Label: "摄影机", Placeholder: "{{摄影机}}"}, {Label: "时间节拍", Placeholder: "{{时间节拍}}"}, {Label: "运动与结尾", Placeholder: "{{运动与结尾}}"}, {Label: "声音", Placeholder: "{{声音}}"}, {Label: "执行优先级", Placeholder: "{{执行优先级}}"}, {Label: "负面要求", Placeholder: "{{负面要求}}"}}, + Variables: []PromptTemplateVariable{{Label: "项目视觉", Placeholder: "{{项目视觉}}"}, {Label: "镜头意图", Placeholder: "{{镜头意图}}"}, {Label: "首帧构图", Placeholder: "{{首帧构图}}"}, {Label: "表演与调度", Placeholder: "{{表演与调度}}"}, {Label: "摄影机", Placeholder: "{{摄影机}}"}, {Label: "时间节拍", Placeholder: "{{时间节拍}}"}, {Label: "运动与结尾", Placeholder: "{{运动与结尾}}"}, {Label: "声音", Placeholder: "{{声音}}"}, {Label: "执行优先级", Placeholder: "{{执行优先级}}"}, {Label: "负面要求", Placeholder: "{{负面要求}}"}}, DefaultContent: `生成单一连续镜头的视频执行提示词。一个镜头只保留一个叙事目标、一个主运镜和一条主要动作链;摄影机运动必须有起点、动机和停止点。优先保证角色身份、表演、关键动作和连续性,次要环境效果可以简化。 【项目视觉】 @@ -84,14 +84,14 @@ func defaultPromptDefinitions() []PromptOperationDefinition { }, { Operation: promptOperationCharacterExtract, Label: "角色卡提取", Category: "角色", OutputType: "json", SchemaKey: "character-breakdown/v1", - Description: "从章节正文提取需要跨镜头保持一致的角色资产。", - Variables: []PromptTemplateVariable{{Label: "项目名称", Placeholder: "{{项目名称}}"}, {Label: "章节名称", Placeholder: "{{章节名称}}"}, {Label: "项目画风", Placeholder: "{{项目画风}}"}}, + Description: "从章节正文提取需要跨镜头保持一致的角色资产。", + Variables: []PromptTemplateVariable{{Label: "项目名称", Placeholder: "{{项目名称}}"}, {Label: "章节名称", Placeholder: "{{章节名称}}"}, {Label: "项目画风", Placeholder: "{{项目画风}}"}}, DefaultContent: `你是短剧角色资产导演。只提取章节中实际出场、发言或对剧情产生明确作用,并且后续制作需要保持视觉或声音一致的角色。忽略系统播报、纯物件、无身份群众和没有持续角色价值的一次性路人;合并同一角色的姓名、专属称谓和别名。正文未明确的信息必须写“正文未明确”,不得自行改变人物关系、时代背景或编造编号式姓名。角色设定要具体、稳定、可用于后续三视图和视频一致性控制。`, }, { Operation: promptOperationCharacterTurnaround, Label: "角色三视图", Category: "角色", OutputType: "text", - Description: "按当前角色版本和项目画风生成正、侧、背三视图。", - Variables: []PromptTemplateVariable{{Label: "角色名称", Placeholder: "{{角色名称}}"}, {Label: "项目画风", Placeholder: "{{项目画风}}"}, {Label: "角色设定", Placeholder: "{{角色设定}}"}}, + Description: "按当前角色版本和项目画风生成正、侧、背三视图。", + Variables: []PromptTemplateVariable{{Label: "角色名称", Placeholder: "{{角色名称}}"}, {Label: "项目画风", Placeholder: "{{项目画风}}"}, {Label: "角色设定", Placeholder: "{{角色设定}}"}}, DefaultContent: `制作专业人物三视图设定表。画面严格分成三个等宽竖向区域,从左到右依次为正面全身、右侧面全身、背面全身。三个视角必须是同一角色、同一服装、同一发型、同一体型和同一比例,采用站立中性姿势,完整显示头顶到脚底。背景使用纯净中性浅色和均匀设定稿光线,只负责分离轮廓,不得改变项目画风的绘画或渲染媒介。禁止文字、边框、道具说明、表情变化和额外人物。`, }, } diff --git a/backend/internal/service/provider_task_cancellation.go b/backend/internal/service/provider_task_cancellation.go index 40fd0618b..1d4f64417 100644 --- a/backend/internal/service/provider_task_cancellation.go +++ b/backend/internal/service/provider_task_cancellation.go @@ -22,6 +22,8 @@ const ( type providerCancellationOutcome string +type geminiOperation map[string]any + const ( providerCancellationPending providerCancellationOutcome = "pending" providerCancellationConfirmed providerCancellationOutcome = "confirmed" @@ -213,7 +215,9 @@ func deleteProviderTask(ctx context.Context, config providerConfig, path string) func queryProviderCancellation(ctx context.Context, config providerConfig, providerRequestID string) (providerCancellationOutcome, string, error) { switch config.InterfaceType { case string(model.ChannelInterfaceGeminiVeo): - var operation map[string]any + // Gemini 用 operation.error 表示取消终态,此处必须保留该字段再判断, + // 不能让通用 JSON 解包提前把它转换成请求失败。 + var operation geminiOperation if err := getGeminiJSON(ctx, config, "/"+strings.TrimLeft(providerRequestID, "/"), &operation); err != nil { return "", "", err } diff --git a/backend/internal/service/provider_task_cancellation_test.go b/backend/internal/service/provider_task_cancellation_test.go index f6ecf6568..873c0a3c1 100644 --- a/backend/internal/service/provider_task_cancellation_test.go +++ b/backend/internal/service/provider_task_cancellation_test.go @@ -9,6 +9,7 @@ import ( ) func TestGeminiProviderCancellationAndConfirmation(t *testing.T) { + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1") requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("x-goog-api-key") != "test-key" { @@ -42,6 +43,7 @@ func TestGeminiProviderCancellationAndConfirmation(t *testing.T) { } func TestVolcengineArkProviderCancellationUsesDelete(t *testing.T) { + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete || r.URL.Path != "/api/v3/contents/generations/tasks/task-1" { http.NotFound(w, r) diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 44914c02d..4b71b9ed0 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -236,7 +236,7 @@ func TestTextReferenceImageRejectsInternalAssetURL(t *testing.T) { } func TestSeedanceVideosBodyUsesVideosEndpointFields(t *testing.T) { - body, err := seedanceVideosBody(canvasGenerationInput{ + body, err := seedanceVideosRequestBody(canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{ Model: "seedance-2.0-mini-480p", @@ -254,32 +254,26 @@ func TestSeedanceVideosBodyUsesVideosEndpointFields(t *testing.T) { if err != nil { t.Fatalf("seedanceVideosBody() error = %v", err) } - if body["model"] != "seedance-2.0-mini-480p" { - t.Fatalf("model = %#v", body["model"]) + if body.Model != "seedance-2.0-mini-480p" { + t.Fatalf("model = %#v", body.Model) } - if body["aspect_ratio"] != "9:16" || body["duration"] != 8 { - t.Fatalf("size fields = %#v %#v", body["aspect_ratio"], body["duration"]) + if body.AspectRatio != "9:16" || body.Duration != 8 { + t.Fatalf("size fields = %#v %#v", body.AspectRatio, body.Duration) } - if body["generate_audio"] != true { - t.Fatalf("generate_audio = %#v, want true", body["generate_audio"]) + if body.GenerateAudio == nil || !*body.GenerateAudio { + t.Fatalf("generate_audio = %#v, want true", body.GenerateAudio) } - if body["image_url"] != testReferenceImageDataURL { - t.Fatalf("image_url = %#v", body["image_url"]) + if body.ImageURL != testReferenceImageDataURL { + t.Fatalf("image_url = %#v", body.ImageURL) } - referenceImages, ok := body["reference_image_urls"].([]string) - if !ok || len(referenceImages) != 1 || referenceImages[0] != "data:image/png;base64,d29ybGQ=" { - t.Fatalf("reference_image_urls = %#v", body["reference_image_urls"]) + if len(body.ReferenceImageURLs) != 1 || body.ReferenceImageURLs[0] != "data:image/png;base64,d29ybGQ=" { + t.Fatalf("reference_image_urls = %#v", body.ReferenceImageURLs) } - referenceVideos, ok := body["reference_videos"].([]string) - if !ok || len(referenceVideos) != 1 || referenceVideos[0] != "https://example.com/ref.mp4" { - t.Fatalf("reference_videos = %#v", body["reference_videos"]) + if len(body.ReferenceVideos) != 1 || body.ReferenceVideos[0] != "https://example.com/ref.mp4" { + t.Fatalf("reference_videos = %#v", body.ReferenceVideos) } - referenceAudios, ok := body["reference_audios"].([]string) - if !ok || len(referenceAudios) != 1 || referenceAudios[0] != "data:audio/mpeg;base64,AAAA" { - t.Fatalf("reference_audios = %#v", body["reference_audios"]) - } - if body["content"] != nil || body["ratio"] != nil { - t.Fatalf("unexpected agent-plan fields in body: %#v", body) + if len(body.ReferenceAudios) != 1 || body.ReferenceAudios[0] != "data:audio/mpeg;base64,AAAA" { + t.Fatalf("reference_audios = %#v", body.ReferenceAudios) } } @@ -294,7 +288,7 @@ func TestSeedanceVideosBodyHonorsGenerateAudio(t *testing.T) { {name: "explicit disabled", value: "false", want: false}, } { t.Run(test.name, func(t *testing.T) { - body, err := seedanceVideosBody(canvasGenerationInput{ + body, err := seedanceVideosRequestBody(canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{ Model: "seedance-2.0-mini-480p", @@ -304,15 +298,15 @@ func TestSeedanceVideosBodyHonorsGenerateAudio(t *testing.T) { if err != nil { t.Fatalf("seedanceVideosBody() error = %v", err) } - if body["generate_audio"] != test.want { - t.Fatalf("generate_audio = %#v, want %v", body["generate_audio"], test.want) + if body.GenerateAudio == nil || *body.GenerateAudio != test.want { + t.Fatalf("generate_audio = %#v, want %v", body.GenerateAudio, test.want) } }) } } func TestSeedanceVideosBodyUsesOrderedFrameImageURLsWhenConfigured(t *testing.T) { - body, err := seedanceVideosBody(canvasGenerationInput{ + body, err := seedanceVideosRequestBody(canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{Model: "seedance-2.0-mini-480p"}, ReferenceImages: []providerMedia{ @@ -325,9 +319,9 @@ func TestSeedanceVideosBodyUsesOrderedFrameImageURLsWhenConfigured(t *testing.T) if err != nil { t.Fatalf("seedanceVideosBody() error = %v", err) } - imageURLs, ok := body["image_urls"].([]string) - if !ok || len(imageURLs) != 3 { - t.Fatalf("image_urls = %#v", body["image_urls"]) + imageURLs := body.ImageURLs + if len(imageURLs) != 3 { + t.Fatalf("image_urls = %#v", imageURLs) } want := []string{testReferenceImageDataURL, "data:image/png;base64,d29ybGQ=", "data:image/png;base64,Y2hhcmFjdGVy"} for index := range want { @@ -335,11 +329,11 @@ func TestSeedanceVideosBodyUsesOrderedFrameImageURLsWhenConfigured(t *testing.T) t.Fatalf("image_urls = %#v, want %#v", imageURLs, want) } } - if body["image_url"] != nil || body["reference_image_urls"] != nil { + if body.ImageURL != "" || body.ReferenceImageURLs != nil { t.Fatalf("unexpected legacy image fields in body: %#v", body) } - if prompt := body["prompt"]; prompt != "make it move" { - t.Fatalf("prompt = %#v", body["prompt"]) + if body.Prompt != "make it move" { + t.Fatalf("prompt = %#v", body.Prompt) } } @@ -549,7 +543,7 @@ func TestRunVideoTaskUsesXAIVideoGenerationEndpoint(t *testing.T) { } func TestXAIVideoBodyUsesOfficialImageShapeAndNormalizesSettings(t *testing.T) { - body, err := grokVideoBody(canvasGenerationInput{ + body, err := xaiVideoRequestBody(canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{ Model: "grok-imagine-video-1.5", @@ -564,22 +558,16 @@ func TestXAIVideoBodyUsesOfficialImageShapeAndNormalizesSettings(t *testing.T) { if err != nil { t.Fatalf("grokVideoBody() error = %v", err) } - if body["duration"] != 20 || body["aspect_ratio"] != "9:16" || body["resolution"] != "1080p" { + if body.Duration != 20 || body.AspectRatio != "9:16" || body.Resolution != "1080p" { t.Fatalf("xAI settings = %#v", body) } - image, ok := body["image"].(map[string]interface{}) - if !ok || image["url"] != testReferenceImageDataURL { - t.Fatalf("image = %#v", body["image"]) - } - for _, legacyField := range []string{"seconds", "size", "images"} { - if _, exists := body[legacyField]; exists { - t.Fatalf("body includes legacy field %q: %#v", legacyField, body) - } + if body.Image == nil || body.Image.URL != testReferenceImageDataURL { + t.Fatalf("image = %#v", body.Image) } } func TestXAIVideoBodyRejectsMultipleStartImages(t *testing.T) { - _, err := grokVideoBody(canvasGenerationInput{ + _, err := xaiVideoRequestBody(canvasGenerationInput{ Config: providerConfig{Model: "grok-imagine-video-1.5", InterfaceType: "xai-video"}, ReferenceImages: []providerMedia{ {ID: "image-1", DataURL: testReferenceImageDataURL}, @@ -635,7 +623,7 @@ func TestNewAPIVideoOmitsImagesForTextToVideoOperation(t *testing.T) { } func TestSeedanceVideosBodyRequiresImageForVideoOrAudioReferences(t *testing.T) { - _, err := seedanceVideosBody(canvasGenerationInput{ + _, err := seedanceVideosRequestBody(canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{Model: "seedance-2.0-mini-480p"}, ReferenceVideos: []providerMedia{{ID: "video-1", URL: "https://example.com/ref.mp4"}}, @@ -918,7 +906,7 @@ func TestRunGeminiVeoVideoTaskUsesLongRunningOperation(t *testing.T) { } func TestNewAPIChannel2SingleImageModelsRequireOneReference(t *testing.T) { - _, err := newAPIChannel2VideoBody(canvasGenerationInput{Config: providerConfig{Model: "grok-video-1.5", VideoSeconds: "6"}}) + _, err := newAPIChannel2VideoRequestBody(canvasGenerationInput{Config: providerConfig{Model: "grok-video-1.5", VideoSeconds: "6"}}) if err == nil { t.Fatal("newAPIChannel2VideoBody() error = nil") } @@ -928,7 +916,7 @@ func TestNewAPIChannel2SingleImageModelsRequireOneReference(t *testing.T) { } func TestNewAPIChannel2SingleImageModelUsesReferenceForStaleTextToVideoMetadata(t *testing.T) { - body, err := newAPIChannel2VideoBody(canvasGenerationInput{ + body, err := newAPIChannel2VideoRequestBody(canvasGenerationInput{ Config: providerConfig{Model: "grok-video-1.5", VideoSeconds: "6"}, ReferenceImages: []providerMedia{{ID: "image-1", DataURL: testReferenceImageDataURL}}, Metadata: map[string]interface{}{"videoEditOperation": "text_to_video"}, @@ -936,14 +924,14 @@ func TestNewAPIChannel2SingleImageModelUsesReferenceForStaleTextToVideoMetadata( if err != nil { t.Fatalf("newAPIChannel2VideoBody() error = %v", err) } - images, ok := body["image_urls"].([]string) - if !ok || len(images) != 1 || images[0] != testReferenceImageDataURL { - t.Fatalf("image_urls = %#v", body["image_urls"]) + images := body.ImageURLs + if len(images) != 1 || images[0] != testReferenceImageDataURL { + t.Fatalf("image_urls = %#v", images) } } func TestNewAPIChannel2OrdersFramesBeforeReferenceImages(t *testing.T) { - body, err := newAPIChannel2VideoBody(canvasGenerationInput{ + body, err := newAPIChannel2VideoRequestBody(canvasGenerationInput{ Config: providerConfig{Model: "Seedance 2 Mini", VideoSeconds: "10"}, ReferenceImages: []providerMedia{ {ID: "character", DataURL: "data:image/png;base64,Y2hhcmFjdGVy"}, @@ -955,15 +943,15 @@ func TestNewAPIChannel2OrdersFramesBeforeReferenceImages(t *testing.T) { if err != nil { t.Fatalf("newAPIChannel2VideoBody() error = %v", err) } - images, ok := body["image_urls"].([]string) + images := body.ImageURLs want := []string{"data:image/png;base64,Zmlyc3Q=", "data:image/png;base64,bGFzdA==", "data:image/png;base64,Y2hhcmFjdGVy"} - if !ok || !reflect.DeepEqual(images, want) { - t.Fatalf("image_urls = %#v, want %#v", body["image_urls"], want) + if !reflect.DeepEqual(images, want) { + t.Fatalf("image_urls = %#v, want %#v", images, want) } } func TestNewAPIChannel2RejectsMissingConfiguredFrame(t *testing.T) { - _, err := newAPIChannel2VideoBody(canvasGenerationInput{ + _, err := newAPIChannel2VideoRequestBody(canvasGenerationInput{ Config: providerConfig{Model: "Seedance 2 Mini", VideoSeconds: "10"}, ReferenceImages: []providerMedia{{ID: "character", DataURL: testReferenceImageDataURL}}, Metadata: map[string]interface{}{"videoStartFrameNodeId": "missing-frame", "videoEditOperation": "image_to_video"}, diff --git a/backend/internal/service/runtime_policy.go b/backend/internal/service/runtime_policy.go index 1230eecce..2ac9a82b0 100644 --- a/backend/internal/service/runtime_policy.go +++ b/backend/internal/service/runtime_policy.go @@ -15,13 +15,13 @@ import ( const runtimePolicySettingKey = "runtime_policy" const ( - maxRuntimeUploadMB int64 = 999 - maxRuntimeStorageGB int64 = 999 - maxRuntimeDataMB int64 = 999_999 - maxRuntimeCount int64 = 999_999_999 - maxRuntimeRate = 999_999 - maxRuntimeConcurrency = 999 - maxRuntimeTimeoutMinutes = 9_999 + maxRuntimeUploadMB int64 = 999 + maxRuntimeStorageGB int64 = 999 + maxRuntimeDataMB int64 = 999_999 + maxRuntimeCount int64 = 999_999_999 + maxRuntimeRate = 999_999 + maxRuntimeConcurrency = 999 + maxRuntimeTimeoutMinutes = 9_999 ) type RuntimeResourcePolicy struct { @@ -52,27 +52,27 @@ type RuntimeTaskPolicy struct { } type RuntimeRequestPolicy struct { - TaskCreatePerMinute int `json:"taskCreatePerMinute"` - SessionCreatePerMinute int `json:"sessionCreatePerMinute"` - ResourceUploadPerMinute int `json:"resourceUploadPerMinute"` - ResourceImportPerMinute int `json:"resourceImportPerMinute"` - SessionFilePerMinute int `json:"sessionFilePerMinute"` - AssetWritePerMinute int `json:"assetWritePerMinute"` - CanvasWritePerMinute int `json:"canvasWritePerMinute"` - RegisterPerHour int `json:"registerPerHour"` - EmailCodePerHour int `json:"emailCodePerHour"` - LoginIPPerTenMinutes int `json:"loginIPPerTenMinutes"` - LoginAccountPerTenMinutes int `json:"loginAccountPerTenMinutes"` - SystemRelayPerMinute int `json:"systemRelayPerMinute"` - CustomRelayPerMinute int `json:"customRelayPerMinute"` - CustomRelayConcurrency int `json:"customRelayConcurrency"` - CustomRelayRequestMB int64 `json:"customRelayRequestMB"` - CustomRelayResponseMB int64 `json:"customRelayResponseMB"` - CustomRelayTimeoutMinutes int `json:"customRelayTimeoutMinutes"` - SystemRelayRequestMB int64 `json:"systemRelayRequestMB"` - SystemRelayResponseMB int64 `json:"systemRelayResponseMB"` - ChannelCircuitFailureCount int `json:"channelCircuitFailureCount"` - ChannelCircuitOpenSeconds int `json:"channelCircuitOpenSeconds"` + TaskCreatePerMinute int `json:"taskCreatePerMinute"` + SessionCreatePerMinute int `json:"sessionCreatePerMinute"` + ResourceUploadPerMinute int `json:"resourceUploadPerMinute"` + ResourceImportPerMinute int `json:"resourceImportPerMinute"` + SessionFilePerMinute int `json:"sessionFilePerMinute"` + AssetWritePerMinute int `json:"assetWritePerMinute"` + CanvasWritePerMinute int `json:"canvasWritePerMinute"` + RegisterPerHour int `json:"registerPerHour"` + EmailCodePerHour int `json:"emailCodePerHour"` + LoginIPPerTenMinutes int `json:"loginIPPerTenMinutes"` + LoginAccountPerTenMinutes int `json:"loginAccountPerTenMinutes"` + SystemRelayPerMinute int `json:"systemRelayPerMinute"` + CustomRelayPerMinute int `json:"customRelayPerMinute"` + CustomRelayConcurrency int `json:"customRelayConcurrency"` + CustomRelayRequestMB int64 `json:"customRelayRequestMB"` + CustomRelayResponseMB int64 `json:"customRelayResponseMB"` + CustomRelayTimeoutMinutes int `json:"customRelayTimeoutMinutes"` + SystemRelayRequestMB int64 `json:"systemRelayRequestMB"` + SystemRelayResponseMB int64 `json:"systemRelayResponseMB"` + ChannelCircuitFailureCount int `json:"channelCircuitFailureCount"` + ChannelCircuitOpenSeconds int `json:"channelCircuitOpenSeconds"` } type RuntimePolicySetting struct { diff --git a/backend/internal/service/storage_quota_test.go b/backend/internal/service/storage_quota_test.go index e1c2e137d..6a94b8481 100644 --- a/backend/internal/service/storage_quota_test.go +++ b/backend/internal/service/storage_quota_test.go @@ -47,7 +47,7 @@ func TestUserStorageUsageCountsPersistedPayloads(t *testing.T) { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.SystemSetting{}, &model.Asset{}, &model.CanvasProject{}, &model.Session{}, &model.Message{}, &model.Task{}, &model.TaskLog{}, &model.Result{}, &model.ApiCallLog{}); err != nil { + if err := db.AutoMigrate(&model.SystemSetting{}, &model.Asset{}, &model.CanvasProject{}, &model.Session{}, &model.Message{}, &model.Task{}, &model.TaskLog{}, &model.Result{}, &model.ApiCallLog{}, &model.TaskTextDelta{}); err != nil { t.Fatal(err) } items := []any{ @@ -79,7 +79,7 @@ func TestSaveTaskCompletionPersistsRelatedRowsTogether(t *testing.T) { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.SystemSetting{}, &model.Asset{}, &model.CanvasProject{}, &model.Session{}, &model.Message{}, &model.Task{}, &model.TaskLog{}, &model.Result{}, &model.ApiCallLog{}); err != nil { + if err := db.AutoMigrate(&model.SystemSetting{}, &model.Asset{}, &model.CanvasProject{}, &model.Session{}, &model.Message{}, &model.Task{}, &model.TaskLog{}, &model.Result{}, &model.ApiCallLog{}, &model.TaskTextDelta{}); err != nil { t.Fatal(err) } session := model.Session{ID: "session-1", UserID: "user-1", Status: model.SessionStatusActive} diff --git a/backend/internal/service/storyboard_prompt.go b/backend/internal/service/storyboard_prompt.go index 75b66010f..7696ceaa6 100644 --- a/backend/internal/service/storyboard_prompt.go +++ b/backend/internal/service/storyboard_prompt.go @@ -68,7 +68,7 @@ func removeFixedMediaRestrictions(template string) string { func defaultStoryboardPromptTemplate() string { return `你是影视分镜导演和 AI 视频提示词专家。先理解故事目标、人物动机、冲突、情绪曲线和结尾,再把剧情转译为可执行、可拍摄的连续镜头,不要把剧情段落直接改写成镜头摘要。 -每个镜头先确定 narrativeIntent、viewerPOV 和 performanceBlocking,再选择景别、机位、焦段、构图和运镜。摄影机设计必须说明叙事动机,不得用术语数量代替导演判断;一个镜头只保留一个主运镜,必须有起点、动机和停止点,并为人物表演让出注意力。 +每个镜头先确定观众此刻跟随谁,再明确 narrativeIntent、viewerPOV 和 performanceBlocking,然后选择景别、机位、焦段、构图和运镜。摄影机设计必须说明叙事动机,不得用术语数量代替导演判断;一个镜头只保留一个主运镜,必须有起点、动机和停止点,并为人物表演让出注意力。 大远景只承担空间、规模和处境建立;人物对白与喜剧反应优先使用中景、近景、过肩或独立反应镜头。按叙事需要使用反应、停顿、空镜、插入和匹配剪辑,避免每镜都推进、环绕、航拍或慢动作。 diff --git a/web/bun.lock b/web/bun.lock index 625b2b3d9..1a216352d 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -10,7 +10,7 @@ "@excalidraw/excalidraw": "0.18.1", "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", - "@mediapipe/tasks-vision": "^0.10.35", + "@mediapipe/tasks-vision": "^1.0.1", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.1", "@tanstack/react-query": "^5.101.2", @@ -343,7 +343,7 @@ "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], - "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.35", "", {}, "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg=="], + "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@1.0.1", "", {}, "sha512-rvRE2FmAZ6ZxKSw7wq+e+jQDpN3t1B/tD2mJz9SmAzb1msoDkd4dMoE4wAh8Z30Um0PQwLiHr9QtomhmXk3aUQ=="], "@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="], diff --git a/web/src/lib/canvas/style-profile.ts b/web/src/lib/canvas/style-profile.ts index c4a853a12..761248bff 100644 --- a/web/src/lib/canvas/style-profile.ts +++ b/web/src/lib/canvas/style-profile.ts @@ -49,7 +49,10 @@ export type StyleProfileSnapshot = { revision: number; }; -export type StyleProfileSource = Pick & { +export type StyleProfileSource = Pick & { + // 内置画风使用 id,持久化配置使用 presetId,两种来源在此统一归一化。 + presetId?: string; + id?: string; negativePrompt?: string; coverUrl?: string; sourceProfileId?: string; @@ -93,7 +96,7 @@ export type StyleExecutionPlan = { export function createStyleProfileSnapshot(source: StyleProfileSource): StyleProfileSnapshot { return { schemaVersion: 1, - presetId: source.presetId.trim(), + presetId: (source.presetId || source.id || "").trim(), title: source.title.trim(), description: source.description.trim(), tags: source.tags.map((tag) => tag.trim()).filter(Boolean), @@ -102,15 +105,17 @@ export function createStyleProfileSnapshot(source: StyleProfileSource): StylePro coverUrl: source.coverUrl?.trim() || undefined, sourceProfileId: source.sourceProfileId?.trim() || undefined, selection: source.selection ? { ...source.selection } : undefined, - assets: source.assets ? source.assets.map((asset) => ({ - ...asset, - parameters: asset.parameters ? { ...asset.parameters } : undefined, - license: asset.license ? { ...asset.license } : undefined, - baseModels: asset.baseModels ? [...asset.baseModels] : undefined, - triggerWords: asset.triggerWords ? [...asset.triggerWords] : undefined, - referenceUrls: asset.referenceUrls ? [...asset.referenceUrls] : undefined, - referenceResourceIds: asset.referenceResourceIds ? [...asset.referenceResourceIds] : undefined, - })) : [], + assets: source.assets + ? source.assets.map((asset) => ({ + ...asset, + parameters: asset.parameters ? { ...asset.parameters } : undefined, + license: asset.license ? { ...asset.license } : undefined, + baseModels: asset.baseModels ? [...asset.baseModels] : undefined, + triggerWords: asset.triggerWords ? [...asset.triggerWords] : undefined, + referenceUrls: asset.referenceUrls ? [...asset.referenceUrls] : undefined, + referenceResourceIds: asset.referenceResourceIds ? [...asset.referenceResourceIds] : undefined, + })) + : [], executionPolicy: source.executionPolicy || "compatible-fallback", source: source.source || "builtin", revision: source.revision || 1, @@ -131,7 +136,16 @@ export function parseStyleProfile(value?: string | null): StyleProfileSnapshot | if (!raw || new TextEncoder().encode(raw).length > MAX_PROFILE_BYTES) return null; try { const parsed = JSON.parse(raw) as Partial; - if (parsed.schemaVersion !== 1 || typeof parsed.presetId !== "string" || typeof parsed.title !== "string" || typeof parsed.description !== "string" || typeof parsed.prompt !== "string" || !Array.isArray(parsed.tags) || !Array.isArray(parsed.assets)) return null; + if ( + parsed.schemaVersion !== 1 || + typeof parsed.presetId !== "string" || + typeof parsed.title !== "string" || + typeof parsed.description !== "string" || + typeof parsed.prompt !== "string" || + !Array.isArray(parsed.tags) || + !Array.isArray(parsed.assets) + ) + return null; if (!parsed.tags.every((tag) => typeof tag === "string")) return null; if ([parsed.negativePrompt, parsed.coverUrl, parsed.sourceProfileId].some((value) => value !== undefined && typeof value !== "string")) return null; if (parsed.selection !== undefined && (!parsed.selection || typeof parsed.selection !== "object" || Array.isArray(parsed.selection) || !Object.values(parsed.selection).every((value) => typeof value === "string"))) return null; @@ -243,18 +257,34 @@ function resolveAssetExecution(asset: StyleAssetBinding, context: StyleExecution function isStyleAssetBinding(value: unknown): value is StyleAssetBinding { if (!value || typeof value !== "object") return false; const asset = value as Partial; - if (typeof asset.id !== "string" || typeof asset.kind !== "string" || !["lora", "template", "reference", "prompt"].includes(asset.kind) || typeof asset.provider !== "string" || typeof asset.title !== "string" || !["draft", "validated", "unavailable"].includes(String(asset.status))) return false; + if ( + typeof asset.id !== "string" || + typeof asset.kind !== "string" || + !["lora", "template", "reference", "prompt"].includes(asset.kind) || + typeof asset.provider !== "string" || + typeof asset.title !== "string" || + !["draft", "validated", "unavailable"].includes(String(asset.status)) + ) + return false; if (asset.enabled !== undefined && typeof asset.enabled !== "boolean") return false; if (asset.weight !== undefined && (!Number.isFinite(asset.weight) || asset.weight < 0 || asset.weight > 2)) return false; if (![asset.sourceId, asset.sourceUrl, asset.model, asset.version, asset.promptFragment].every(isOptionalString)) return false; if (![asset.baseModels, asset.triggerWords, asset.referenceUrls, asset.referenceResourceIds].every((values) => values === undefined || (Array.isArray(values) && values.every((item) => typeof item === "string")))) return false; - if (asset.parameters !== undefined && (!asset.parameters || typeof asset.parameters !== "object" - || !isOptionalString(asset.parameters.sampler) || !isOptionalString(asset.parameters.size) - || (asset.parameters.steps !== undefined && !Number.isInteger(asset.parameters.steps)) - || (asset.parameters.cfg !== undefined && !Number.isFinite(asset.parameters.cfg)))) return false; - if (asset.license !== undefined && (!asset.license || typeof asset.license !== "object" - || (asset.license.commercial !== undefined && typeof asset.license.commercial !== "boolean") - || ![asset.license.note, asset.license.source, asset.license.capturedAt].every(isOptionalString))) return false; + if ( + asset.parameters !== undefined && + (!asset.parameters || + typeof asset.parameters !== "object" || + !isOptionalString(asset.parameters.sampler) || + !isOptionalString(asset.parameters.size) || + (asset.parameters.steps !== undefined && !Number.isInteger(asset.parameters.steps)) || + (asset.parameters.cfg !== undefined && !Number.isFinite(asset.parameters.cfg))) + ) + return false; + if ( + asset.license !== undefined && + (!asset.license || typeof asset.license !== "object" || (asset.license.commercial !== undefined && typeof asset.license.commercial !== "boolean") || ![asset.license.note, asset.license.source, asset.license.capturedAt].every(isOptionalString)) + ) + return false; return !styleAssetValidationMessage(asset as StyleAssetBinding); } diff --git a/web/src/pages/canvas/use-canvas-style-workflow.ts b/web/src/pages/canvas/use-canvas-style-workflow.ts index ef98661e0..24a2fb71d 100644 --- a/web/src/pages/canvas/use-canvas-style-workflow.ts +++ b/web/src/pages/canvas/use-canvas-style-workflow.ts @@ -20,58 +20,51 @@ type UseCanvasStyleWorkflowOptions = { setStylePickerOpen: Dispatch>; }; -export function useCanvasStyleWorkflow({ - domainProjectId, - nodesRef, - selectedNodeIdsRef, - getCanvasCenter, - setNodes, - setSelectedNodeIds, - setSelectedConnectionId, - setDialogNodeId, - setStylePickerOpen, -}: UseCanvasStyleWorkflowOptions) { +export function useCanvasStyleWorkflow({ domainProjectId, nodesRef, selectedNodeIdsRef, getCanvasCenter, setNodes, setSelectedNodeIds, setSelectedConnectionId, setDialogNodeId, setStylePickerOpen }: UseCanvasStyleWorkflowOptions) { const { message } = App.useApp(); const queryClient = useQueryClient(); - const applyCanvasStyle = useCallback((preset: CanvasStylePreset, profileJson: string) => { - const current = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard"); - const metadata: CanvasNodeMetadata = { - content: preset.prompt, - prompt: preset.prompt, - status: "success", - workflowKind: "styleboard", - workflowTitle: "项目画风", - workflowDescription: preset.description, - stylePresetId: preset.id, - styleProfileJson: profileJson, - fontSize: 14, - }; - let styleNode: CanvasNodeData; - if (current) { - styleNode = { ...current, title: `画风 · ${preset.title}`, metadata: { ...current.metadata, ...metadata } }; - nodesRef.current = nodesRef.current.map((node) => node.id === current.id ? styleNode : node); - } else { - styleNode = createCanvasNode(CanvasNodeType.Text, getCanvasCenter(), metadata); - styleNode.title = `画风 · ${preset.title}`; - styleNode.width = 420; - styleNode.height = 240; - nodesRef.current = [...nodesRef.current, styleNode]; - } - setNodes(nodesRef.current); - const selection = new Set([styleNode.id]); - selectedNodeIdsRef.current = selection; - setSelectedNodeIds(selection); - setSelectedConnectionId(null); - setDialogNodeId(null); - setStylePickerOpen(false); - message.success(`已应用“${preset.title}”画风`); - }, [getCanvasCenter, message, nodesRef, selectedNodeIdsRef, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setStylePickerOpen]); + const applyCanvasStyle = useCallback( + (preset: CanvasStylePreset, profileJson: string) => { + const current = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard"); + const metadata: CanvasNodeMetadata = { + content: preset.prompt, + prompt: preset.prompt, + status: "success", + workflowKind: "styleboard", + workflowTitle: "项目画风", + workflowDescription: preset.description, + stylePresetId: preset.id, + styleProfileJson: profileJson, + fontSize: 14, + }; + let styleNode: CanvasNodeData; + if (current) { + styleNode = { ...current, title: `画风 · ${preset.title}`, metadata: { ...current.metadata, ...metadata } }; + nodesRef.current = nodesRef.current.map((node) => (node.id === current.id ? styleNode : node)); + } else { + styleNode = createCanvasNode(CanvasNodeType.Text, getCanvasCenter(), metadata); + styleNode.title = `画风 · ${preset.title}`; + styleNode.width = 420; + styleNode.height = 240; + nodesRef.current = [...nodesRef.current, styleNode]; + } + setNodes(nodesRef.current); + const selection = new Set([styleNode.id]); + selectedNodeIdsRef.current = selection; + setSelectedNodeIds(selection); + setSelectedConnectionId(null); + setDialogNodeId(null); + setStylePickerOpen(false); + message.success(`已应用“${preset.title}”画风`); + }, + [getCanvasCenter, message, nodesRef, selectedNodeIdsRef, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setStylePickerOpen], + ); const persistStyleMutation = useMutation({ mutationFn: ({ preset, profileJson }: { preset: CanvasStylePreset; profileJson: string }) => { if (!domainProjectId) throw new Error("画布尚未关联项目"); - return updateDomainProject(domainProjectId, { stylePresetId: preset.id, styleProfileJson }); + return updateDomainProject(domainProjectId, { stylePresetId: preset.id, styleProfileJson: profileJson }); }, onSuccess: (_project, { preset, profileJson }) => { applyCanvasStyle(preset, profileJson); @@ -80,15 +73,18 @@ export function useCanvasStyleWorkflow({ onError: (error) => message.error(error instanceof Error ? error.message : "项目画风保存失败"), }); - const selectCanvasStyle = useCallback((preset: CanvasStylePreset) => { - if (persistStyleMutation.isPending) return; - const profileJson = serializeStyleProfile(preset.profile || createStyleProfileSnapshot(preset)); - if (!domainProjectId) { - applyCanvasStyle(preset, profileJson); - return; - } - persistStyleMutation.mutate({ preset, profileJson }); - }, [applyCanvasStyle, domainProjectId, persistStyleMutation]); + const selectCanvasStyle = useCallback( + (preset: CanvasStylePreset) => { + if (persistStyleMutation.isPending) return; + const profileJson = serializeStyleProfile(preset.profile || createStyleProfileSnapshot(preset)); + if (!domainProjectId) { + applyCanvasStyle(preset, profileJson); + return; + } + persistStyleMutation.mutate({ preset, profileJson }); + }, + [applyCanvasStyle, domainProjectId, persistStyleMutation], + ); return { selectCanvasStyle, styleApplying: persistStyleMutation.isPending }; } diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index 4e2cb3950..5efa24927 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -23,10 +23,7 @@ export type ResponseToolCall = { thoughtSignature?: string; }; -export type ResponseInputMessage = - | AiTextMessage - | { type: "function_call"; call_id: string; name: string; arguments: string; thoughtSignature?: string } - | { role: "tool"; tool_call_id: string; content: string }; +export type ResponseInputMessage = AiTextMessage | { type: "function_call"; call_id: string; name: string; arguments: string; thoughtSignature?: string } | { role: "tool"; tool_call_id: string; content: string }; export type ResponseFunctionTool = { type: "function"; @@ -46,10 +43,7 @@ export type ToolResponseResult = { type ToolChoice = "auto" | "required" | { type: "function"; name: string }; type ResponseMessageContent = AiTextMessage["content"] | string; type ResponseInputContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string }; -type ResponseInputItem = - | { role: "system" | "user" | "assistant"; content: string | ResponseInputContent[] } - | { type: "function_call"; call_id: string; name: string; arguments: string } - | { type: "function_call_output"; call_id: string; output: string }; +type ResponseInputItem = { role: "system" | "user" | "assistant"; content: string | ResponseInputContent[] } | { type: "function_call"; call_id: string; name: string; arguments: string } | { type: "function_call_output"; call_id: string; output: string }; type ResponseApiToolDefinition = { type: "function"; name: string; @@ -57,9 +51,7 @@ type ResponseApiToolDefinition = { parameters: Record; strict?: boolean; }; -type ResponseApiOutputItem = - | { type?: "message"; content?: Array<{ type?: string; text?: string }> } - | { type?: "function_call"; id?: string; call_id?: string; name?: string; arguments?: string }; +type ResponseApiOutputItem = { type?: "message"; content?: Array<{ type?: string; text?: string }> } | { type?: "function_call"; id?: string; call_id?: string; name?: string; arguments?: string }; type ResponseApiPayload = { id?: string; output?: ResponseApiOutputItem[]; @@ -211,7 +203,8 @@ function dimensionGCD(left: number, right: number) { function resolveImageRequestSize(profile: ImageCapabilityConfig, quality: string | undefined, size: string) { const request = imageSizeRequest(profile, size); if (!request) return undefined; - return { parameter: request.parameter, value: request.parameter === "size" ? resolveRequestSize(quality, request.value) : resolveAspectRatio(request.value) }; + const value = request.parameter === "size" ? resolveRequestSize(quality, request.value) : resolveAspectRatio(request.value); + return value ? { parameter: request.parameter, value } : undefined; } function validateImageCapability(profile: ImageCapabilityConfig, prompt: string, references: ReferenceImage[], mask?: ReferenceImage) { @@ -620,12 +613,7 @@ async function requestStreamingChatCompletion(config: AiConfig, body: Record) { - const systemText = [ - config.systemPrompt.trim(), - ...messages.flatMap((message) => (!("type" in message) && message.role === "system" ? [geminiTextContent(message.content)] : [])), - ] - .filter(Boolean) - .join("\n\n"); + const systemText = [config.systemPrompt.trim(), ...messages.flatMap((message) => (!("type" in message) && message.role === "system" ? [geminiTextContent(message.content)] : []))].filter(Boolean).join("\n\n"); const contents = toGeminiContents(messages.filter((message) => ("type" in message ? true : message.role !== "system"))); return { contents, @@ -685,10 +673,7 @@ function toGeminiToolOptions(tools: ResponseFunctionTool[], toolChoice: ToolChoi description: tool.function.description, parameters: tool.function.parameters, })); - const functionCallingConfig = - typeof toolChoice === "object" - ? { mode: "ANY", allowedFunctionNames: [toolChoice.name] } - : { mode: toolChoice === "required" ? "ANY" : "AUTO" }; + const functionCallingConfig = typeof toolChoice === "object" ? { mode: "ANY", allowedFunctionNames: [toolChoice.name] } : { mode: toolChoice === "required" ? "ANY" : "AUTO" }; return { tools: [{ functionDeclarations }], toolConfig: { functionCallingConfig }, @@ -849,9 +834,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option ...(imageProfile.outputFormat.supported ? { output_format: IMAGE_OUTPUT_FORMAT } : {}), ...(imageProfile.transparentBackground.supported && normalizedImage.transparentBackground === "true" ? { background: "transparent" } : {}), }; - const responseData = isVolcengineArk - ? await postVolcengineArkImage(requestConfig, payload, options) - : await postChannelJSON(requestConfig, aiApiUrl(requestConfig, "/images/generations"), payload, options); + const responseData = isVolcengineArk ? await postVolcengineArkImage(requestConfig, payload, options) : await postChannelJSON(requestConfig, aiApiUrl(requestConfig, "/images/generations"), payload, options); const images = parseImagePayload(responseData); return images; } catch (error) { @@ -949,17 +932,33 @@ export async function requestImageQuestion(config: AiConfig, messages: AiTextMes return answer; } if (requestConfig.interfaceType === "chat-completion") { - const answer = (await requestStreamingChatCompletion(requestConfig, { - model: requestConfig.model, - messages: toChatCompletionMessages(withSystemMessage(requestConfig, messages)), - }, onDelta, options)).content || "没有返回内容"; + const answer = + ( + await requestStreamingChatCompletion( + requestConfig, + { + model: requestConfig.model, + messages: toChatCompletionMessages(withSystemMessage(requestConfig, messages)), + }, + onDelta, + options, + ) + ).content || "没有返回内容"; if (answer === "没有返回内容") onDelta(answer); return answer; } - const answer = (await requestStreamingResponse(requestConfig, { - model: requestConfig.model, - input: toResponseInput(withSystemMessage(requestConfig, messages)), - }, onDelta, options)).content || "没有返回内容"; + const answer = + ( + await requestStreamingResponse( + requestConfig, + { + model: requestConfig.model, + input: toResponseInput(withSystemMessage(requestConfig, messages)), + }, + onDelta, + options, + ) + ).content || "没有返回内容"; if (answer === "没有返回内容") onDelta(answer); return answer; } catch (error) { @@ -974,13 +973,18 @@ export async function requestToolResponse(config: AiConfig, messages: ResponseIn return await requestGeminiStreamingResponse(requestConfig, toGeminiBody(requestConfig, messages, toGeminiToolOptions(tools, toolChoice)), onDelta, options); } if (requestConfig.interfaceType === "chat-completion") { - return await requestStreamingChatCompletion(requestConfig, { - model: requestConfig.model, - messages: toChatCompletionMessages(withSystemMessage(requestConfig, messages)), - tools, - tool_choice: toChatCompletionToolChoice(toolChoice), - parallel_tool_calls: false, - }, onDelta, options); + return await requestStreamingChatCompletion( + requestConfig, + { + model: requestConfig.model, + messages: toChatCompletionMessages(withSystemMessage(requestConfig, messages)), + tools, + tool_choice: toChatCompletionToolChoice(toolChoice), + parallel_tool_calls: false, + }, + onDelta, + options, + ); } return await requestStreamingResponse( requestConfig,