diff --git a/web/package.json b/web/package.json index 1491c6a24..dc6d58da4 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,7 @@ "pretest": "bun test test/admin-ui-regressions.test.ts test/password-reset.test.ts test/oss-settings.test.ts", "typecheck": "tsc --noEmit", "test:canvas": "bun test test/canvas-media-performance.test.ts test/canvas-node-title-interaction.test.ts test/canvas-spatial-index.test.ts", - "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/model-capabilities.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-generation-mentions.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-frame.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-pricing.test.ts test/model-protocols.test.ts test/model-capabilities.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-generation-mentions.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-frame.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "test:director:e2e": "bun scripts/director-p0-chrome-e2e.mjs", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 9822a3080..a2f7d73e5 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -4,7 +4,7 @@ import { Popover } from "antd"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities"; -import { modelQuoteRequest, normalizeTierResolution, priceTiersForCurrentSelection } from "@/lib/model-pricing"; +import { formatPriceRange, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing"; import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; import { cn } from "@/lib/utils"; import { modelDisplayName, modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; @@ -29,7 +29,21 @@ type ModelPickerProps = { showConfiguredModelName?: boolean; }; -export function ModelPicker({ config, value, onChange, capability, className, popoverClassName, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default", requirements, showConfiguredModelName = false }: ModelPickerProps) { +export function ModelPicker({ + config, + value, + onChange, + capability, + className, + popoverClassName, + fullWidth = false, + placeholder = "选择模型", + onMissingConfig, + showSelectedPrice = true, + variant = "default", + requirements, + showConfiguredModelName = false, +}: ModelPickerProps) { const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const pickerId = useId(); // 双保险:即使 store merge 写出非法 theme,这里也兜底到 dark,避免 "reading 'node'" 崩溃 @@ -62,7 +76,7 @@ export function ModelPicker({ config, value, onChange, capability, className, po const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent; // 旧画布可能保存过已下架或前端历史内置模型;它们不能重新进入当前可选目录。 const current = options.includes(resolvedCurrent) ? resolvedCurrent : ""; - const currentPrice = modelMenuPrice(config, current, capability, false, requirements); + const currentPrice = modelMenuPrice(config, current, capability, false, requirements); const quoteRequest = useMemo(() => modelQuoteRequest(config, current, capability, requirements), [capability, config, current, requirements]); const [routeQuote, setRouteQuote] = useState(); const creationVariant = variant === "creation"; @@ -151,11 +165,13 @@ export function ModelPicker({ config, value, onChange, capability, className, po ref={menuRef} data-canvas-no-zoom className={cn("canvas-model-picker-menu max-w-[calc(100vw-24px)]", creationVariant ? "creation-model-picker-menu w-[360px]" : "w-[var(--panel-width-compact)]")} - style={{ - background: theme.node.panel, - color: theme.node.text, - "--canvas-model-picker-trigger-width": triggerWidth ? String(triggerWidth) + "px" : undefined, - } as CSSProperties} + style={ + { + background: theme.node.panel, + color: theme.node.text, + "--canvas-model-picker-trigger-width": triggerWidth ? String(triggerWidth) + "px" : undefined, + } as CSSProperties + } role="listbox" aria-label={placeholder} onKeyDown={handleMenuKeyDown} @@ -173,7 +189,11 @@ export function ModelPicker({ config, value, onChange, capability, className, po
{group.label} - {group.scope ? {group.scope} : null} + {group.scope ? ( + + {group.scope} + + ) : null}
{group.models.map((modelGroup) => { @@ -199,7 +219,16 @@ export function ModelPicker({ config, value, onChange, capability, className, po window.requestAnimationFrame(() => triggerRef.current?.focus()); }} > - + {selected ? : null} ); @@ -284,7 +313,10 @@ function ModelLabel({ const logicalCost = channel.modelCosts?.find((item) => item.model === modelOptionName(model)); const logicalSpec = logicalCost?.logicalCapabilitySpec; const videoProfile = capability === "video" ? modelCapabilityConfigFor(config, model).video : undefined; - const capabilitySummary = disabledReason || logicalCost?.description?.trim() || (logicalSpec ? logicalCapabilitySummary(logicalSpec) : videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description); + const capabilitySummary = + disabledReason || + logicalCost?.description?.trim() || + (logicalSpec ? logicalCapabilitySummary(logicalSpec) : videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description); return ( @@ -366,10 +398,7 @@ function formatDurationSummary(profile: NonNullable[number]["logicalPriceTiers"]>, allTiers: NonNullable[number]["logicalPriceTiers"]>, ): Extract { - const fixedRequestValues = visibleTiers - .filter((tier) => tier.billingMode === "fixed_request") - .map((tier) => tier.unitPriceMicrocredits / 1_000_000) - .filter((value) => value > 0); - const perSecondValues = visibleTiers - .filter((tier) => tier.billingMode === "per_second") - .map((tier) => tier.unitPriceMicrocredits / 1_000_000) - .filter((value) => value > 0); - const hasTokenTier = visibleTiers.some((tier) => tier.billingMode === "token"); - const label = fixedRequestValues.length - ? formatPriceRange(fixedRequestValues, "积分") - : perSecondValues.length - ? formatPriceRange(perSecondValues, "积分/秒") - : hasTokenTier - ? "按量预估" - : "未配置"; + const label = priceTierSummaryLabel(visibleTiers); return { kind: "tiers", label, @@ -424,12 +438,6 @@ function channelTierPriceSummary( }; } -function formatPriceRange(values: number[], suffix: string) { - const unique = Array.from(new Set(values)).sort((left, right) => left - right); - const format = (value: number) => value.toLocaleString("zh-CN", { maximumFractionDigits: 3 }); - return unique.length === 1 ? `${format(unique[0])} ${suffix}` : `${format(unique[0])}-${format(unique[unique.length - 1])} ${suffix}`; -} - function tierResolutionLabel(value: string) { const normalized = normalizeTierResolution(value); return normalized === "*" ? "全部分辨率" : normalized.toUpperCase(); @@ -441,17 +449,17 @@ function tierDurationLabel(seconds: number) { function tierSpecificationLabel(tier: NonNullable[number]["logicalPriceTiers"]>[number]) { const selector = tier.selector || {}; - const operationLabels: Record = { text_to_image: "文生图", image_to_image: "图生图", text_to_video: "文生视频", image_to_video: "图生视频", video_to_video: "视频生视频" }; - const operation = selector.operation && selector.operation !== "*" ? (operationLabels[selector.operation] || selector.operation) : ""; - const details = [ - operation, - selector.quality && selector.quality !== "*" ? selector.quality.toUpperCase() : "", - selector.size && selector.size !== "*" ? selector.size : "", - tier.resolution !== "*" ? tierResolutionLabel(tier.resolution) : "", - tier.videoSeconds ? tierDurationLabel(tier.videoSeconds) : "", - selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "", - ].filter(Boolean); - return details.length ? details.join(" / ") : "默认规格"; + const operationLabels: Record = { text_to_image: "文生图", image_to_image: "图生图", text_to_video: "文生视频", image_to_video: "图生视频", video_to_video: "视频生视频" }; + const operation = selector.operation && selector.operation !== "*" ? operationLabels[selector.operation] || selector.operation : ""; + const details = [ + operation, + selector.quality && selector.quality !== "*" ? selector.quality.toUpperCase() : "", + selector.size && selector.size !== "*" ? selector.size : "", + tier.resolution !== "*" ? tierResolutionLabel(tier.resolution) : "", + tier.videoSeconds ? tierDurationLabel(tier.videoSeconds) : "", + selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "", + ].filter(Boolean); + return details.length ? details.join(" / ") : "默认规格"; } function tierPriceLabel(tier: NonNullable[number]["logicalPriceTiers"]>[number]) { diff --git a/web/src/lib/model-pricing.ts b/web/src/lib/model-pricing.ts index 634df19c0..92bfab8af 100644 --- a/web/src/lib/model-pricing.ts +++ b/web/src/lib/model-pricing.ts @@ -51,6 +51,25 @@ export function priceTiersForCurrentSelection(tiers: ModelPriceTier[], capabilit return matched; } +export function priceTierSummaryLabel(tiers: ModelPriceTier[]) { + const unitPrices = (billingMode: ModelPriceTier["billingMode"]) => + tiers + .filter((tier) => tier.billingMode === billingMode) + .map((tier) => tier.unitPriceMicrocredits / 1_000_000) + .filter((value) => Number.isFinite(value) && value >= 0); + const fixedRequestValues = unitPrices("fixed_request"); + const perSecondValues = unitPrices("per_second"); + if (fixedRequestValues.length) return formatPriceRange(fixedRequestValues, "积分"); + if (perSecondValues.length) return formatPriceRange(perSecondValues, "积分/秒"); + return tiers.some((tier) => tier.billingMode === "token") ? "按量预估" : "未配置"; +} + +export function formatPriceRange(values: number[], suffix: string) { + const unique = Array.from(new Set(values)).sort((left, right) => left - right); + const format = (value: number) => value.toLocaleString("zh-CN", { maximumFractionDigits: 3 }); + return unique.length === 1 ? `${format(unique[0])} ${suffix}` : `${format(unique[0])}-${format(unique[unique.length - 1])} ${suffix}`; +} + export function modelQuoteRequest(config: AiConfig, value: string, capability?: ModelCapability, requirements?: ModelRequirements): { logicalModelID: string; intent: ModelRequestIntent } | undefined { if (!capability || !value) return undefined; const channel = resolveModelChannel(config, value); diff --git a/web/test/generation-task-local.test.ts b/web/test/generation-task-local.test.ts index 3177728af..447149d2f 100644 --- a/web/test/generation-task-local.test.ts +++ b/web/test/generation-task-local.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; -import { defaultConfig, type AiConfig } from "../src/stores/use-config-store"; +import { createModelChannel, defaultConfig } from "../src/stores/use-config-store"; import { runBackendGenerationTask, runBackendGenerationTaskBatch } from "../src/services/api/generation-task"; import { deleteGenerationTask, formatTaskLog, listGenerationTasks, projectBackendSafeTaskLog, splitGenerationTaskObservationIds, type GenerationTask } from "../src/services/api/task-center"; import { isLocalDreaminaBackgroundTask, localDreaminaCancellationCopy, localDreaminaDetachOutcome, projectLocalDreaminaTask } from "../src/services/local-dreamina-task-projection"; @@ -24,24 +24,25 @@ function sourceSection(source: string, startMarker: string, endMarker: string) { return compactSource(source.slice(start, end)); } -function remoteBackendConfig(model: string, overrides: Partial = {}): AiConfig { - const modelName = model.includes("::") ? model.slice(model.indexOf("::") + 2) : model; +function backendModelConfig(model: string) { + const channel = createModelChannel({ + id: "system-test-channel", + name: "系统测试渠道", + baseUrl: "/api/system-test-channel", + apiKey: "system", + apiFormat: "openai", + scope: "system", + models: [model], + }); + const selectedModel = `${channel.id}::${model}`; return { ...defaultConfig, - ...overrides, - model, - channels: [ - { - id: "default", - name: "Backend test channel", - baseUrl: "https://api.example.com", - apiKey: "test-key", - apiFormat: "openai", - interfaceType: "newapi-channel-2", - models: [modelName], - scope: "user", - }, - ], + channelMode: "remote" as const, + channels: [channel], + model: selectedModel, + imageModel: selectedModel, + videoModel: selectedModel, + audioModel: selectedModel, }; } @@ -1307,7 +1308,7 @@ test("remote provider keeps Create resolution semantics and still creates one Ba { mode: "video", prompt: "A remote test clip", - config: remoteBackendConfig("default::grok-imagine-video", { vquality: "720", quality: "auto" }), + config: { ...backendModelConfig("grok-imagine-video"), vquality: "720", quality: "auto" }, }, { createTask: async (input) => { @@ -1375,7 +1376,7 @@ test("remote image video and audio references keep Backend parity without Dreami { mode: item.mode, prompt: "Remote parity fixture", - config: remoteBackendConfig("default::provider-neutral-model"), + config: backendModelConfig("provider-neutral-model"), ...item.references, }, { @@ -1694,7 +1695,7 @@ test("Create audio upload converts, previews, removes, and submits through the s projectId: running.projectId, mode: "video", prompt: running.prompt, - config: remoteBackendConfig("remote-video-audio-fixture", { videoModel: "remote-video-audio-fixture" }), + config: backendModelConfig("remote-video-audio-fixture"), ...references, } as Parameters[0], { diff --git a/web/test/model-pricing.test.ts b/web/test/model-pricing.test.ts index 1e0a00bc4..e20436421 100644 --- a/web/test/model-pricing.test.ts +++ b/web/test/model-pricing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { modelQuoteRequest, normalizeTierResolution, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing"; +import { modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing"; import type { ModelRequirements } from "../src/lib/model-selection"; import { createModelChannel, defaultConfig, normalizeConfigSnapshot, resolveModelChannel, type AiConfig } from "../src/stores/use-config-store"; @@ -86,6 +86,19 @@ describe("model request pricing", () => { ).toBe(0.125); }); + test("displays zero-price system tiers as configured free pricing", () => { + const fixedConfig = systemConfig({ + capability: "image", + tiers: [{ selector: {}, billingMode: "fixed_request", unitPriceMicrocredits: 0 }], + }); + const perSecondConfig = systemConfig({ + tiers: [{ selector: {}, billingMode: "per_second", unitPriceMicrocredits: 0 }], + }); + + expect(priceTierSummaryLabel(resolveModelChannel(fixedConfig, fixedConfig.model).modelCosts![0]!.logicalPriceTiers!)).toBe("0 积分"); + expect(priceTierSummaryLabel(resolveModelChannel(perSecondConfig, perSecondConfig.model).modelCosts![0]!.logicalPriceTiers!)).toBe("0 积分/秒"); + }); + test("uses reference count and operation to avoid a text-video price tier", () => { const config = systemConfig({ tiers: [