chore(release): 版本发布 - 集成本地模型编辑与画布改动并发布 v1.2.6.rc3

This commit is contained in:
ddCat
2026-09-06 07:33:46 +08:00
19 changed files with 476 additions and 36 deletions

View File

@@ -7,7 +7,7 @@
"build:bridge": "bun ../canvas-agent/scripts/build-comfy-bridge.ts",
"dev": "vite --host 0.0.0.0 --port 3000",
"build": "bun run build:bridge && tsc --noEmit && vite build",
"pretest": "bun test test/admin-ui-regressions.test.ts test/announcement-prompt-regressions.test.ts test/password-reset.test.ts test/oss-settings.test.ts",
"pretest": "bun test test/admin-ui-regressions.test.ts test/announcement-prompt-regressions.test.ts test/password-reset.test.ts test/oss-settings.test.ts test/channel-model-editor-form.test.ts test/channel-model-price-tier-form.test.ts test/admin-logical-models.test.ts && bun test test/canvas-grid-viewport.test.tsx",
"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-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",

View File

@@ -107,6 +107,11 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
}, mode),
};
const config = buildNodeConfig(globalConfig, node, mode, requirements);
const resolvedRequirements: ModelRequirements = {
...requirements,
options: modelRequestOptions(config, mode),
videoSeconds: mode === "video" ? config.videoSeconds : undefined,
};
const promptOptimizerProvider = useMemo(() => {
if (!promptOptimizerEnabled || !promptOptimizerInstallation || !promptOptimizerPlugin.createPromptOptimizer) return null;
return promptOptimizerPlugin.createPromptOptimizer(createPluginHostContext(promptOptimizerPlugin, promptOptimizerInstallation, globalConfig));
@@ -121,9 +126,9 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
seconds: mode === "video" ? config.videoSeconds : 1,
capability: mode,
config,
requirements,
requirements: resolvedRequirements,
});
const quoteRequest = modelQuoteRequest(config, config.model, mode, requirements);
const quoteRequest = modelQuoteRequest(config, config.model, mode, resolvedRequirements);
const quoteRequestKey = JSON.stringify(quoteRequest || null);
const [quotedCredits, setQuotedCredits] = useState<number | null>(null);
const credits = quotedCredits ?? configuredCredits;
@@ -341,9 +346,10 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
value={config.model}
onChange={(model) => onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })}
capability={mode}
requirements={requirements}
requirements={resolvedRequirements}
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
showSelectedPrice={false}
showOptionPrices={creditsEnabled}
variant="creation"
showConfiguredModelName
/>
@@ -690,7 +696,7 @@ function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
return type === CanvasNodeType.Text || type === CanvasNodeType.Skill ? "text" : type === CanvasNodeType.Video ? "video" : type === CanvasNodeType.Audio ? "audio" : "image";
}
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode, requirements: ModelRequirements): AiConfig {
export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode, requirements: ModelRequirements): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const preferredModel = resolveCanvasGenerationModel(globalConfig, node.metadata?.model, mode) || resolveCanvasGenerationModel(globalConfig, defaultModel, mode) || fallbackModel;

View File

