fix(*): 持续集成 - 修复锁文件、格式门禁与生成链路构建失败
This commit is contained in:
29
.github/workflows/quality.yml
vendored
29
.github/workflows/quality.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: `制作专业人物三视图设定表。画面严格分成三个等宽竖向区域,从左到右依次为正面全身、右侧面全身、背面全身。三个视角必须是同一角色、同一服装、同一发型、同一体型和同一比例,采用站立中性姿势,完整显示头顶到脚底。背景使用纯净中性浅色和均匀设定稿光线,只负责分离轮廓,不得改变项目画风的绘画或渲染媒介。禁止文字、边框、道具说明、表情变化和额外人物。`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -68,7 +68,7 @@ func removeFixedMediaRestrictions(template string) string {
|
||||
func defaultStoryboardPromptTemplate() string {
|
||||
return `你是影视分镜导演和 AI 视频提示词专家。先理解故事目标、人物动机、冲突、情绪曲线和结尾,再把剧情转译为可执行、可拍摄的连续镜头,不要把剧情段落直接改写成镜头摘要。
|
||||
|
||||
每个镜头先确定 narrativeIntent、viewerPOV 和 performanceBlocking,再选择景别、机位、焦段、构图和运镜。摄影机设计必须说明叙事动机,不得用术语数量代替导演判断;一个镜头只保留一个主运镜,必须有起点、动机和停止点,并为人物表演让出注意力。
|
||||
每个镜头先确定观众此刻跟随谁,再明确 narrativeIntent、viewerPOV 和 performanceBlocking,然后选择景别、机位、焦段、构图和运镜。摄影机设计必须说明叙事动机,不得用术语数量代替导演判断;一个镜头只保留一个主运镜,必须有起点、动机和停止点,并为人物表演让出注意力。
|
||||
|
||||
大远景只承担空间、规模和处境建立;人物对白与喜剧反应优先使用中景、近景、过肩或独立反应镜头。按叙事需要使用反应、停顿、空镜、插入和匹配剪辑,避免每镜都推进、环绕、航拍或慢动作。
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
@@ -49,7 +49,10 @@ export type StyleProfileSnapshot = {
|
||||
revision: number;
|
||||
};
|
||||
|
||||
export type StyleProfileSource = Pick<StyleProfileSnapshot, "presetId" | "title" | "description" | "tags" | "prompt"> & {
|
||||
export type StyleProfileSource = Pick<StyleProfileSnapshot, "title" | "description" | "tags" | "prompt"> & {
|
||||
// 内置画风使用 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<StyleProfileSnapshot>;
|
||||
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<StyleAssetBinding>;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,58 +20,51 @@ type UseCanvasStyleWorkflowOptions = {
|
||||
setStylePickerOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<str
|
||||
}
|
||||
|
||||
function toGeminiBody(config: AiConfig, messages: ResponseInputMessage[], extra?: Record<string, unknown>) {
|
||||
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<ImageApiResponse>(requestConfig, aiApiUrl(requestConfig, "/images/generations"), payload, options);
|
||||
const responseData = isVolcengineArk ? await postVolcengineArkImage(requestConfig, payload, options) : await postChannelJSON<ImageApiResponse>(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,
|
||||
|
||||
Reference in New Issue
Block a user