@@ -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 };
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getGenerationResourceNodes } from "@/lib/canvas/canvas-resource-referen
|
||||
import { resolveCanvasDrawingReference } from "@/lib/canvas/canvas-drawing-reference";
|
||||
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
|
||||
import { nodeReferenceImage } from "@/lib/canvas/canvas-project-generation";
|
||||
import type { ModelReferenceLimits } from "@/lib/model-selection";
|
||||
|
||||
export type CharacterGenerationReference = {
|
||||
nodeId: string;
|
||||
@@ -286,7 +287,7 @@ export function buildNodeResponseMessages(context: NodeGenerationContext): AiTex
|
||||
];
|
||||
}
|
||||
|
||||
export async function hydrateNodeGenerationContext(context: NodeGenerationContext, projectId: string, domainProjectId?: string, mode?: CanvasGenerationMode, includeCharacterVoiceSamples = false, includeCharacterPrompt = true) {
|
||||
export async function hydrateNodeGenerationContext(context: NodeGenerationContext, projectId: string, domainProjectId?: string, mode?: CanvasGenerationMode, includeCharacterVoiceSamples = false, includeCharacterPrompt = true, referenceLimits?: ModelReferenceLimits) {
|
||||
const { imageToDataUrl } = await import("@/services/image-storage");
|
||||
let referenceImages = await Promise.all(
|
||||
context.referenceImages.map(async (image) => {
|
||||
@@ -299,7 +300,7 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
|
||||
const { getProjectCharacter } = await import("@/services/api/projects");
|
||||
const { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources");
|
||||
const details = await Promise.all(context.characterReferences.map((reference) => getProjectCharacter(domainProjectId, reference.assetId)));
|
||||
const remainingBudget = 9 - referenceImages.length;
|
||||
const remainingBudget = Math.max(0, (referenceLimits?.maxImages ?? 9) - referenceImages.length);
|
||||
const selected = details.flatMap((detail) => {
|
||||
const representation = preferredCharacterRepresentation(detail.character.representations);
|
||||
return representation ? [representation] : [];
|
||||
@@ -353,7 +354,8 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
|
||||
voiceSamples.push(voice);
|
||||
});
|
||||
}
|
||||
if (context.referenceAudios.length + voiceSamples.length > 3) throw new Error(`当前模型参考音频容量不足:已连接 ${context.referenceAudios.length} 个音频,角色声音样本还需要 ${voiceSamples.length} 个名额`);
|
||||
const maxAudios = referenceLimits?.maxAudios ?? 3;
|
||||
if (context.referenceAudios.length + voiceSamples.length > maxAudios) throw new Error(`当前模型参考音频容量不足:已连接 ${context.referenceAudios.length} 个音频,角色声音样本还需要 ${voiceSamples.length} 个名额`);
|
||||
const characterVoiceAudios = voiceSamples.map((voice) => ({
|
||||
id: `character-voice-${voice.assetId}`,
|
||||
name: `${voice.characterName}-声音样本.mp3`,
|
||||
@@ -370,6 +372,7 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
|
||||
referenceAudios,
|
||||
resolvedCharacterVersions,
|
||||
resolvedCharacterVoices,
|
||||
imageCount: referenceImages.length,
|
||||
audioCount: referenceAudios.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-genera
|
||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { 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";
|
||||
@@ -44,7 +45,6 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const simpleMode = workspaceMode === "simple";
|
||||
const mode = defaultMode(node.type);
|
||||
const config = buildNodeConfig(globalConfig, node, mode);
|
||||
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
|
||||
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
|
||||
const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? "";
|
||||
@@ -54,6 +54,20 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
const [expandedPromptOpen, setExpandedPromptOpen] = useState(false);
|
||||
const [promptContentHeight, setPromptContentHeight] = useState(0);
|
||||
const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴)
|
||||
const activeReferences = mentionReferences.filter((item) => item.active && item.kind !== "skill");
|
||||
const requirements: ModelRequirements = {
|
||||
capability: mode,
|
||||
input: {
|
||||
textCount: (prompt.trim() ? 1 : 0) + activeReferences.filter((item) => item.kind === "text").length,
|
||||
imageCount: activeReferences.filter((item) => item.kind === "image").length,
|
||||
videoCount: activeReferences.filter((item) => item.kind === "video").length,
|
||||
audioCount: activeReferences.filter((item) => item.kind === "audio").length,
|
||||
characterCount: activeReferences.filter((item) => item.kind === "character").length,
|
||||
},
|
||||
videoOperation: node.metadata?.videoEditOperation,
|
||||
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds,
|
||||
};
|
||||
const config = buildNodeConfig(globalConfig, node, mode, requirements);
|
||||
const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const priceChannel = resolveModelChannel(config, config.model);
|
||||
const credits = requestCreditCost({
|
||||
@@ -63,7 +77,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
count: mode === "image" ? generationCount : 1,
|
||||
seconds: mode === "video" ? config.videoSeconds : 1,
|
||||
});
|
||||
const activeReferenceCount = mentionReferences.filter((item) => item.active && item.kind !== "skill").length;
|
||||
const activeReferenceCount = activeReferences.length;
|
||||
const videoFrameOptions = mentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl }));
|
||||
const darkSurface = themeName === "dark";
|
||||
const monochromeAccent = theme.node.activeStroke;
|
||||
@@ -204,6 +218,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
value={config.model}
|
||||
onChange={(model) => onConfigChange(node.id, { model })}
|
||||
capability={mode}
|
||||
requirements={requirements}
|
||||
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
|
||||
showSelectedPrice={false}
|
||||
/>
|
||||
@@ -473,10 +488,11 @@ 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): AiConfig {
|
||||
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 model = resolveCanvasGenerationModel(globalConfig, node.metadata?.model, mode) || resolveCanvasGenerationModel(globalConfig, defaultModel, mode) || fallbackModel;
|
||||
const preferredModel = resolveCanvasGenerationModel(globalConfig, node.metadata?.model, mode) || resolveCanvasGenerationModel(globalConfig, defaultModel, mode) || fallbackModel;
|
||||
const model = resolveCompatibleModel(globalConfig, preferredModel, requirements) || preferredModel;
|
||||
return {
|
||||
...globalConfig,
|
||||
model,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { buildTimelineImportSegments, type CanvasTimelineSegmentItem } from "@/lib/canvas/canvas-video-timeline-segments";
|
||||
import { listVideoReferenceModels } from "@/lib/canvas/canvas-video-regeneration";
|
||||
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
@@ -109,7 +110,15 @@ export function CanvasVideoSegmentDialog({ node, nodes, connections, open, mode,
|
||||
const isVideoMode = mode === "video";
|
||||
const durationSec = durationMs > 0 ? durationMs / 1000 : 0;
|
||||
const hasTimelineVideoClips = Boolean(timeline?.clips.some((clip) => clip.kind === "video"));
|
||||
const videoProfile = useMemo(() => (model ? modelCapabilityConfigFor(config, model).video : undefined), [config, model]);
|
||||
const hasPrompt = Boolean(prompt.trim());
|
||||
const modelRequirements = useMemo<ModelRequirements>(() => ({
|
||||
capability: "video",
|
||||
input: { textCount: hasPrompt ? 1 : 0, imageCount: 0, videoCount: 1, audioCount: 0, characterCount: 0 },
|
||||
videoOperation: operation,
|
||||
videoSeconds: config.videoSeconds,
|
||||
}), [config.videoSeconds, hasPrompt, operation]);
|
||||
const resolvedModel = resolveCompatibleModel(config, model, modelRequirements) || model;
|
||||
const videoProfile = useMemo(() => (resolvedModel ? modelCapabilityConfigFor(config, resolvedModel).video : undefined), [config, resolvedModel]);
|
||||
const defaultModel = config.videoModel || config.model || "";
|
||||
const defaultModelSupported = eligibleModels.includes(defaultModel);
|
||||
const hasEligibleModels = eligibleModels.length > 0;
|
||||
@@ -185,7 +194,7 @@ export function CanvasVideoSegmentDialog({ node, nodes, connections, open, mode,
|
||||
startMs: 0,
|
||||
endMs: 0,
|
||||
segments: segments.map(({ id, startMs, endMs, sourceNodeId, sourceStorageKey, sourceUrl }) => ({ id, startMs, endMs, sourceNodeId, sourceStorageKey, sourceUrl })),
|
||||
model,
|
||||
model: resolvedModel,
|
||||
operation,
|
||||
prompt: prompt.trim(),
|
||||
});
|
||||
@@ -321,7 +330,7 @@ export function CanvasVideoSegmentDialog({ node, nodes, connections, open, mode,
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="block min-w-0">
|
||||
<div className="mb-1.5 text-sm font-medium">重生成模型</div>
|
||||
<ModelPicker config={config} value={model} onChange={setModel} capability="video" fullWidth onMissingConfig={() => message.warning("请先配置支持参考视频的视频模型")} />
|
||||
<ModelPicker config={config} value={resolvedModel} onChange={setModel} capability="video" requirements={modelRequirements} fullWidth onMissingConfig={() => message.warning("请先配置支持参考视频的视频模型")} />
|
||||
</label>
|
||||
<label className="block min-w-0">
|
||||
<div className="mb-1.5 text-sm font-medium">生成模式</div>
|
||||
|
||||
@@ -124,7 +124,7 @@ export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidt
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, containerRef, canCreateDrawing, onCreate, onClose }: { pending: PendingConnectionCreate; viewport: ViewportTransform; viewportSize: { width: number; height: number }; containerRef: RefObject<HTMLDivElement | null>; canCreateDrawing: boolean; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing) => void; onClose: () => void }) {
|
||||
export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, containerRef, canCreateDrawing, getDisabledReason, onCreate, onClose }: { pending: PendingConnectionCreate; viewport: ViewportTransform; viewportSize: { width: number; height: number }; containerRef: RefObject<HTMLDivElement | null>; canCreateDrawing: boolean; getDisabledReason: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing) => string; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing) => void; onClose: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const reducedMotion = useReducedMotion();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -170,25 +170,25 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
|
||||
<button type="button" className="grid size-6 shrink-0 place-items-center rounded-full border opacity-55 transition-opacity hover:opacity-100" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }} onClick={onClose} aria-label="关闭连线创建菜单"><X className="size-3" /></button>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<List className="size-4" />} title="文本生成" onClick={() => onCreate(CanvasNodeType.Text)} />
|
||||
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Clapperboard className="size-4" />} title="分镜脚本" onClick={() => onCreate(CanvasNodeType.Script)} />
|
||||
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<ImageIcon className="size-4" />} title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} />
|
||||
{canCreateDrawing ? <ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Pencil className="size-4" />} title="绘图" onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null}
|
||||
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Video className="size-4" />} title="视频生成" onClick={() => onCreate(CanvasNodeType.Video)} />
|
||||
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Music2 className="size-4" />} title="音频参考" onClick={() => onCreate(CanvasNodeType.Audio)} />
|
||||
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<List className="size-4" />} title="文本生成" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} />
|
||||
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Clapperboard className="size-4" />} title="分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} />
|
||||
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<ImageIcon className="size-4" />} title="图片生成" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} />
|
||||
{canCreateDrawing ? <ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Pencil className="size-4" />} title="绘图" disabledReason={getDisabledReason(CanvasNodeType.Drawing)} onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null}
|
||||
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Video className="size-4" />} title="视频生成" disabledReason={getDisabledReason(CanvasNodeType.Video)} onClick={() => onCreate(CanvasNodeType.Video)} />
|
||||
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Music2 className="size-4" />} title="音频参考" disabledReason={getDisabledReason(CanvasNodeType.Audio)} onClick={() => onCreate(CanvasNodeType.Audio)} />
|
||||
</div>
|
||||
</SpotlightSurface>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionCreateOption({ motionEnabled, icon, title, description, onClick }: { motionEnabled: boolean; icon: ReactNode; title: string; description?: string; onClick: () => void }) {
|
||||
function ConnectionCreateOption({ motionEnabled, icon, title, description, disabledReason, onClick }: { motionEnabled: boolean; icon: ReactNode; title: string; description?: string; disabledReason?: string; onClick: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
return (
|
||||
<motion.button type="button" whileHover={motionEnabled ? { x: 2 } : undefined} whileTap={motionEnabled ? { scale: 0.98 } : undefined} transition={aceternityMotion.spring.dock} className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-[var(--dock-item-radius)] border border-transparent px-2 py-1.5 text-left outline-none hover:border-black/10 hover:bg-black/5 focus-visible:ring-2 dark:hover:border-white/10 dark:hover:bg-white/8" style={{ color: theme.node.text, "--tw-ring-color": theme.node.muted } as CSSProperties} onClick={onClick}>
|
||||
<motion.button type="button" disabled={Boolean(disabledReason)} title={disabledReason} whileHover={motionEnabled && !disabledReason ? { x: 2 } : undefined} whileTap={motionEnabled && !disabledReason ? { scale: 0.98 } : undefined} transition={aceternityMotion.spring.dock} className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-[var(--dock-item-radius)] border border-transparent px-2 py-1.5 text-left outline-none hover:border-black/10 hover:bg-black/5 focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:border-white/10 dark:hover:bg-white/8" style={{ color: theme.node.text, "--tw-ring-color": theme.node.muted } as CSSProperties} onClick={onClick}>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-md)] opacity-65 transition-opacity group-hover:opacity-100 [&_svg]:size-3.5" style={{ background: theme.toolbar.itemHover }}>{icon}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2 text-[var(--fs-tiny)] font-semibold leading-4">{title}</span>
|
||||
{description ? <span className="mt-0.5 block truncate text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>{description}</span> : null}
|
||||
{disabledReason || description ? <span className="mt-0.5 block truncate text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>{disabledReason || description}</span> : null}
|
||||
</span>
|
||||
<ChevronRight className="size-3.5 shrink-0 opacity-35 transition-transform group-hover:translate-x-0.5" />
|
||||
</motion.button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Popover } from "antd";
|
||||
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
|
||||
import { compatibleModelInGroup, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { modelDisplayName, modelOptionLabel, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -20,9 +21,10 @@ type ModelPickerProps = {
|
||||
onMissingConfig?: () => void;
|
||||
showSelectedPrice?: boolean;
|
||||
variant?: "default" | "creation";
|
||||
requirements?: ModelRequirements;
|
||||
};
|
||||
|
||||
export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default" }: ModelPickerProps) {
|
||||
export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default", requirements }: ModelPickerProps) {
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const pickerId = useId();
|
||||
// 双保险:即使 store merge 写出非法 theme,这里也兜底到 dark,避免 "reading 'node'" 崩溃
|
||||
@@ -43,15 +45,16 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
key: channel.id,
|
||||
label: channel.name || "未命名渠道",
|
||||
scope: channel.scope === "system" ? "系统渠道" : "自定义渠道",
|
||||
models: options.filter((model) => resolveModelChannel(config, model).id === channel.id),
|
||||
models: groupModelsByDisplayName(config, options.filter((model) => resolveModelChannel(config, model).id === channel.id)),
|
||||
}))
|
||||
.filter((group) => group.models.length);
|
||||
const groupedModels = new Set(channelGroups.flatMap((group) => group.models));
|
||||
const groupedModels = new Set(channelGroups.flatMap((group) => group.models.flatMap((modelGroup) => modelGroup.models)));
|
||||
const ungroupedModels = options.filter((model) => !groupedModels.has(model));
|
||||
return ungroupedModels.length ? [...channelGroups, { key: "ungrouped", label: "其他模型", scope: "未指定渠道", models: ungroupedModels }] : channelGroups;
|
||||
return ungroupedModels.length ? [...channelGroups, { key: "ungrouped", label: "其他模型", scope: "未指定渠道", models: groupModelsByDisplayName(config, ungroupedModels) }] : channelGroups;
|
||||
}, [config, options]);
|
||||
const current = value || "";
|
||||
const currentPrice = modelMenuPrice(config, current);
|
||||
const resolvedCurrent = resolveCompatibleModel(config, current, requirements) || current;
|
||||
const currentPrice = modelMenuPrice(config, resolvedCurrent);
|
||||
const creationVariant = variant === "creation";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -136,23 +139,30 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid min-w-0 gap-1">
|
||||
{group.models.map((model) => {
|
||||
const selected = model === current;
|
||||
{group.models.map((modelGroup) => {
|
||||
const selected = modelGroup.models.includes(current);
|
||||
const model = compatibleModelInGroup(config, modelGroup.models, requirements, selected ? current : undefined);
|
||||
const displayModel = model || (selected ? current : modelGroup.models[0]);
|
||||
const disabledReason = model ? "" : modelCompatibilityError(config, modelGroup.models[0], requirements) || "当前输入不符合该模型能力";
|
||||
return (
|
||||
<button
|
||||
key={model}
|
||||
key={modelGroup.key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className="canvas-model-picker-option"
|
||||
aria-disabled={Boolean(disabledReason)}
|
||||
disabled={Boolean(disabledReason)}
|
||||
title={disabledReason || modelOptionLabel(config, displayModel)}
|
||||
className="canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45"
|
||||
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
|
||||
onClick={() => {
|
||||
if (!model) return;
|
||||
onChange(model);
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
}}
|
||||
>
|
||||
<ModelLabel config={config} model={model} capability={capability} theme={theme} creationVariant={creationVariant} showPrice={creditsEnabled} />
|
||||
<ModelLabel config={config} model={displayModel} capability={capability} theme={theme} creationVariant={creationVariant} showPrice={creditsEnabled} disabledReason={disabledReason} />
|
||||
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
|
||||
</button>
|
||||
);
|
||||
@@ -220,6 +230,7 @@ function ModelLabel({
|
||||
theme,
|
||||
creationVariant,
|
||||
showPrice,
|
||||
disabledReason,
|
||||
}: {
|
||||
config: AiConfig;
|
||||
model: string;
|
||||
@@ -227,10 +238,11 @@ function ModelLabel({
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
creationVariant: boolean;
|
||||
showPrice: boolean;
|
||||
disabledReason?: string;
|
||||
}) {
|
||||
const meta = modelMenuMeta(model, capability);
|
||||
const videoProfile = capability === "video" ? modelCapabilityConfigFor(config, model).video : undefined;
|
||||
const capabilitySummary = videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description;
|
||||
const capabilitySummary = disabledReason || (videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description);
|
||||
return (
|
||||
<span className="flex w-full min-w-0 items-center gap-1.5 overflow-hidden py-0">
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover }}>
|
||||
|
||||
Reference in New Issue
Block a user