@@ -22,7 +22,7 @@ export function generationTaskInput(task: GenerationTask) {
}
export function generationTaskNodeId(task: GenerationTask) {
return generationTaskInput(task)?.metadata?.nodeId || "";
return task.clientContext?.nodeId || generationTaskInput(task)?.metadata?.nodeId || "";
}
export function generationTaskMode(task: GenerationTask, fallback?: CanvasGenerationMode): CanvasGenerationMode {

View File

@@ -1,5 +1,6 @@
import { modelRequestOptions, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
import { buildImageResolutionOptions, imageResolutionOption } from "@/lib/image-resolution-tiers";
import type { ModelRequestIntent } from "@/services/api/logical-models";
import { modelOptionName, resolveModelChannel, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
@@ -79,7 +80,7 @@ export function modelQuoteRequest(config: AiConfig, value: string, capability?:
const input = requirements?.input;
const intent: ModelRequestIntent = {
capability,
operation: capability === "video" && input ? resolveVideoOperation(input, requirements?.videoOperation) : requirements?.videoOperation,
operation: capability === "image" ? imagePriceOperation(requirements) : capability === "video" && input ? resolveVideoOperation(input, requirements?.videoOperation) : requirements?.videoOperation,
inputs: {
image: (input?.imageCount || 0) + (input?.characterCount || 0),
video: input?.videoCount || 0,
@@ -115,12 +116,27 @@ function priceSelectorForRequest(capability: ModelCapability | undefined, config
if (seconds > 0) requested.videoSeconds = String(seconds);
}
if (capability === "image") {
if (config.quality && config.quality !== "auto") requested.quality = config.quality.toLowerCase();
if (config.size && config.size !== "auto") requested.size = config.size.toLowerCase();
requested.operation = imagePriceOperation(requirements);
const options = { ...modelRequestOptions(config, "image"), ...requirements?.options, ...(requirements?.imageSize ? { size: requirements.imageSize } : {}) };
const imageQuality = String(options.quality ?? "").trim().toLowerCase();
const imageSize = String(options.size ?? "").trim();
if ((imageQuality === "" || imageQuality === "auto" || imageQuality === "any") && imageSize) {
const resolution = imageResolutionOption(buildImageResolutionOptions([imageSize]), imageSize)?.tier;
if (resolution) requested.quality = resolution;
}
for (const key of ["quality", "size"] as const) {
const value = String(options[key] ?? "").trim().toLowerCase();
if (value && value !== "auto" && value !== "any" && !requested[key]) requested[key] = value;
}
}
return requested;
}
function imagePriceOperation(requirements?: ModelRequirements) {
const input = requirements?.input;
return (input?.imageCount || 0) + (input?.characterCount || 0) > 0 ? "image_to_image" : "text_to_image";
}
function priceSelectorForTier(tier: ModelPriceTier) {
const selector = { ...(tier.selector || {}) };
if (!Object.keys(selector).length) {

View File

@@ -377,7 +377,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
const needsDiscovery = recoveryNodes.some((node) => !node.metadata?.taskId && !node.metadata?.agentGenerationContinuation?.taskId);
const projectTasks = needsDiscovery
? (
await listGenerationTasks(30, { projectId: startedProjectId }, undefined, signal).catch((error) => {
await listGenerationTasks(100, { projectId: startedProjectId }, undefined, signal).catch((error) => {
if (!isCurrentProject()) throw error;
return [];
})

View File

@@ -11,6 +11,15 @@ import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
import { onlineToolToOps } from "../src/components/canvas/canvas-assistant-panel";
import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel } from "../src/lib/generation-task-display";
import { generationErrorMessage } from "../src/lib/generation-error";
import { generationTaskNodeId } from "../src/lib/canvas/canvas-generation-task-sync";
test("canvas recovery reads node identity from task summaries without full task input", () => {
const task: GenerationTask = { id: "task-summary", type: "canvas_image", status: "running", prompt: "", attempts: 1, createdAt: "", updatedAt: "", clientContext: { nodeId: "node-summary" } };
expect(generationTaskNodeId(task)).toBe("node-summary");
expect(generationTaskNodeId({ ...task, inputJson: "invalid json" })).toBe("node-summary");
expect(generationTaskNodeId({ ...task, clientContext: undefined, inputJson: JSON.stringify({ metadata: { nodeId: "node-detail" } }) })).toBe("node-detail");
expect(generationTaskNodeId({ ...task, clientContext: undefined })).toBe("");
});
function compactSource(source: string) {
return source.replace(/\s+/g, " ").trim();

View File

@@ -3,6 +3,10 @@ import { describe, expect, test } from "bun:test";
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";
import { buildNodeConfig } from "../src/components/canvas/canvas-node-prompt-panel";
import { buildGenerationConfig } from "../src/lib/canvas/canvas-project-generation";
import { modelRequestOptions } from "../src/lib/model-selection";
import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
function systemConfig(input: { capability?: "image" | "video"; logicalModelId?: string; tiers: Array<{ selector: Record<string, string>; billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number }> }) {
const capability = input.capability || "video";
@@ -58,6 +62,84 @@ const textVideoRequirements: ModelRequirements = {
};
describe("model request pricing", () => {
test("keeps the panel quote and generation options aligned without selecting a price tier as quality", () => {
const config = systemConfig({
capability: "image",
tiers: [
{ selector: { quality: "1k" }, billingMode: "fixed_request", unitPriceMicrocredits: 1_000_000 },
{ selector: { quality: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 2_000_000 },
{ selector: { quality: "4k" }, billingMode: "fixed_request", unitPriceMicrocredits: 4_000_000 },
],
});
for (const size of ["auto", "1024x1024", "2048x2048"]) {
const node: CanvasNodeData = { id: "image-1", type: CanvasNodeType.Image, title: "Image", position: { x: 0, y: 0 }, width: 400, height: 400, metadata: { model: config.model, quality: "auto", size } };
const requirements: ModelRequirements = { capability: "image", options: { quality: "auto", size } };
const panel = buildNodeConfig(config, node, "image", requirements);
const generation = buildGenerationConfig(config, node, "image", requirements);
expect(modelRequestOptions(panel, "image")).toEqual(modelRequestOptions(generation, "image"));
expect(panel.quality).toBe("auto");
}
});
test("uses image dimensions to match resolution pricing when quality is automatic", () => {
const config = systemConfig({
capability: "image",
tiers: [
{ selector: { quality: "1k" }, billingMode: "fixed_request", unitPriceMicrocredits: 2_000_000 },
{ selector: { quality: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 3_000_000 },
{ selector: { quality: "4k" }, billingMode: "fixed_request", unitPriceMicrocredits: 5_000_000 },
],
});
for (const [size, price] of [["1024x1024", 2], ["2048x2048", 3], ["2880x2880", 5]] as const) {
expect(requestCreditCost({ channelMode: "remote", modelCosts: resolveModelChannel(config, config.model).modelCosts, model: "image-model", capability: "image", config: { ...config, quality: "auto", size }, requirements: { capability: "image", options: { quality: "auto", size } }, count: 1 })).toBe(price);
}
});
test("matches each image resolution and generation operation for quotes and displayed costs", () => {
const qualities = ["1k", "2k", "4k"];
const config = systemConfig({
capability: "image",
logicalModelId: "logical-image",
tiers: ["text_to_image", "image_to_image"].flatMap((operation, operationIndex) =>
qualities.map((quality, index) => ({
selector: { operation, quality },
billingMode: "fixed_request" as const,
unitPriceMicrocredits: (index + 1 + operationIndex * 3) * 1_000_000,
})),
),
});
const channel = resolveModelChannel(config, config.model);
for (const imageCount of [0, 1]) {
for (const [index, quality] of qualities.entries()) {
const requirements: ModelRequirements = {
capability: "image",
input: { textCount: 1, imageCount, characterCount: 0, videoCount: 0, audioCount: 0 },
options: { quality: quality.toUpperCase(), size: "16:9" },
};
expect(requestCreditCost({ channelMode: "remote", modelCosts: channel.modelCosts, model: "image-model", capability: "image", config, requirements, count: 1 })).toBe(index + 1 + imageCount * 3);
expect(modelQuoteRequest(config, config.model, "image", requirements)?.intent).toMatchObject({
operation: imageCount ? "image_to_image" : "text_to_image",
inputs: { image: imageCount },
options: { quality: quality.toUpperCase() },
});
}
}
const characterRequirements: ModelRequirements = { capability: "image", input: { textCount: 1, imageCount: 0, characterCount: 1, videoCount: 0, audioCount: 0 }, options: { quality: "2k" } };
expect(modelQuoteRequest(config, config.model, "image", characterRequirements)?.intent).toMatchObject({ operation: "image_to_image", inputs: { image: 1 } });
expect(requestCreditCost({ channelMode: "remote", modelCosts: channel.modelCosts, model: "image-model", capability: "image", config, requirements: characterRequirements, count: 1 })).toBe(5);
expect(priceTiersForCurrentSelection(channel.modelCosts![0]!.logicalPriceTiers!, "image", { ...config, quality: "8k" })).toHaveLength(0);
});
test("prefers a matching image specification over the uniform fallback", () => {
const config = systemConfig({ capability: "image", tiers: [
{ selector: {}, billingMode: "fixed_request", unitPriceMicrocredits: 1_000_000 },
{ selector: { operation: "text_to_image", quality: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 2_000_000 },
] });
const tiers = resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!;
expect(priceTiersForCurrentSelection(tiers, "image", { ...config, quality: "2k" })[0]?.unitPriceMicrocredits).toBe(2_000_000);
expect(priceTiersForCurrentSelection(tiers, "image", { ...config, quality: "4k" })[0]?.unitPriceMicrocredits).toBe(1_000_000);
});
test("preserves provider-specific resolution enums when matching price tiers", () => {
expect(normalizeTierResolution("768P竖")).toBe("768p竖");
expect(normalizeTierResolution("HD_Portrait")).toBe("hd_portrait");