feat(canvas): 画布模型与连线能力 - 合并同名模型并按输入动态限制节点关联

Closes #220
This commit is contained in:
ddCat
2026-08-14 12:41:55 +08:00
parent cfdf08d05f
commit e7af466a16
16 changed files with 509 additions and 80 deletions

View File

@@ -7,7 +7,8 @@ import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resol
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { canvasThemes } from "@/lib/canvas-theme";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities";
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue } from "@/lib/model-capabilities";
import { modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
import { navigateToSettings } from "@/lib/settings-navigation";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -19,7 +20,7 @@ import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasVi
type CanvasConfigNodePanelProps = {
node: CanvasNodeData;
isRunning: boolean;
inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number };
inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number; characterCount: number };
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
onGenerate: (nodeId: string) => void;
onStop: (nodeId: string) => void;
@@ -45,8 +46,13 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const mode = node.metadata?.generationMode || "image";
const simpleMode = workspaceMode === "simple";
const config = buildNodeConfig(globalConfig, node, mode);
const imageProfile = mode === "image" ? modelCapabilityConfigFor(config, config.model).image! : undefined;
const requirements: ModelRequirements = {
capability: mode,
input: inputSummary,
videoOperation: node.metadata?.videoEditOperation,
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds,
};
const config = buildNodeConfig(globalConfig, node, mode, requirements);
const videoProfile = mode === "video" ? modelCapabilityConfigFor(config, config.model).video! : undefined;
const operationOptions = videoProfile ? videoOperationOptions.filter((item) => videoProfile.operations.includes(item.value) || item.value === "concat") : videoOperationOptions;
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
@@ -54,13 +60,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? count : 1, seconds: mode === "video" ? config.videoSeconds : 1 });
const hasPrice = creditsEnabled && credits !== null;
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount || inputSummary.characterCount);
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
const capabilityError = imageProfile
? imageCapabilityError(imageProfile, inputSummary)
: videoProfile
? videoCapabilityError(videoProfile, config.videoSeconds, inputSummary, node.metadata?.videoEditOperation)
: "";
const capabilityError = modelCompatibilityError(config, config.model, requirements);
const canGenerate = (hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput)) && !capabilityError;
return (
@@ -120,6 +122,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
<InputChip label="参考视频" value={`${inputSummary.videoCount} 个`} style={chipStyle} />
<InputChip label="参考音频" value={`${inputSummary.audioCount} 个`} style={chipStyle} />
{inputSummary.characterCount ? <InputChip label="角色卡" value={`${inputSummary.characterCount} 个`} style={chipStyle} /> : null}
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[var(--fs-label)]" style={chipStyle} onMouseDown={(event) => event.stopPropagation()} onClick={onComposerToggle}>
{simpleMode ? <MessageSquare className="size-3.5" /> : <Settings2 className="size-3.5" />}
{simpleMode ? "编辑生成内容" : "组装提示词"}
@@ -150,7 +153,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
<div className="mb-2 rounded-lg px-2 py-2 text-[var(--fs-label)]" style={{ background: theme.node.fill, color: theme.node.muted }}>将使用当前默认模型与生成参数</div>
) : (
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "image" || mode === "video" || mode === "audio" ? "grid-cols-[minmax(0,1fr)_148px]" : "grid-cols-1"}`} onMouseDown={(event) => event.stopPropagation()}>
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => navigateToSettings({ continueCreation: true })} fullWidth showSelectedPrice={creditsEnabled} />
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} requirements={requirements} onMissingConfig={() => navigateToSettings({ continueCreation: true })} fullWidth showSelectedPrice={creditsEnabled} />
{mode === "video" ? (
<CanvasVideoSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
) : mode === "image" ? (
@@ -203,10 +206,11 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
}
function defaultVideoOperation(inputSummary: CanvasConfigNodePanelProps["inputSummary"]): CanvasVideoEditOperation {
if (inputSummary.audioCount > 0 && inputSummary.imageCount === 0 && inputSummary.videoCount === 0) return "audio_to_video";
const visualInputCount = inputSummary.imageCount + inputSummary.characterCount;
if (inputSummary.audioCount > 0 && visualInputCount === 0 && inputSummary.videoCount === 0) return "audio_to_video";
if (inputSummary.videoCount > 0) return "extend";
if (inputSummary.imageCount > 0) return "image_to_video";
return "image_to_video";
if (visualInputCount > 0) return "image_to_video";
return "text_to_video";
}
function InputChip({ label, value, style }: { label: string; value: string; style: CSSProperties }) {
@@ -218,11 +222,12 @@ function InputChip({ label, value, style }: { label: string; value: string; styl
);
}
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig {
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode, 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 storedModel = node.metadata?.model;
const model = storedModel && configuredModelMatchesCapability(globalConfig, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(globalConfig, defaultModel, mode) ? defaultModel : fallbackModel;
const preferredModel = storedModel && configuredModelMatchesCapability(globalConfig, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(globalConfig, defaultModel, mode) ? defaultModel : fallbackModel;
const model = resolveCompatibleModel(globalConfig, preferredModel, requirements) || preferredModel;
const imageProfile = mode === "image" ? modelCapabilityConfigFor(globalConfig, model).image! : undefined;
const normalizedImage = imageProfile ? normalizeImageValue(imageProfile, { size: node.metadata?.size || globalConfig.size || defaultConfig.size, quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality, transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground, count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count) }) : undefined;
const videoProfile = mode === "video" ? modelCapabilityConfigFor(globalConfig, model).video! : undefined;
@@ -252,19 +257,6 @@ function videoConfigPatch(key: keyof AiConfig, value: string) {
return { [key]: value };
}
function videoCapabilityError(profile: NonNullable<ReturnType<typeof modelCapabilityConfigFor>["video"]>, seconds: string, input: CanvasConfigNodePanelProps["inputSummary"], operation?: string) {
if (!videoDurationAllowed(profile, Number(seconds))) return "当前模型不支持该视频时长";
if (input.imageCount > profile.references.maxImages || input.videoCount > profile.references.maxVideos || input.audioCount > profile.references.maxAudios) return "参考素材数量超过当前模型限制";
const resolvedOperation = operation || (input.audioCount > 0 && input.imageCount === 0 && input.videoCount === 0 ? "audio_to_video" : input.videoCount > 0 ? "extend" : input.imageCount > 0 ? "image_to_video" : "text_to_video");
if (!profile.operations.includes(resolvedOperation)) return "当前模型不支持该生成模式";
return "";
}
function imageCapabilityError(profile: ImageCapabilityConfig, input: CanvasConfigNodePanelProps["inputSummary"]) {
if (input.imageCount > profile.references.maxImages) return `当前图片模型最多支持 ${profile.references.maxImages} 张参考图`;
return "";
}
function audioConfigPatch(key: CanvasAudioSettingKey, value: string) {
if (key === "audioVoice") return { audioVoice: value };
if (key === "audioFormat") return { audioFormat: value };