From be655b6569c3cba41c18eb4d150151c69ab9d261 Mon Sep 17 00:00:00 2001 From: andy Date: Wed, 23 Sep 2026 15:10:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A02.5=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 +- README.md | 6 +- README.zh-CN.md | 7 +- backend/internal/application/runtime.go | 48 +++++- backend/internal/application/runtime_test.go | 66 ++++++++ backend/internal/billing/defaults.go | 26 ++++ backend/internal/billing/defaults_test.go | 34 +++-- backend/internal/billing/evolink25_test.go | 70 +++++++++ backend/internal/httpapi/openapi.go | 2 +- backend/internal/jobs/provider.go | 144 +++++++++++++++++- backend/internal/jobs/provider_test.go | 106 +++++++++++++ backend/internal/orchestration/creation.go | 9 ++ .../orchestration/orchestration_test.go | 42 +++++ backend/internal/providers/providers_test.go | 21 +++ backend/internal/settings/service.go | 2 +- backend/internal/templates/service.go | 5 + backend/internal/templates/service_test.go | 19 +++ components/create-studio.tsx | 99 ++++++++++-- docs/API.md | 21 +++ lib/evolink/image-client.ts | 71 ++++++++- lib/image-models.ts | 71 +++++++++ lib/server/app-settings.ts | 4 +- lib/server/billing-catalog.ts | 32 ++++ lib/server/generation-service.ts | 12 +- lib/server/image-template-input.ts | 9 ++ lib/server/public-api-jobs.ts | 23 +-- lib/types.ts | 1 + tests/evolink-image-client.test.ts | 55 +++++++ tests/evolink25-billing.test.ts | 20 +++ tests/image-models.test.ts | 56 +++++++ tests/image-templates.test.ts | 16 ++ tests/task-management.test.ts | 31 ++++ 32 files changed, 1075 insertions(+), 56 deletions(-) create mode 100644 backend/internal/billing/evolink25_test.go create mode 100644 lib/image-models.ts create mode 100644 tests/evolink25-billing.test.ts create mode 100644 tests/image-models.test.ts diff --git a/.env.example b/.env.example index 989deea..cdd9541 100644 --- a/.env.example +++ b/.env.example @@ -71,9 +71,10 @@ VOLCENGINE_SERVICE=cv VOLCENGINE_VISUAL_ENDPOINT=https://visual.volcengineapi.com JIMENG_IMAGE_GENERATE_46_REQ_KEY=jimeng_seedream46_cvtob -# EvoLink GPT Image 2 relay for image generation. +# EvoLink GPT Image 2 / 2.5 relay for image generation; all models share this API key. EVOLINK_API_KEY= EVOLINK_BASE_URL=https://api.evolink.ai +# Default only; each request can select gpt-image-2, gpt-image-2.5-flare or gpt-image-2.5-sunburst. EVOLINK_IMAGE_MODEL=gpt-image-2 EVOLINK_IMAGE_QUALITY=medium diff --git a/README.md b/README.md index 296a94e..e2618b8 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ https://你的域名/api/auth/callback - `jimeng`:默认引擎,走火山 Visual 即梦能力。 - `seedream`:走火山方舟 Seedream 5.0 Pro 同步图片接口,支持基础文生图、图文生图和多图融合。 -- `evolink`:走 EvoLink GPT Image 2 中转站,提交任务后轮询 EvoLink task 结果。 +- `evolink`:走 EvoLink GPT Image 2 / 2.5 中转站,提交任务后轮询 EvoLink task 结果。 - `bailian`: uses Alibaba Cloud Model Studio Wan 2.7 for text/reference image generation and image-to-video. ## 视频创作引擎 @@ -168,6 +168,10 @@ Seedream 5.0 Pro 使用火山方舟同步接口 `POST /api/v3/images/generations - 查询:`GET /v1/tasks/{task_id}` - 默认模型:`gpt-image-2` +创作页选择 EvoLink 后,可以切换 Image 2、Image 2.5 Flare 或 Image 2.5 Sunburst。三个模型共用已有 API Key;`EVOLINK_IMAGE_MODEL` 只决定默认模型,显式请求 `model` 可以逐次选择,旧任务继续使用原模型。模板会保存所选模型。 + +Image 2.5 当前开放 1K、单张、low/medium/high 三档质量,最多 16 张参考图。两种 2.5 模型均使用独立价格规则:基础输出估算成本 ¥0.04/¥0.09/¥0.35,默认加价后 ¥0.05/¥0.11/¥0.42/张。输入费用未单独加收,上游实际 token 账单可能不同。新规则随计费目录自动补齐,已有 Image 2 规则与管理员倍率保留。参数限制、价格来源及完整请求见 [API 文档](./docs/API.md#图片任务示例)。 + 未配置 `EVOLINK_API_KEY` 时,服务会明确报告凭据未配置,不会生成占位图片。 ## AI 生成台与提示词编排 diff --git a/README.zh-CN.md b/README.zh-CN.md index c28a382..5bae882 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -22,7 +22,7 @@ - 个人用量:点击页头账号 ID 查看真实任务次数 - 用量管理:`/usage`,管理员可见 - 计费中心:`/billing`,所有组织成员可见,超级管理员负责规则、组织余额与直接上账 -- 图片生成:即梦 4.6、Seedream 5.0 Pro、EvoLink GPT Image 2 或阿里云百炼 +- 图片生成:即梦 4.6、Seedream 5.0 Pro、EvoLink GPT Image 2 / 2.5 或阿里云百炼 - 视频生成:Seedance 2.0 - 素材引用:上传后可在提示词中使用 `@图片1`、`@视频1`、`@音频1` - 生产链路:未配置真实接口时明确报错,不生成占位结果 @@ -171,6 +171,7 @@ npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码 | 火山方舟 `doubao-seedance-2-5-260628` | 720P | ¥1.51 | 每秒 | | 火山方舟 `doubao-seedance-2-5-260628` | 1080P | ¥3.74 | 每秒 | | EvoLink `gpt-image-2` | medium / 1K / 1:1 / 无参考图基础估算 | ¥0.34 | 每张 | +| EvoLink `gpt-image-2.5-flare` / `gpt-image-2.5-sunburst` | 1K / low、medium、high 基础输出估算 | ¥0.04 / ¥0.09 / ¥0.35 | 每张 | | 即梦 `jimeng_seedream46_cvtob` | 公开资源包折算参考 | ¥0.20 | 每张 | | 火山方舟 `doubao-seedream-5-0-pro-260628` | 1K / 1.5K 基础生图 | ¥0.30 | 每张 | | 火山方舟 `doubao-seedream-5-0-pro-260628` | 2K 基础生图 | ¥0.60 | 每张 | @@ -187,9 +188,11 @@ npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码 - `jimeng`:火山 Visual 即梦能力 - `seedream`:火山方舟 Seedream 5.0 Pro 基础生图,支持文生图、图文生图和多图融合 -- `evolink`:EvoLink GPT Image 2 中转接口 +- `evolink`:EvoLink GPT Image 2 / 2.5 中转接口;可选择 Image 2、2.5 Flare 或 2.5 Sunburst,共用 API Key - `bailian`:阿里云百炼万相 2.7,支持文生图、最多 9 张参考图生图,以及 1–2 张首尾帧图生视频 +Image 2.5 当前支持 1K、单张输出、low/medium/high 和最多 16 张参考图,模板保存所选模型。默认加价后分别为 ¥0.05、¥0.11、¥0.42/张;输入费用未单独加收,此平台基础输出估算不等于上游实际 token 账单。新增价格规则自动补齐,保留已有 Image 2 规则及管理员倍率;无须新建 API Key 或数据库结构迁移。完整参数见 [API 文档](./docs/API.md#图片任务示例)。 + ### 视频生成 视频生成使用 Seedance 2.0。 diff --git a/backend/internal/application/runtime.go b/backend/internal/application/runtime.go index c4749e8..3844ba4 100644 --- a/backend/internal/application/runtime.go +++ b/backend/internal/application/runtime.go @@ -194,6 +194,18 @@ func providerImageTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget } } +func providerImageModels(getenv postgres.Getenv) map[string]jobs.ProviderTarget { + models := []string{"gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", firstNonEmpty(getenv("EVOLINK_IMAGE_MODEL"), "gpt-image-2")} + targets := make(map[string]jobs.ProviderTarget, len(models)) + for _, model := range models { + targets[model] = jobs.ProviderTarget{ + Provider: "evolink", Model: model, + Settings: map[string]any{"quality": firstNonEmpty(getenv("EVOLINK_IMAGE_QUALITY"), "medium")}, + } + } + return targets +} + func providerVideoTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget { seedanceModel := firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128") return map[string]jobs.ProviderTarget{ @@ -293,8 +305,13 @@ func capabilitySummary(getenv postgres.Getenv) func(context.Context) (any, error primaryVideo["label"] = "百炼图生视频" primaryVideo["limits"] = bailianVideoCapabilityLimits() } + imageCapability := map[string]any{"id": "image.generate", "label": imageCapabilityLabel(engine), "kind": "image", "engine": engine, "provider": provider, "reqKey": imageModel(getenv)} + imageCapability["evolink"] = evolinkImageCapability(getenv) + if engine == "evolink" { + imageCapability["models"] = evolinkImageModelOptions(getenv) + } return []any{ - map[string]any{"id": "image.generate", "label": imageCapabilityLabel(engine), "kind": "image", "engine": engine, "provider": provider, "reqKey": imageModel(getenv)}, + imageCapability, primaryVideo, map[string]any{"id": "video.generate.bailian", "label": "百炼图生视频", "kind": "video", "engine": "bailian", "provider": "bailian", "reqKey": firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25"), "limits": bailianVideoCapabilityLimits()}, map[string]any{"id": "video.generate.minimax", "label": "MiniMax H3 视频生成", "kind": "video", "engine": "minimax", "provider": "minimax", "reqKey": providers.MinimaxH3Model, "limits": minimaxVideoCapabilityLimits()}, @@ -302,6 +319,26 @@ func capabilitySummary(getenv postgres.Getenv) func(context.Context) (any, error } } +func evolinkImageModelOptions(getenv postgres.Getenv) []any { + options := []any{ + map[string]any{"id": "gpt-image-2", "label": "GPT Image 2"}, + map[string]any{"id": "gpt-image-2.5-flare", "label": "GPT Image 2.5 Flare"}, + map[string]any{"id": "gpt-image-2.5-sunburst", "label": "GPT Image 2.5 Sunburst"}, + } + custom := firstNonEmpty(getenv("EVOLINK_IMAGE_MODEL"), "gpt-image-2") + if custom != "gpt-image-2" && custom != "gpt-image-2.5-flare" && custom != "gpt-image-2.5-sunburst" { + options = append(options, map[string]any{"id": custom, "label": custom}) + } + return options +} + +func evolinkImageCapability(getenv postgres.Getenv) map[string]any { + return map[string]any{ + "reqKey": firstNonEmpty(getenv("EVOLINK_IMAGE_MODEL"), "gpt-image-2"), + "models": evolinkImageModelOptions(getenv), + } +} + func seedanceCapabilityLimits(model string) map[string]any { maximum := 15 if model == "doubao-seedance-2-5-260628" { @@ -346,6 +383,11 @@ func runtimeHealthDetails(getenv postgres.Getenv) httpapi.HealthDetails { } } videoCapability := map[string]any{"id": "video.generate", "label": "视频生成", "engine": video, "engineLabel": videoLabel, "reqKey": videoModel(getenv)} + imageCapability := map[string]any{"id": "image.generate", "label": imageCapabilityLabel(image), "engine": image, "engineLabel": imageLabel, "reqKey": imageModel(getenv)} + imageCapability["evolink"] = evolinkImageCapability(getenv) + if image == "evolink" { + imageCapability["models"] = evolinkImageModelOptions(getenv) + } if video == "seedance" { videoCapability["models"] = seedanceModelOptions() } @@ -357,7 +399,7 @@ func runtimeHealthDetails(getenv postgres.Getenv) httpapi.HealthDetails { MinimaxMode: providerMode(strings.TrimSpace(getenv("MINIMAX_API_KEY")) != "", "minimax"), AuthMode: authMode, Capabilities: []any{ - map[string]any{"id": "image.generate", "label": imageCapabilityLabel(image), "engine": image, "engineLabel": imageLabel, "reqKey": imageModel(getenv)}, + imageCapability, videoCapability, }, } @@ -477,7 +519,7 @@ func configuredProviderJobBuilder(getenv postgres.Getenv, enforceAvailability bo return jobs.ProviderJobBuilder{ ImageProvider: imageProvider(getenv), VideoProvider: videoProvider(getenv), ImageModel: imageModel(getenv), VideoModel: videoModel(getenv), ImageEngine: imageEngine(getenv), VideoEngine: videoEngine(getenv), - ImageEngines: providerImageTargets(getenv), VideoEngines: providerVideoTargets(getenv), VideoModels: providerVideoModels(getenv), UnavailableProviders: unavailable, NewID: applicationJobID, + ImageEngines: providerImageTargets(getenv), ImageModels: providerImageModels(getenv), VideoEngines: providerVideoTargets(getenv), VideoModels: providerVideoModels(getenv), UnavailableProviders: unavailable, NewID: applicationJobID, } } diff --git a/backend/internal/application/runtime_test.go b/backend/internal/application/runtime_test.go index effc848..e60f7f4 100644 --- a/backend/internal/application/runtime_test.go +++ b/backend/internal/application/runtime_test.go @@ -190,6 +190,72 @@ func TestRuntimeProviderJobBuilderRefreshesDatabaseSettings(t *testing.T) { } } +func TestEvoLinkImageModelOptionsAndConfiguredDefault(t *testing.T) { + getenv := func(name string) string { + switch name { + case "IMAGE_GENERATE_ENGINE": + return "evolink" + case "EVOLINK_IMAGE_MODEL": + return "custom-image-model" + case "EVOLINK_IMAGE_QUALITY": + return "high" + default: + return "" + } + } + options := evolinkImageModelOptions(getenv) + if len(options) != 4 || options[3].(map[string]any)["id"] != "custom-image-model" { + t.Fatalf("options=%#v", options) + } + capabilities, err := capabilitySummary(getenv)(context.Background()) + if err != nil { + t.Fatal(err) + } + image := capabilities.([]any)[0].(map[string]any) + if image["reqKey"] != "custom-image-model" || len(image["models"].([]any)) != 4 { + t.Fatalf("image capability=%#v", image) + } + builder := configuredProviderJobBuilder(getenv, false) + for _, model := range []string{"gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "custom-image-model"} { + command, err := builder.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{ + "engine": "evolink", "model": model, "prompt": "draw", + }) + if err != nil { + t.Fatalf("model %s: %v", model, err) + } + var request providers.Request + if err := json.Unmarshal(command.Job.RequestPayload, &request); err != nil { + t.Fatal(err) + } + if command.Job.ReqKey != model || request.Model != model || request.Settings["quality"] != "high" { + t.Fatalf("model %s: job=%#v request=%#v", model, command.Job, request) + } + } +} + +func TestImageCapabilityExposesEvoLinkDefaultWhenPrimaryIsJimeng(t *testing.T) { + getenv := func(name string) string { + if name == "EVOLINK_IMAGE_MODEL" { + return "gpt-image-2.5-sunburst" + } + return "" + } + capabilities, err := capabilitySummary(getenv)(context.Background()) + if err != nil { + t.Fatal(err) + } + image := capabilities.([]any)[0].(map[string]any) + evolink := image["evolink"].(map[string]any) + if image["engine"] != "jimeng" || evolink["reqKey"] != "gpt-image-2.5-sunburst" || len(evolink["models"].([]any)) != 3 { + t.Fatalf("image capability=%#v", image) + } + health := runtimeHealthDetails(getenv) + healthImage := health.Capabilities[0].(map[string]any) + if healthImage["evolink"].(map[string]any)["reqKey"] != "gpt-image-2.5-sunburst" { + t.Fatalf("health capability=%#v", healthImage) + } +} + func TestRuntimeProviderJobBuilderRoutesSeedreamWithoutReplacingJimeng(t *testing.T) { values := map[string]string{ "SEEDANCE_API_KEY": "ark-secret", diff --git a/backend/internal/billing/defaults.go b/backend/internal/billing/defaults.go index 8afdfd9..cc5d2ff 100644 --- a/backend/internal/billing/defaults.go +++ b/backend/internal/billing/defaults.go @@ -23,6 +23,8 @@ func DefaultBillingPriceRules() []PriceRule { rules := []PriceRule{ {ID: "base-volcengine-jimeng-seedream46", Provider: "volcengine-visual", Capability: "image.generate", ReqKey: "jimeng_seedream46_cvtob", Unit: UnitImage, StandardUnitPriceFen: 20, Note: "即梦4.6公开资源包基准。", Source: catalogSource("https://www.volcengine.com/activity/jimeng")}, {ID: "base-evolink-gpt-image-2", Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2", Unit: UnitImage, StandardUnitPriceFen: 34, Note: "medium / 1K / 1:1 / 无参考图基准。", Source: catalogSource("https://evolink.ai/zh/gpt-image-2"), Dimensions: evolinkDimensions()}, + evolink25PriceRule("gpt-image-2.5-flare", "https://evolink.ai/gpt-image-2-5"), + evolink25PriceRule("gpt-image-2.5-sunburst", "https://evolink.ai/zh/gpt-image-2-5-sunburst"), {ID: "base-bailian-wan27-image-pro", Provider: "bailian", Capability: "image.generate", ReqKey: "wan2.7-image-pro", Unit: UnitImage, StandardUnitPriceFen: 50, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-image-pro")}, {ID: "base-seedream-5-0-pro", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, Unit: UnitImage, StandardUnitPriceFen: 30, Note: "基础生图:1K/1.5K ¥0.30,2K ¥0.60;首张输入图免费,额外输入图 ¥0.02/张。", Source: catalogSource("https://docs.volcengine.com/docs/82379/1544106?lang=zh"), Dimensions: seedreamDimensions()}, {ID: "base-seedream-5-0-pro-layers", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, Unit: UnitImage, StandardUnitPriceFen: 15, Conditions: Conditions{"layerDecomposition": true}, Priority: 10, Note: "图层拆分:≤261万像素 ¥0.15/张,>261万像素 ¥0.30/张;最多预留底图加16个图层,完成后按每张实际像素结算。", Source: catalogSource("https://docs.volcengine.com/docs/82379/1544106?lang=zh"), Dimensions: seedreamDimensions()}, @@ -86,6 +88,30 @@ func evolinkDimensions() []ParameterDimension { } } +func evolink25PriceRule(model, sourceURL string) PriceRule { + dimensions := evolinkDimensions() + dimensions[0] = ParameterDimension{ + Key: "quality", Label: "生成质量", BaselineValue: "medium", DefaultValue: "medium", + Tiers: []ParameterTier{ + {Value: "low", Label: "快速", StandardFactor: 4.0 / 9, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true}, + {Value: "medium", Label: "标准", StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true}, + {Value: "high", Label: "精细", StandardFactor: 35.0 / 9, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true}, + }, + } + // This integration exposes only 1K. Do not inherit Image 2's 2K/4K estimates. + dimensions[1].Tiers = dimensions[1].Tiers[:1] + dimensions[2].Tiers = append(dimensions[2].Tiers, ParameterTier{Value: "other", Match: map[string]any{}, StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true}) + return PriceRule{ + ID: "base-evolink-" + model, Provider: "evolink", Capability: "image.generate", ReqKey: model, + Unit: UnitImage, StandardUnitPriceFen: 9, Dimensions: dimensions, + Note: "1K 基础输出估算:low/medium/high 成本为 ¥0.04/¥0.09/¥0.35;输入费用未单独加收,非上游实际 token 账单。", + Source: map[string]any{ + "url": sourceURL, "observedAt": "2026-09-23", "currency": "USD", "unitPrice": 0.012, "fxRate": 7.2, + "basis": "1024×1024、无参考图输出费用估算:low 约 $0.0053,medium 约 $0.012,high 约 $0.0474;人民币标准成本向上取整到分。参考图和提示词会影响上游实扣。", + }, + } +} + // NormalizeBillingParameters derives quote inputs from the persisted generation // payload. Explicit parameters are retained only as a fallback for values the // payload does not carry. diff --git a/backend/internal/billing/defaults_test.go b/backend/internal/billing/defaults_test.go index 641f87d..22b92d8 100644 --- a/backend/internal/billing/defaults_test.go +++ b/backend/internal/billing/defaults_test.go @@ -5,22 +5,24 @@ import "testing" func TestDefaultBillingPriceRulesFreezeOfficialCatalogMatchKeys(t *testing.T) { rules := DefaultBillingPriceRules() want := map[string]bool{ - "base-volcengine-jimeng-seedream46": false, - "base-evolink-gpt-image-2": false, - "base-bailian-wan27-image-pro": false, - "base-seedream-5-0-pro": false, - "base-seedream-5-0-pro-layers": false, - "base-bailian-wan27-i2v-720p": false, - "base-bailian-wan27-i2v-1080p": false, - "base-seedance-2-0-480p": false, - "base-seedance-2-0-720p": false, - "base-seedance-2-0-1080p": false, - "base-seedance-2-0-4k": false, - "base-seedance-2-5-480p": false, - "base-seedance-2-5-720p": false, - "base-seedance-2-5-1080p": false, - "base-minimax-h3-768p": false, - "base-minimax-h3-2k": false, + "base-volcengine-jimeng-seedream46": false, + "base-evolink-gpt-image-2": false, + "base-evolink-gpt-image-2.5-flare": false, + "base-evolink-gpt-image-2.5-sunburst": false, + "base-bailian-wan27-image-pro": false, + "base-seedream-5-0-pro": false, + "base-seedream-5-0-pro-layers": false, + "base-bailian-wan27-i2v-720p": false, + "base-bailian-wan27-i2v-1080p": false, + "base-seedance-2-0-480p": false, + "base-seedance-2-0-720p": false, + "base-seedance-2-0-1080p": false, + "base-seedance-2-0-4k": false, + "base-seedance-2-5-480p": false, + "base-seedance-2-5-720p": false, + "base-seedance-2-5-1080p": false, + "base-minimax-h3-768p": false, + "base-minimax-h3-2k": false, } for _, rule := range rules { if _, ok := want[rule.ID]; !ok { diff --git a/backend/internal/billing/evolink25_test.go b/backend/internal/billing/evolink25_test.go new file mode 100644 index 0000000..1c6f923 --- /dev/null +++ b/backend/internal/billing/evolink25_test.go @@ -0,0 +1,70 @@ +package billing + +import ( + "context" + "errors" + "testing" +) + +func TestEvoLink25QuotesUseIndependentModelAndQualityPrices(t *testing.T) { + catalog := Catalog{Rules: DefaultBillingPriceRules()} + for _, model := range []string{"gpt-image-2.5-flare", "gpt-image-2.5-sunburst"} { + for _, test := range []struct { + quality string + cost, amount int64 + }{{"low", 4, 5}, {"medium", 9, 11}, {"high", 35, 42}} { + t.Run(model+"/"+test.quality, func(t *testing.T) { + quote, err := catalog.Quote(QuoteInput{Provider: "evolink", Capability: "image.generate", ReqKey: model, Parameters: Parameters{"quality": test.quality}}) + if err != nil { + t.Fatal(err) + } + if quote.PriceRuleID != "base-evolink-"+model || quote.StandardUnitPriceFen != test.cost || quote.AmountFen != test.amount { + t.Fatalf("quote=%+v", quote) + } + }) + } + for _, params := range []Parameters{{"quality": "xhigh"}, {"resolution": "2K"}, {"resolution": "4K"}} { + if _, err := catalog.Quote(QuoteInput{Provider: "evolink", Capability: "image.generate", ReqKey: model, Parameters: params}); !errors.Is(err, ErrParameterTier) { + t.Fatalf("unsupported params %v: %v", params, err) + } + } + } + legacy, err := catalog.Quote(QuoteInput{Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2"}) + if err != nil || legacy.AmountFen != 41 { + t.Fatalf("legacy quote=%+v, err=%v", legacy, err) + } +} + +func TestEvoLink25A4QuoteKeepsModelAndQuality(t *testing.T) { + store := "eStoreStub{rules: DefaultBillingPriceRules()} + quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{ + Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2.5-sunburst", + Payload: map[string]any{"settings": map[string]any{"width": 848.0, "height": 1200.0, "quality": "high", "imageCount": 1.0}}, + }) + if err != nil { + t.Fatal(err) + } + if quote.AmountFen != 42 || quote.Parameters["aspectRatio"] != "70:99" { + t.Fatalf("quote=%+v", quote) + } +} + +func TestEvoLink25QuotesAllSupportedAspectRatios(t *testing.T) { + store := "eStoreStub{rules: DefaultBillingPriceRules()} + for _, size := range []string{"1:2", "2:3", "5:4", "21:9", "848x1200"} { + quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{ + Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2.5-flare", + Payload: map[string]any{"settings": map[string]any{"size": size, "quality": "medium", "imageCount": 1.0}}, + }) + if err != nil || quote.AmountFen != 11 { + t.Fatalf("size=%s quote=%+v err=%v", size, quote, err) + } + } + quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{ + Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2.5-flare", + Payload: map[string]any{"settings": map[string]any{"width": 512.0, "height": 1024.0, "quality": "medium"}}, + }) + if err != nil || quote.AmountFen != 11 || quote.Parameters["aspectRatio"] != "512:1024" { + t.Fatalf("unlisted ratio quote=%+v err=%v", quote, err) + } +} diff --git a/backend/internal/httpapi/openapi.go b/backend/internal/httpapi/openapi.go index 0c876df..12e5ec0 100644 --- a/backend/internal/httpapi/openapi.go +++ b/backend/internal/httpapi/openapi.go @@ -95,7 +95,7 @@ func openAPISchemas() map[string]any { "properties": map[string]any{ "capability": openAPIRef("GenerationCapability"), "engine": map[string]any{"type": "string", "enum": []string{"jimeng", "seedream", "evolink", "bailian", "seedance", "minimax"}}, - "model": map[string]any{"type": "string", "enum": []string{"doubao-seedance-2-0-260128", "doubao-seedance-2-5-260628"}, "description": "Optional allow-listed Seedance model for video.generate."}, + "model": map[string]any{"type": "string", "description": "Optional allow-listed model. For image.generate with engine=evolink: gpt-image-2, gpt-image-2.5-flare, gpt-image-2.5-sunburst, or the configured EVOLINK_IMAGE_MODEL. For video.generate with engine=seedance: doubao-seedance-2-0-260128 or doubao-seedance-2-5-260628."}, "prompt": map[string]any{"type": "string", "description": "Prompt text. Required for generation except Seedream layer decomposition, where an empty prompt triggers automatic decomposition."}, "inputUrls": map[string]any{"type": "array", "items": map[string]any{"type": "string", "format": "uri"}, "description": "Reference image URLs for image capabilities."}, "imageUrls": map[string]any{"type": "array", "items": map[string]any{"type": "string", "format": "uri"}, "description": "Alias for image input URLs."}, diff --git a/backend/internal/jobs/provider.go b/backend/internal/jobs/provider.go index 21bde44..cb66044 100644 --- a/backend/internal/jobs/provider.go +++ b/backend/internal/jobs/provider.go @@ -314,6 +314,7 @@ type ProviderJobBuilder struct { ImageModel, VideoModel string ImageEngine, VideoEngine string ImageEngines map[string]ProviderTarget + ImageModels map[string]ProviderTarget VideoEngines map[string]ProviderTarget VideoModels map[string]ProviderTarget UnavailableProviders map[string]string @@ -372,6 +373,21 @@ func (b ProviderJobBuilder) target(capability string, rawEngine, rawModel any) ( if rawEngine != nil && engine == "" { return ProviderTarget{}, "", invalidPreparation("unsupported image engine") } + configured := firstConfiguredEngine(b.ImageEngine, b.ImageProvider, "image") + if rawModel != nil { + if engine == "" { + engine = configured + } + model := stringValue(rawModel) + if model == "" || engine != "evolink" { + return ProviderTarget{}, "", invalidPreparation("unsupported image model") + } + target, ok := b.ImageModels[model] + if !ok || target.Provider != "evolink" || target.Model == "" { + return ProviderTarget{}, "", invalidPreparation("unsupported image model") + } + return target, engine, nil + } if engine != "" { if engine != "jimeng" && engine != "evolink" && engine != "bailian" && engine != "seedream" { return ProviderTarget{}, "", invalidPreparation("unsupported image engine") @@ -382,7 +398,6 @@ func (b ProviderJobBuilder) target(capability string, rawEngine, rawModel any) ( } return target, engine, nil } - configured := firstConfiguredEngine(b.ImageEngine, b.ImageProvider, "image") if target, ok := b.ImageEngines[configured]; ok && target.Provider == b.ImageProvider && target.Model == b.ImageModel { return target, configured, nil } @@ -483,6 +498,12 @@ func prepareProviderRequest(capability, engine, model string, defaults map[strin if err == nil { applyImageSettingDefaults(settings, defaults) } + if err == nil && engine == "evolink" && isEvoLinkImage25(model) { + err = validateEvoLinkImageQuality(body, settings) + } + if err == nil && engine == "evolink" && isEvoLinkImage25(model) { + err = validateEvoLinkImage25(body, urls, settings) + } if err == nil && engine == "bailian" { err = validateBailianImage(urls, settings) } @@ -517,6 +538,127 @@ func prepareProviderRequest(capability, engine, model string, defaults map[strin return preparedProviderRequest{request: providers.Request{Capability: capability, Prompt: text, InputURLs: urls, Materials: providerMaterials, Settings: settings}, assetIDs: assetIDs}, nil } +func validateEvoLinkImageQuality(body, settings map[string]any) error { + quality := body["quality"] + if quality == nil { + if nested, ok := body["settings"].(map[string]any); ok { + quality = nested["quality"] + } + } + if quality != nil { + value, ok := quality.(string) + if !ok || !validImageQuality(strings.ToLower(strings.TrimSpace(value))) { + return invalidPreparation("invalid image parameter: quality") + } + } + if settings["quality"] == nil { + settings["quality"] = "medium" + } + value, ok := settings["quality"].(string) + if !ok || !validImageQuality(value) { + return invalidPreparation("invalid image parameter: quality") + } + return nil +} + +func validImageQuality(value string) bool { + return value == "low" || value == "medium" || value == "high" +} + +func isEvoLinkImage25(model string) bool { + return model == "gpt-image-2.5-flare" || model == "gpt-image-2.5-sunburst" +} + +func validateEvoLinkImage25(body map[string]any, urls []string, settings map[string]any) error { + if len(urls) > 16 { + return invalidPreparation("EvoLink Image 2.5 supports at most 16 reference images") + } + resolutions := []any{body["resolution"]} + counts := []any{body["n"]} + if nested, ok := body["settings"].(map[string]any); ok { + resolutions = append(resolutions, nested["resolution"]) + counts = append(counts, nested["n"]) + } + for _, resolution := range resolutions { + if resolution != nil && !strings.EqualFold(strings.TrimSpace(stringValue(resolution)), "1K") { + return invalidPreparation("EvoLink Image 2.5 resolution must be 1K") + } + } + for _, count := range counts { + if count != nil { + value, ok := finiteNumber(count) + if !ok || value != 1 { + return invalidPreparation("EvoLink Image 2.5 n must be 1") + } + } + } + if raw := settings["imageCount"]; raw != nil { + count, ok := finiteNumber(raw) + if !ok || count != 1 { + return invalidPreparation("EvoLink Image 2.5 imageCount must be 1") + } + } + if settings["force_single"] == false { + return invalidPreparation("EvoLink Image 2.5 requires force_single") + } + if raw := settings["size"]; raw != nil { + size, _ := raw.(string) + if !evoLink1KSize(size) { + return invalidPreparation("EvoLink Image 2.5 size must be a supported 1K aspect ratio or A4 size") + } + } + widthRaw, heightRaw := settings["width"], settings["height"] + if (widthRaw == nil) != (heightRaw == nil) { + return invalidPreparation("EvoLink Image 2.5 requires both width and height") + } + if widthRaw != nil { + width, widthOK := finiteNumber(widthRaw) + height, heightOK := finiteNumber(heightRaw) + if !widthOK || !heightOK || width < 1 || height < 1 || width > 8192 || height > 8192 || width != math.Trunc(width) || height != math.Trunc(height) { + return invalidPreparation("invalid EvoLink Image 2.5 dimensions") + } + if !evoLink1KDimensions(int(width), int(height)) { + return invalidPreparation("EvoLink Image 2.5 custom dimensions exceed the supported 1K size") + } + } else if settings["size"] == nil { + // EvoLink's auto size may choose a larger output even with resolution=1K. + settings["size"] = "1:1" + } + settings["imageCount"] = float64(1) + return nil +} + +func evoLink1KSize(size string) bool { + switch strings.TrimSpace(size) { + case "1:1", "1:2", "2:1", "1:3", "3:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "9:21", "21:9", "848x1200", "1200x848": + return true + default: + return false + } +} + +func evoLink1KDimensions(width, height int) bool { + if (width == 848 && height == 1200) || (width == 1200 && height == 848) || + (width == 1680 && height == 2376) || (width == 2376 && height == 1680) { + return true + } + divisor := gcdImageDimensions(width, height) + if evoLink1KSize(fmt.Sprintf("%d:%d", width/divisor, height/divisor)) { + return true + } + shorter, longer := min(width, height), max(width, height) + pixels := width * height + return width%16 == 0 && height%16 == 0 && + pixels >= 655360 && pixels <= 1024*1024 && longer <= 3*shorter +} + +func gcdImageDimensions(left, right int) int { + for right != 0 { + left, right = right, left%right + } + return left +} + func applyImageSettingDefaults(settings, defaults map[string]any) { for key, value := range defaults { if _, configured := settings[key]; !configured { diff --git a/backend/internal/jobs/provider_test.go b/backend/internal/jobs/provider_test.go index 88c9200..dd6a316 100644 --- a/backend/internal/jobs/provider_test.go +++ b/backend/internal/jobs/provider_test.go @@ -62,6 +62,107 @@ func TestProviderJobBuilderRejectsUnconfiguredProviderBeforeCreation(t *testing. } } +func TestProviderJobBuilderSelectsAllowListedEvoLinkImageModel(t *testing.T) { + b := testProviderBuilder() + for _, model := range []string{"gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst"} { + cmd, err := b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{ + "engine": "evolink", "model": model, "prompt": "draw", "imageUrls": []any{"https://in.test/reference.png"}, + }) + if err != nil { + t.Fatalf("model %s: %v", model, err) + } + var request providers.Request + if err := json.Unmarshal(cmd.Job.RequestPayload, &request); err != nil { + t.Fatal(err) + } + if cmd.Job.Provider != "evolink" || cmd.Job.ReqKey != model || request.Model != model || (model != "gpt-image-2" && request.Settings["quality"] != "medium") { + t.Fatalf("model %s: job=%#v request=%#v", model, cmd.Job, request) + } + } +} + +func TestProviderJobBuilderRejectsUnknownOrCrossEngineImageModel(t *testing.T) { + b := testProviderBuilder() + for _, body := range []map[string]any{ + {"engine": "evolink", "model": "unknown", "prompt": "draw"}, + {"engine": "jimeng", "model": "gpt-image-2.5-flare", "prompt": "draw"}, + {"model": "gpt-image-2.5-flare", "prompt": "draw"}, + {"engine": "evolink", "model": 1, "prompt": "draw"}, + } { + _, err := b.Build(context.Background(), "owner", "", "image.generate", "", body) + if err == nil || !strings.Contains(err.Error(), "unsupported image model") { + t.Fatalf("body=%#v error=%v", body, err) + } + } +} + +func TestProviderJobBuilderRejectsInvalidEvoLinkQuality(t *testing.T) { + b := testProviderBuilder() + for _, body := range []map[string]any{ + {"engine": "evolink", "model": "gpt-image-2.5-flare", "prompt": "draw", "quality": "auto"}, + {"engine": "evolink", "model": "gpt-image-2.5-sunburst", "prompt": "draw", "settings": map[string]any{"quality": 1}}, + } { + _, err := b.Build(context.Background(), "owner", "", "image.generate", "", body) + if err == nil || !strings.Contains(err.Error(), "invalid image parameter: quality") { + t.Fatalf("body=%#v error=%v", body, err) + } + } +} + +func TestProviderJobBuilderEnforcesImage25OneKSingleOutput(t *testing.T) { + b := testProviderBuilder() + for _, body := range []map[string]any{ + {"resolution": "2K"}, + {"settings": map[string]any{"resolution": "2K"}}, + {"n": 2}, + {"settings": map[string]any{"n": 2}}, + {"size": "2K"}, + {"size": "2048x2048"}, + {"width": 2048, "height": 1537}, + {"width": 1200, "height": 1199}, + {"width": 1, "height": 7}, + {"width": 100, "height": 101}, + {"width": 800, "height": 816}, + {"width": 2048}, + {"imageCount": 2}, + {"force_single": false}, + } { + body["engine"], body["model"], body["prompt"] = "evolink", "gpt-image-2.5-flare", "draw" + if _, err := b.Build(context.Background(), "owner", "", "image.generate", "", body); err == nil { + t.Fatalf("accepted invalid Image 2.5 request: %#v", body) + } + } + urls := make([]any, 17) + for index := range urls { + urls[index] = fmt.Sprintf("https://in.test/%d.png", index) + } + if _, err := b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{ + "engine": "evolink", "model": "gpt-image-2.5-flare", "prompt": "draw", "imageUrls": urls, + }); err == nil { + t.Fatal("accepted more than 16 reference images") + } + for _, dimensions := range [][2]int{{2048, 2048}, {848, 1200}, {1200, 848}, {800, 1024}, {1280, 736}} { + if _, err := b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{ + "engine": "evolink", "model": "gpt-image-2.5-flare", "prompt": "draw", "width": dimensions[0], "height": dimensions[1], "force_single": true, + }); err != nil { + t.Fatalf("dimensions %v: %v", dimensions, err) + } + } + command, err := b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{ + "engine": "evolink", "model": "gpt-image-2.5-flare", "prompt": "draw", + }) + if err != nil { + t.Fatal(err) + } + var request providers.Request + if err := json.Unmarshal(command.Job.RequestPayload, &request); err != nil { + t.Fatal(err) + } + if request.Settings["size"] != "1:1" || request.Settings["imageCount"] != float64(1) { + t.Fatalf("default settings=%#v", request.Settings) + } +} + func TestProviderJobBuilderUsesPublicInputURLsForImageMaterialCoverage(t *testing.T) { b := testProviderBuilder() cmd, err := b.Build(context.Background(), "owner", "client", "image.generate", "", map[string]any{ @@ -733,6 +834,11 @@ func testProviderBuilder() ProviderJobBuilder { "bailian": {Provider: "bailian", Model: "bailian-image-test"}, "seedream": {Provider: "seedream", Model: providers.Seedream50ProModel, Settings: map[string]any{"size": "1.5K", "outputFormat": "png", "optimizeMode": "standard", "imageCount": float64(1)}}, }, + ImageModels: map[string]ProviderTarget{ + "gpt-image-2": {Provider: "evolink", Model: "gpt-image-2"}, + "gpt-image-2.5-flare": {Provider: "evolink", Model: "gpt-image-2.5-flare"}, + "gpt-image-2.5-sunburst": {Provider: "evolink", Model: "gpt-image-2.5-sunburst"}, + }, VideoEngines: map[string]ProviderTarget{ "seedance": {Provider: "seedance", Model: "seedance-test", Settings: map[string]any{"ratio": "9:16", "duration": 5, "resolution": "720p"}}, "bailian": {Provider: "bailian", Model: "bailian-video-test"}, diff --git a/backend/internal/orchestration/creation.go b/backend/internal/orchestration/creation.go index 117d408..f269feb 100644 --- a/backend/internal/orchestration/creation.go +++ b/backend/internal/orchestration/creation.go @@ -136,6 +136,15 @@ func (c *CreationCoordinator) RetryPlatform(ctx context.Context, session identit if engine := retryEngine(original.Provider, original.Capability); engine != "" { body["engine"] = engine } + if original.Provider == "evolink" && original.Capability == "image.generate" { + model := strings.TrimSpace(original.ReqKey) + if model == "" { + model = strings.TrimSpace(request.Model) + } + if model != "" { + body["model"] = model + } + } created, _, err := c.CreatePlatform(ctx, session, CreationInput{Capability: original.Capability, Body: body, RetryOf: original.ID}) if err != nil { return jobs.Job{}, err diff --git a/backend/internal/orchestration/orchestration_test.go b/backend/internal/orchestration/orchestration_test.go index 9c7a7ba..8661df0 100644 --- a/backend/internal/orchestration/orchestration_test.go +++ b/backend/internal/orchestration/orchestration_test.go @@ -285,6 +285,48 @@ func TestCreationCoordinatorRetryRebuildsAnyOwnedImageJobWithFreshQuote(t *testi } } +func TestCreationCoordinatorRetryPreservesEvoLinkImageModelForQuoteAndJob(t *testing.T) { + for _, test := range []struct { + name, reqKey, payloadModel, expected string + bodyModel bool + }{ + {name: "reqKey wins", reqKey: "gpt-image-2.5-flare", payloadModel: "gpt-image-2.5-sunburst", expected: "gpt-image-2.5-flare", bodyModel: true}, + {name: "payload fallback", payloadModel: "gpt-image-2.5-sunburst", expected: "gpt-image-2.5-sunburst", bodyModel: true}, + {name: "legacy default", expected: "gpt-image-2"}, + } { + t.Run(test.name, func(t *testing.T) { + builder := jobs.ProviderJobBuilder{ + ImageProvider: "evolink", ImageModel: "gpt-image-2", ImageEngine: "evolink", + ImageEngines: map[string]jobs.ProviderTarget{"evolink": {Provider: "evolink", Model: "gpt-image-2", Settings: map[string]any{"quality": "medium"}}}, + ImageModels: map[string]jobs.ProviderTarget{ + "gpt-image-2.5-flare": {Provider: "evolink", Model: "gpt-image-2.5-flare", Settings: map[string]any{"quality": "medium"}}, + "gpt-image-2.5-sunburst": {Provider: "evolink", Model: "gpt-image-2.5-sunburst", Settings: map[string]any{"quality": "medium"}}, + }, + NewID: func() string { return "retry-job" }, + } + creator := &creationStoreStub{} + quoter := "erStub{} + coordinator := NewCreationCoordinator(builder, creator, quoter, &chargeLedgerStub{}, &creationStateStub{}) + payload, _ := json.Marshal(providers.Request{Capability: "image.generate", Model: test.payloadModel, Prompt: "draw again", Settings: map[string]any{"quality": "high"}}) + original := jobs.Job{ID: "original", OwnerID: "account", Capability: "image.generate", Provider: "evolink", ReqKey: test.reqKey, RequestPayload: payload} + created, err := coordinator.RetryPlatform(context.Background(), identity.Session{User: identity.User{ID: "account", OrganizationID: "org"}}, original) + if err != nil { + t.Fatal(err) + } + var prepared providers.Request + if err := json.Unmarshal(created.RequestPayload, &prepared); err != nil { + t.Fatal(err) + } + if created.ReqKey != test.expected || prepared.Model != test.expected || quoter.command.ReqKey != test.expected || quoter.command.Provider != "evolink" || created.RetryOf != "original" || prepared.Settings["quality"] != "high" { + t.Fatalf("created=%#v prepared=%#v quote=%#v", created, prepared, quoter.command) + } + if got, ok := quoter.command.Payload["model"]; ok != test.bodyModel || (ok && got != test.expected) { + t.Fatalf("quote payload model=%#v present=%v", got, ok) + } + }) + } +} + func TestSafeBillingErrorMapsPostgresWalletFailures(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/providers/providers_test.go b/backend/internal/providers/providers_test.go index 5657e7d..64b2630 100644 --- a/backend/internal/providers/providers_test.go +++ b/backend/internal/providers/providers_test.go @@ -822,6 +822,27 @@ func TestEvoLinkPayloadIncludesQualityAndSize(t *testing.T) { } } +func TestEvoLinkImage25PayloadUsesSelectedModelAndOneK(t *testing.T) { + client := roundTripFunc(func(request *http.Request) (*http.Response, error) { + var body map[string]any + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + images, ok := body["image_urls"].([]any) + if request.URL.Path != "/v1/images/generations" || body["model"] != "gpt-image-2.5-flare" || body["quality"] != "medium" || body["n"] != float64(1) || body["resolution"] != "1K" || body["size"] != "848x1200" || !ok || len(images) != 1 { + t.Fatalf("payload=%#v", body) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"id":"task-25","status":"queued"}`)), Header: http.Header{}}, nil + }) + _, err := NewEvoLink(Config{BaseURL: "https://e.test", APIKey: "secret", Model: "gpt-image-2"}, client).Submit(context.Background(), Request{ + Capability: "image.generate", Model: "gpt-image-2.5-flare", Prompt: "draw", InputURLs: []string{"https://in.test/ref.png"}, + Settings: map[string]any{"quality": "medium", "width": 848, "height": 1200, "imageCount": float64(1)}, + }) + if err != nil { + t.Fatal(err) + } +} + func TestSeedreamPayloadAndSynchronousResponse(t *testing.T) { client := roundTripFunc(func(request *http.Request) (*http.Response, error) { if request.URL.Path != "/api/v3/images/generations" || request.Header.Get("Authorization") != "Bearer secret" { diff --git a/backend/internal/settings/service.go b/backend/internal/settings/service.go index b59a568..daab92d 100644 --- a/backend/internal/settings/service.go +++ b/backend/internal/settings/service.go @@ -602,7 +602,7 @@ func definitions() []Group { {ID: "auth", Title: "平台账号安全", Description: "平台自建手机号账号登录。", Fields: []Field{{Key: "ZHINIAN_AUTH_REQUIRED", Label: "登录保护", Type: "select", DefaultValue: "auto", Options: []Option{{Label: "自动", Value: "auto"}, {Label: "启用", Value: "1"}, {Label: "停用", Value: "0"}}}, {Key: "ZHINIAN_AUTH_SESSION_SECRET", Label: "会话签名密钥", Secret: true, Type: "password"}}}, {ID: "billing", Title: "企业计费", Description: "企业计费和对公账户信息。", Fields: []Field{{Key: "ZHINIAN_BILLING_REQUIRED", Label: "真实任务计费", Type: "select", DefaultValue: "1", Options: []Option{{Label: "启用", Value: "1"}, {Label: "停用(免计费)", Value: "0"}}}, {Key: "ZHINIAN_BILLING_ACCOUNT_NAME", Label: "对公账户名称"}, {Key: "ZHINIAN_BILLING_ACCOUNT_BANK", Label: "开户行"}, {Key: "ZHINIAN_BILLING_ACCOUNT_NUMBER", Label: "银行账号"}, {Key: "ZHINIAN_BILLING_CONTACT", Label: "充值对接信息"}}}, {ID: "visual", Title: "即梦图片 API", Description: "火山 AK/SK。", Fields: []Field{{Key: "VOLCENGINE_ACCESS_KEY_ID", Label: "Access Key ID", Secret: true, Type: "password"}, {Key: "VOLCENGINE_SECRET_ACCESS_KEY", Label: "Secret Access Key", Secret: true, Type: "password"}}}, - {ID: "evolink", Title: "EvoLink 图片 API", Description: "GPT Image 2 图片生成。", Fields: []Field{{Key: "EVOLINK_API_KEY", Label: "EvoLink API Key", Secret: true, Type: "password"}, {Key: "EVOLINK_BASE_URL", Label: "Base URL", DefaultValue: "https://api.evolink.ai"}, {Key: "EVOLINK_IMAGE_MODEL", Label: "图片模型", DefaultValue: "gpt-image-2"}, {Key: "EVOLINK_IMAGE_QUALITY", Label: "质量", DefaultValue: "medium"}}}, + {ID: "evolink", Title: "EvoLink 图片 API", Description: "GPT Image 2 / 2.5 图片生成。", Fields: []Field{{Key: "EVOLINK_API_KEY", Label: "EvoLink API Key", Secret: true, Type: "password"}, {Key: "EVOLINK_BASE_URL", Label: "Base URL", DefaultValue: "https://api.evolink.ai"}, {Key: "EVOLINK_IMAGE_MODEL", Label: "默认图片模型", DefaultValue: "gpt-image-2"}, {Key: "EVOLINK_IMAGE_QUALITY", Label: "默认质量", DefaultValue: "medium"}}}, {ID: "seedance", Title: "火山方舟 API", Description: "Seedance 视频与 Seedream 5.0 Pro 图片共用此 API Key。", Fields: []Field{{Key: "SEEDANCE_API_KEY", Label: "方舟 API Key", Secret: true, Type: "password"}}}, {ID: "bailian", Title: "阿里云百炼 API", Description: "万相图片与视频。", Fields: []Field{{Key: "BAILIAN_API_KEY", Label: "百炼 API Key", Secret: true, Type: "password"}, {Key: "BAILIAN_BASE_URL", Label: "Base URL", DefaultValue: "https://llm-126wneubbdo6dbr5.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"}, {Key: "BAILIAN_IMAGE_MODEL", Label: "图片模型", DefaultValue: "wan2.7-image-pro"}, {Key: "BAILIAN_VIDEO_MODEL", Label: "视频模型", DefaultValue: "wan2.7-i2v-2026-04-25"}}}, {ID: "minimax", Title: "MiniMax API", Description: "MiniMax H3 视频生成。", Fields: []Field{{Key: "MINIMAX_API_KEY", Label: "MiniMax API Key", Secret: true, Type: "password"}, {Key: "MINIMAX_BASE_URL", Label: "Base URL", DefaultValue: "https://api.minimax.cn"}}}, diff --git a/backend/internal/templates/service.go b/backend/internal/templates/service.go index 5b74dfc..5e55e9d 100644 --- a/backend/internal/templates/service.go +++ b/backend/internal/templates/service.go @@ -20,6 +20,7 @@ var ( type Settings struct { Engine string `json:"engine,omitempty"` + Model string `json:"model,omitempty"` Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` ForceSingle *bool `json:"forceSingle,omitempty"` @@ -231,6 +232,10 @@ func normalizeSettings(value Settings) Settings { if value.Engine != "jimeng" && value.Engine != "evolink" && value.Engine != "bailian" && value.Engine != "seedream" { value.Engine = "" } + value.Model = strings.TrimSpace(value.Model) + if value.Engine != "evolink" || len(value.Model) > 120 { + value.Model = "" + } if value.Width < 1 || value.Height < 1 { value.Width, value.Height = 0, 0 } else { diff --git a/backend/internal/templates/service_test.go b/backend/internal/templates/service_test.go index d0bc18c..3f32a1e 100644 --- a/backend/internal/templates/service_test.go +++ b/backend/internal/templates/service_test.go @@ -86,6 +86,25 @@ func TestServiceNormalizesAndScopesTemplates(t *testing.T) { } } +func TestTemplateSettingsPreserveEvoLinkModelAndLegacyEmptyModel(t *testing.T) { + service := NewService(&memoryCatalog{}, nil, func() string { return "tmpl_model" }) + created, err := service.Create(context.Background(), "owner", CreateCommand{ + Name: "Flare", Prompt: "draw", Settings: Settings{Engine: "evolink", Model: " gpt-image-2.5-flare ", Quality: "medium"}, + }) + if err != nil || created.Settings.Model != "gpt-image-2.5-flare" { + t.Fatalf("created=%#v err=%v", created, err) + } + legacy, err := service.Create(context.Background(), "owner", CreateCommand{ + Name: "Legacy", Prompt: "draw", Settings: Settings{Engine: "evolink"}, + }) + if err != nil || legacy.Settings.Model != "" { + t.Fatalf("legacy=%#v err=%v", legacy, err) + } + if got := normalizeSettings(Settings{Engine: "jimeng", Model: "gpt-image-2.5-flare"}); got.Model != "" { + t.Fatalf("cross-engine model=%#v", got) + } +} + func TestServiceUpdateDeleteAndNotFound(t *testing.T) { now := time.Now().UTC() catalog := &memoryCatalog{items: []Template{{ID: "t1", OwnerID: "owner", Name: "old", Prompt: "p", Settings: Settings{}, CreatedAt: now, UpdatedAt: now}}} diff --git a/components/create-studio.tsx b/components/create-studio.tsx index fb2b341..929b9dc 100644 --- a/components/create-studio.tsx +++ b/components/create-studio.tsx @@ -6,6 +6,7 @@ import clsx from "clsx"; import { clampPage, pageItems, Pagination } from "@/components/pagination"; import { SeedreamWorkspace } from "@/components/seedream-workspace"; import { imageSizePresets, imageDimensionsForEngine, imageSizePresetFromDimensions, type ImageSizePreset } from "@/lib/image-size-presets"; +import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, configuredImageModel, evolinkModelCapability, imageModelAfterHealth, imageModelFromJob, imageModelLabel, templateImageModel } from "@/lib/image-models"; import { SeedreamLayerResultViewer, type SeedreamLayerOutput } from "@/components/seedream-layer-result-viewer"; import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; import { @@ -74,6 +75,8 @@ type HealthCapability = { id: string; engine?: string; reqKey?: string; + models?: Array<{ id: string; label: string }>; + evolink?: { reqKey?: string; models?: Array<{ id: string; label: string }> }; }; type ImageTemplate = { @@ -83,6 +86,7 @@ type ImageTemplate = { previewImageUrl?: string; settings: { engine?: ImageGenerateEngine; + model?: string; width?: number; height?: number; forceSingle?: boolean; @@ -101,6 +105,7 @@ type TemplateForm = { prompt: string; previewImageUrl: string; engine: ImageGenerateEngine; + evolinkModel: string; jimengInfluence: string; evolinkQuality: string; seedreamSize: string; @@ -123,6 +128,8 @@ const evolinkQualityOptions = [ { id: "high", label: "精细", quality: "high" } ]; +const evolinkModelOptions = IMAGE_MODEL_OPTIONS; + const seedreamSizeOptions = ["1K", "1.5K", "2K"] as const; const seedreamLayerSizeOptions = ["auto", "1K", "1.5K", "2K"] as const; const seedreamOutputFormatOptions = [ @@ -138,6 +145,8 @@ type TemplateConsoleSnapshot = { promptByMode: Record; imageSize: ImageSizePreset; imageEngine: ImageGenerateEngine; + evolinkModel: string; + evolinkModelChosen: boolean; jimengInfluence: string; evolinkQuality: string; seedreamSize: string; @@ -155,6 +164,7 @@ const defaultTemplateForm: TemplateForm = { prompt: "", previewImageUrl: "", engine: "jimeng", + evolinkModel: DEFAULT_IMAGE_MODEL, jimengInfluence: jimengInfluenceOptions[1].id, evolinkQuality: evolinkQualityOptions[1].id, seedreamSize: "1.5K", @@ -197,6 +207,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate const [notice, setNotice] = useState(null); const [imageSize, setImageSize] = useState(imageSizePresets[0]); const [imageEngine, setImageEngine] = useState("jimeng"); + const [evolinkModel, setEvolinkModel] = useState(DEFAULT_IMAGE_MODEL); + const [evolinkDefaultModel, setEvolinkDefaultModel] = useState(DEFAULT_IMAGE_MODEL); + const [evolinkModels, setEvolinkModels] = useState>([...evolinkModelOptions]); const [jimengInfluence, setJimengInfluence] = useState(jimengInfluenceOptions[1].id); const [evolinkQuality, setEvolinkQuality] = useState(evolinkQualityOptions[1].id); const [seedreamSize, setSeedreamSize] = useState("1.5K"); @@ -226,6 +239,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate const promptRef = useRef(null); const templatePromptRef = useRef(null); const materialDraftInputRef = useRef(null); + const evolinkModelChosenRef = useRef(false); const generateMode: GenerateMode = mode === "video" ? "video" : "image"; const prompt = promptByMode[generateMode]; @@ -400,6 +414,13 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate if (active && (imageCapability?.engine === "evolink" || imageCapability?.engine === "jimeng" || imageCapability?.engine === "bailian" || imageCapability?.engine === "seedream")) { setImageEngine(imageCapability.engine); } + const evolinkCapability = evolinkModelCapability(imageCapability); + if (active && evolinkCapability) { + const models = normalizedImageModelOptions(evolinkCapability.models); + setEvolinkModels(models); + setEvolinkDefaultModel(configuredImageModel(models, evolinkCapability.reqKey)); + setEvolinkModel((current) => imageModelAfterHealth(models, evolinkCapability.reqKey, current, evolinkModelChosenRef.current)); + } if (active && (videoCapability?.engine === "seedance" || videoCapability?.engine === "bailian" || videoCapability?.engine === "minimax")) { setVideoEngine(videoCapability.engine); if (videoCapability.engine === "seedance") { @@ -498,7 +519,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate active = false; window.clearTimeout(timer); }; - }, [generateMode, imageCreationMode, imageEngine, imageSize.height, imageSize.width, seedreamInteractiveSourceKeys, seedreamRequestAnnotations, seedreamLayerSize, seedreamOptimizeMode, seedreamOutputFormat, seedreamSize, seedreamSourceKey, selectedEvolinkQuality.quality, selectedJimengInfluence.scale, videoDuration, videoEngine, videoModel, videoRatio, videoResolution, materials, generationInputReady, prompt]); + }, [generateMode, imageCreationMode, imageEngine, evolinkModel, imageSize.height, imageSize.width, seedreamInteractiveSourceKeys, seedreamRequestAnnotations, seedreamLayerSize, seedreamOptimizeMode, seedreamOutputFormat, seedreamSize, seedreamSourceKey, selectedEvolinkQuality.quality, selectedJimengInfluence.scale, videoDuration, videoEngine, videoModel, videoRatio, videoResolution, materials, generationInputReady, prompt]); async function loadImageTemplates(isActive: () => boolean = () => true) { setTemplatesLoading(true); @@ -775,6 +796,8 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate promptByMode: { ...promptByMode }, imageSize, imageEngine, + evolinkModel, + evolinkModelChosen: evolinkModelChosenRef.current, jimengInfluence, evolinkQuality, seedreamSize, @@ -797,6 +820,8 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate setPromptByMode(snapshot.promptByMode); setImageSize(snapshot.imageSize); setImageEngine(snapshot.imageEngine); + evolinkModelChosenRef.current = snapshot.evolinkModelChosen; + setEvolinkModel(snapshot.evolinkModel); setJimengInfluence(snapshot.jimengInfluence); setEvolinkQuality(snapshot.evolinkQuality); setSeedreamSize(snapshot.seedreamSize); @@ -806,6 +831,8 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate setPromptByMode((items) => ({ ...items, [generateMode]: "" })); setImageSize(imageSizePresets[0]); setImageEngine(imageCreationMode === "basic" ? "jimeng" : "seedream"); + evolinkModelChosenRef.current = false; + setEvolinkModel(evolinkDefaultModel); setJimengInfluence(jimengInfluenceOptions[1].id); setEvolinkQuality(evolinkQualityOptions[1].id); setSeedreamSize("1.5K"); @@ -822,6 +849,10 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate if (generateMode === "image") { const templateEngine = normalizeImageEngine(template.settings.engine); if (templateEngine) setImageEngine(imageCreationMode === "basic" ? templateEngine : "seedream"); + if (templateEngine === "evolink") { + evolinkModelChosenRef.current = Boolean(template.settings.model); + setEvolinkModel(templateImageModel(template.settings.model, evolinkDefaultModel)); + } const preset = imageSizePresetFromDimensions(template.settings.width, template.settings.height); if (preset) setImageSize(preset); const templateInfluence = jimengInfluenceOptions.find((option) => option.scale === template.settings.scale); @@ -839,7 +870,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate } function resetTemplateForm() { - setTemplateForm(defaultTemplateFormForEngine(imageEngine)); + setTemplateForm(defaultTemplateFormForEngine(imageEngine, evolinkModel)); setEditingTemplateId(null); setTemplateError(null); setMaterialDraft(null); @@ -853,7 +884,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate function openTemplateEditor(template: ImageTemplate) { setTemplateForm({ - ...formFromTemplate(template), + ...formFromTemplate(template, evolinkDefaultModel), previewImageUrl: storedAssetPreviewUrl(template.previewImageUrl, taskAssets) }); setEditingTemplateId(template.id); @@ -889,7 +920,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate const rest = items.filter((item) => item.id !== saved.id); return [...rest, saved].sort(sortTemplates); }); - setTemplateForm(formFromTemplate(saved)); + setTemplateForm(formFromTemplate(saved, evolinkDefaultModel)); setEditingTemplateId(null); setTemplateEditorOpen(false); applyTemplateToConsole(saved, isEditing ? "模板已更新并应用。" : "模板已保存并应用。"); @@ -1069,7 +1100,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate materials: materials.filter((material) => material.type === "image"), ...imageDimensionsForEngine(imageSize, imageEngine), ...(imageEngine === "evolink" - ? { quality: selectedEvolinkQuality.quality } + ? { model: evolinkModel, quality: selectedEvolinkQuality.quality } : { scale: selectedJimengInfluence.scale }), force_single: true }; @@ -1241,6 +1272,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
{template.name} + {template.settings.engine === "evolink" ? {imageModelLabel(templateImageModel(template.settings.model, evolinkDefaultModel))} : null}
+ {!specializedImageMode && imageEngine === "evolink" ? ( +
+ + +
+ ) : null}
{imageEngine === "seedream" ? ( @@ -1660,7 +1703,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate />
- {imageEngineLabel(templateForm.engine)} + {templateForm.engine === "evolink" ? imageModelLabel(templateForm.evolinkModel) : imageEngineLabel(templateForm.engine)} {templateForm.engine === "evolink" ? selectedTemplateEvolinkQuality.label : templateForm.engine === "bailian" ? "智能推理" : templateForm.engine === "seedream" ? selectedTemplateSeedreamOptimizeMode.label : selectedTemplateJimengInfluence.label} {templateForm.engine === "seedream" ? templateForm.seedreamSize : templateForm.size}
@@ -1687,7 +1730,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate aria-pressed={templateForm.engine === "evolink"} onClick={() => setTemplateForm((form) => ({ ...form, engine: "evolink" }))} > - Image2 + EvoLink
+ {templateForm.engine === "evolink" ? ( + + ) : null}