diff --git a/lib/style-pro/model-availability.ts b/lib/style-pro/model-availability.ts new file mode 100644 index 000000000..89822ebdb --- /dev/null +++ b/lib/style-pro/model-availability.ts @@ -0,0 +1,51 @@ +import type { AiModel, AiProvider } from "@/lib/db/schema"; +import { + getAiModelFrontendAvailability, + type ModelFrontendAvailability, +} from "@/lib/ai/model-frontend-availability"; +import { modelSupportsReferenceImageInput } from "@/lib/ai/reference-image-capability"; + +type StyleProAvailabilityEntry = { + model: Pick< + AiModel, + | "displayName" + | "modelId" + | "supportsVision" + | "capabilities" + | "defaultParams" + | "isEnabled" + | "healthStatus" + | "healthAutoDisabledAt" + | "healthObservationStartedAt" + >; + provider: Pick; +}; + +export function getStyleProModelAvailability( + entry: StyleProAvailabilityEntry +): ModelFrontendAvailability { + if (!modelSupportsReferenceImageInput(entry.model, entry.provider)) { + return { + availabilityStatus: "maintenance", + availabilityLabel: "仅文生图", + selectable: false, + disabledReason: "风格化需要参考图,该模型仅支持文生图", + }; + } + + const availability = getAiModelFrontendAvailability(entry.model); + if (availability.availabilityStatus === "testing") { + return { + availabilityStatus: "testing", + availabilityLabel: "暂不可用", + selectable: false, + disabledReason: "模型近期生成异常,恢复健康后会自动开放", + }; + } + + return availability; +} + +export function isStyleProModelRunnable(entry: StyleProAvailabilityEntry) { + return getStyleProModelAvailability(entry).selectable; +} diff --git a/lib/style-pro/model-options.ts b/lib/style-pro/model-options.ts index 800ac3fde..c1cf4965d 100644 --- a/lib/style-pro/model-options.ts +++ b/lib/style-pro/model-options.ts @@ -6,7 +6,6 @@ import { getImageModelCreditDiscountLabel, } from "@/lib/ai/image-model-pricing"; import { - getAiModelFrontendAvailability, type ModelFrontendAvailabilityStatus, } from "@/lib/ai/model-frontend-availability"; import { isStillImageGenerationModelDescriptor } from "@/lib/ai/model-classification"; @@ -14,6 +13,10 @@ import { getEnabledAiModelsWithProvider, getFrontendDisplayAiModelsWithProvider, } from "@/lib/db/queries"; +import { + getStyleProModelAvailability, + isStyleProModelRunnable, +} from "./model-availability"; export type StyleProModelEntry = Awaited< ReturnType @@ -56,7 +59,9 @@ export async function getStyleProImageModelEntries(options?: { false, options?.includeHidden === true ); - return models.filter(isStyleProImageModel); + return models + .filter(isStyleProImageModel) + .filter(isStyleProModelRunnable); } export async function getStyleProImageModelDisplayEntries(options?: { @@ -105,7 +110,7 @@ export function toPublicStyleProModelOption( ): PublicStyleProModelOption { const imageGenConfig = (entry.model.imageGenConfig || null) as DiscountableImageGenConfigLike | null; - const availability = getAiModelFrontendAvailability(entry.model); + const availability = getStyleProModelAvailability(entry); return { id: entry.model.id, modelId: entry.model.modelId, diff --git a/tests/routes/model-classification.test.ts b/tests/routes/model-classification.test.ts index ad3dadcc9..b501fa902 100644 --- a/tests/routes/model-classification.test.ts +++ b/tests/routes/model-classification.test.ts @@ -4,6 +4,35 @@ import { isStillImageGenerationModelDescriptor, isVideoModelDescriptor, } from "@/lib/ai/model-classification"; +import { getStyleProModelAvailability } from "@/lib/style-pro/model-availability"; +import type { AiProvider } from "@/lib/db/schema"; + +function styleProEntry(overrides?: { + providerType?: AiProvider["type"]; + modelId?: string; + supportsVision?: boolean; + healthStatus?: "healthy" | "degraded" | "auto_disabled"; + healthAutoDisabledAt?: Date | null; + healthObservationStartedAt?: Date | null; +}) { + return { + model: { + displayName: "Test Image Model", + modelId: overrides?.modelId || "test-image-model", + supportsVision: overrides?.supportsVision ?? true, + capabilities: null, + defaultParams: null, + isEnabled: true, + healthStatus: overrides?.healthStatus || "healthy", + healthAutoDisabledAt: overrides?.healthAutoDisabledAt || null, + healthObservationStartedAt: + overrides?.healthObservationStartedAt || null, + }, + provider: { + type: overrides?.providerType || "openai-image", + }, + }; +} test.describe("model classification", () => { test("keeps multi-reference BananaImage models classified as image models", () => { @@ -51,4 +80,37 @@ test.describe("model classification", () => { }) ).toBe(false); }); + + test("prevents reference-incompatible models from being selected in Style Pro", () => { + const availability = getStyleProModelAvailability( + styleProEntry({ supportsVision: false }) + ); + + expect(availability.selectable).toBe(false); + expect(availability.availabilityLabel).toBe("仅文生图"); + }); + + test("temporarily disables degraded models in Style Pro", () => { + const availability = getStyleProModelAvailability( + styleProEntry({ + healthStatus: "degraded", + healthObservationStartedAt: new Date(), + }) + ); + + expect(availability.selectable).toBe(false); + expect(availability.availabilityLabel).toBe("暂不可用"); + }); + + test("keeps known reference-capable BFL models selectable", () => { + const availability = getStyleProModelAvailability( + styleProEntry({ + providerType: "bfl-image", + modelId: "flux-2-klein-4b", + supportsVision: false, + }) + ); + + expect(availability.selectable).toBe(true); + }); }); diff --git a/tests/routes/model3d-style-pro-regressions.test.ts b/tests/routes/model3d-style-pro-regressions.test.ts index 9df978016..c2cb53070 100644 --- a/tests/routes/model3d-style-pro-regressions.test.ts +++ b/tests/routes/model3d-style-pro-regressions.test.ts @@ -77,6 +77,19 @@ test.describe("3D workspace and Style Pro regressions", () => { expect(workspace).toContain("revalidateOnReconnect: true"); }); + test("enforces Style Pro model availability on both display and execution", () => { + const options = readSource("lib/style-pro/model-options.ts"); + const availability = readSource( + "lib/style-pro/model-availability.ts" + ); + + expect(options).toContain(".filter(isStyleProModelRunnable)"); + expect(options).toContain("getStyleProModelAvailability(entry)"); + expect(availability).toContain("modelSupportsReferenceImageInput"); + expect(availability).toContain('availabilityLabel: "仅文生图"'); + expect(availability).toContain('availabilityLabel: "暂不可用"'); + }); + test("opens the canvas conversation panel by default", () => { const page = readSource( "app/canvas/[id]/CanvasEditorLegacyPageClient.tsx"