fix(*): 持续集成 - 修复锁文件、格式门禁与生成链路构建失败

This commit is contained in:
ddCat
2026-08-07 12:35:03 +08:00
parent 06ddff0d6c
commit 43f41d9e00
18 changed files with 289 additions and 238 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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}

View File

@@ -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{

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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: `制作专业人物三视图设定表。画面严格分成三个等宽竖向区域,从左到右依次为正面全身、右侧面全身、背面全身。三个视角必须是同一角色、同一服装、同一发型、同一体型和同一比例,采用站立中性姿势,完整显示头顶到脚底。背景使用纯净中性浅色和均匀设定稿光线,只负责分离轮廓,不得改变项目画风的绘画或渲染媒介。禁止文字、边框、道具说明、表情变化和额外人物。`,
},
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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"},

View File

@@ -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 {

View File

@@ -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}

View File

@@ -68,7 +68,7 @@ func removeFixedMediaRestrictions(template string) string {
func defaultStoryboardPromptTemplate() string {
return `你是影视分镜导演和 AI 视频提示词专家。先理解故事目标、人物动机、冲突、情绪曲线和结尾,再把剧情转译为可执行、可拍摄的连续镜头,不要把剧情段落直接改写成镜头摘要。
每个镜头先确定 narrativeIntent、viewerPOV 和 performanceBlocking,再选择景别、机位、焦段、构图和运镜。摄影机设计必须说明叙事动机,不得用术语数量代替导演判断;一个镜头只保留一个主运镜,必须有起点、动机和停止点,并为人物表演让出注意力。
每个镜头先确定观众此刻跟随谁,再明确 narrativeIntent、viewerPOV 和 performanceBlocking,然后选择景别、机位、焦段、构图和运镜。摄影机设计必须说明叙事动机,不得用术语数量代替导演判断;一个镜头只保留一个主运镜,必须有起点、动机和停止点,并为人物表演让出注意力。
大远景只承担空间、规模和处境建立;人物对白与喜剧反应优先使用中景、近景、过肩或独立反应镜头。按叙事需要使用反应、停顿、空镜、插入和匹配剪辑,避免每镜都推进、环绕、航拍或慢动作。

View File

@@ -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=="],

View File

@@ -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);
}

View File

@@ -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 };
}

View File

@@ -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,