import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from "react"; import { Check, ChevronDown, ChevronLeft, Coins } from "lucide-react"; import { Popover } from "antd"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities"; import { formatPriceRange, modelQuoteDescription, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing"; import { compatibleModelInGroup, configuredModelDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; import { groupModelsForPicker, isDirectSystemModel, modelChannelLabel } from "@/lib/model-picker-groups"; import { cn } from "@/lib/utils"; import { modelDisplayName, modelIcon, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { useUserStore } from "@/stores/use-user-store"; import { ModelLogo } from "@/components/model-logo"; import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models"; type ModelPickerProps = { config: AiConfig; value?: string; onChange: (model: string) => void; capability?: ModelCapability; className?: string; popoverClassName?: string; fullWidth?: boolean; placeholder?: string; onMissingConfig?: () => void; showSelectedPrice?: boolean; showOptionPrices?: boolean; variant?: "default" | "creation"; requirements?: ModelRequirements; showConfiguredModelName?: boolean; }; export function ModelPicker({ config, value, onChange, capability, className, popoverClassName, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, showOptionPrices = showSelectedPrice, variant = "creation", requirements, showConfiguredModelName = false, }: ModelPickerProps) { const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const pickerId = useId(); // 双保险:即使 store merge 写出非法 theme,这里也兜底到 dark,避免 "reading 'node'" 崩溃 const rawTheme = useActiveTheme(); const theme = (canvasThemes[rawTheme as keyof typeof canvasThemes] ?? canvasThemes.dark) as CanvasTheme; const [open, setOpen] = useState(false); const [activeGroupKey, setActiveGroupKey] = useState(null); const [previewedModel, setPreviewedModel] = useState(""); const [triggerWidth, setTriggerWidth] = useState(null); const menuRef = useRef(null); const triggerRef = useRef(null); const options = useMemo(() => Array.from(new Set(selectableModelsByCapability(config, capability).filter(Boolean))), [capability, config]); const optionGroups = useMemo(() => groupModelsForPicker(config, options), [config, options]); const storedCurrent = value?.trim() || ""; // 参数档位会在选中模型后由调用方归一到其能力配置,不能因为旧模型留下的参数而禁止切换。 const selectionRequirements = requirements ? { ...requirements, videoSeconds: undefined, imageSize: undefined, options: undefined } : undefined; const resolvedCurrent = isDirectSystemModel(config, storedCurrent) ? storedCurrent : resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent; // 旧画布可能保存过已下架或前端历史内置模型;它们不能重新进入当前可选目录。 const current = options.includes(resolvedCurrent) ? resolvedCurrent : ""; 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"; useLayoutEffect(() => { const trigger = triggerRef.current; if (!trigger) return; const updateTriggerWidth = () => setTriggerWidth(Math.ceil(trigger.getBoundingClientRect().width)); updateTriggerWidth(); const observer = new ResizeObserver(updateTriggerWidth); observer.observe(trigger); return () => observer.disconnect(); }, [className, fullWidth, showSelectedPrice, variant, value]); useEffect(() => { if (!showSelectedPrice || !creditsEnabled || !quoteRequest) { setRouteQuote(undefined); return; } const controller = new AbortController(); setRouteQuote(undefined); quoteModel(quoteRequest, controller.signal) .then((payload) => setRouteQuote(payload.quote)) .catch(() => { if (!controller.signal.aborted) setRouteQuote(undefined); }); return () => controller.abort(); }, [creditsEnabled, quoteRequest, showSelectedPrice]); useEffect(() => { const closeOtherPicker = (event: Event) => { if ((event as CustomEvent).detail !== pickerId) setOpen(false); }; window.addEventListener("model-picker-open", closeOtherPicker); return () => window.removeEventListener("model-picker-open", closeOtherPicker); }, [pickerId]); useEffect(() => { if (!open) return; // 画布拖拽从 pointerdown 开始,须在捕获阶段关闭 Portal 菜单,避免菜单与触发器分离。 const closeOnOutsidePointer = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Node)) return; if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return; setOpen(false); }; window.addEventListener("pointerdown", closeOnOutsidePointer, true); return () => window.removeEventListener("pointerdown", closeOnOutsidePointer, true); }, [open]); const setPickerOpen = (nextOpen: boolean) => { if (nextOpen && !options.length) onMissingConfig?.(); if (nextOpen) window.dispatchEvent(new CustomEvent("model-picker-open", { detail: pickerId })); if (nextOpen) { setPreviewedModel(current || options[0] || ""); setActiveGroupKey(null); } setOpen(nextOpen); }; const focusMenuOption = (last = false) => { window.requestAnimationFrame(() => { const buttons = menuRef.current?.querySelectorAll('[data-model-picker-item]:not(:disabled)'); const target = last ? buttons?.item((buttons?.length || 1) - 1) : buttons?.item(0); target?.focus(); }); }; const handleTriggerKeyDown = (event: KeyboardEvent) => { if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; event.preventDefault(); setPickerOpen(true); focusMenuOption(event.key === "ArrowUp"); }; const handleMenuKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); setOpen(false); triggerRef.current?.focus(); return; } if (event.key === "ArrowLeft" && activeGroupKey !== null) { event.preventDefault(); setActiveGroupKey(null); focusMenuOption(); return; } if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; const buttons = Array.from(event.currentTarget.querySelectorAll('[data-model-picker-item]:not(:disabled)')); if (!buttons.length) return; event.preventDefault(); const activeIndex = buttons.indexOf(document.activeElement as HTMLButtonElement); const nextIndex = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : event.key === "ArrowUp" ? Math.max(0, activeIndex - 1) : Math.min(buttons.length - 1, activeIndex + 1); buttons[nextIndex]?.focus(); }; const content = (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > {optionGroups.length ? ( activeGroupKey === null ? (
{optionGroups.map((group) => { const groupCurrent = group.models.find((item) => item.models.includes(current)); const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || ""; return ; })}
) :
{optionGroups.map((group) => { const groupCurrent = group.models.find((item) => item.models.includes(current)); const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || ""; return ; })}
{optionGroups.filter((group) => group.key === activeGroupKey).map((group) =>
{group.label}{group.scope ? {group.scope} : null}
{group.models.map((modelGroup) => { const selected = modelGroup.models.includes(current); const model = compatibleModelInGroup(config, modelGroup.models, selectionRequirements, selected ? current : undefined); const displayModel = model || (selected ? current : modelGroup.models[0]); const disabledReason = model ? "" : modelCompatibilityError(config, modelGroup.models[0], selectionRequirements) || "当前输入不符合该模型能力"; return ( ); })}
)}
) : (
{emptyModelLabel(config, capability)}
)}
); return (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
); } function emptyModelLabel(config: AiConfig, capability?: ModelCapability) { const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : ""; if (capability && config.models.length) return `暂无支持当前输入的${label}模型`; return config.models.length ? `暂无匹配的${label}模型` : "当前没有可用模型,请联系管理员或检查模型配置"; } function ModelLabel({ config, model, capability, theme, creationVariant, showConfiguredModelName, label, requirements, showPrice, disabledReason, showDescription, }: { config: AiConfig; model: string; capability?: ModelCapability; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; creationVariant: boolean; showConfiguredModelName: boolean; label?: string; requirements?: ModelRequirements; showPrice: boolean; disabledReason?: string; showDescription: boolean; }) { const meta = modelMenuMeta(model, capability); const channel = resolveModelChannel(config, model); 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() || (isDirectSystemModel(config, model) ? "" : logicalSpec ? logicalCapabilitySummary(logicalSpec) : videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description); return ( {label || pickerModelDisplayName(config, model, showConfiguredModelName)} {capabilitySummary} {showPrice ? ( ) : null} {!creationVariant && meta.time ? ( {meta.time} ) : null} ); } function logicalCapabilitySummary(spec: NonNullable[number]["logicalCapabilitySpec"]>) { const operationLabels: Record = { text_to_video: "文生视频", image_to_video: "图生视频", reference_to_video: "全模态参考", audio_to_video: "音频生视频", extend: "视频续写", inpaint: "局部修改", replace_element: "元素替换", camera_motion: "运镜调整", style_transfer: "风格迁移", }; const inputLabels: Record = { image: { label: spec.capability === "text" ? "图片理解" : "参考图片", unit: "张" }, video: { label: spec.capability === "text" ? "视频理解" : "参考视频", unit: "个" }, audio: { label: "参考音频", unit: "个" }, mask: { label: "蒙版", unit: "张" }, }; const optionLabels: Record = { size: "画面比例", aspectRatio: "画面比例", quality: "生成质量", count: "输出数量", videoSeconds: "视频时长", duration: "视频时长", vquality: "输出分辨率", resolution: "输出分辨率", audioVoice: "音色", audioFormat: "音频格式", audioSpeed: "语速", }; const values: string[] = []; values.push(...(spec.operations || []).map((operation) => operationLabels[operation] || operation)); for (const [name, constraint] of Object.entries(spec.inputs || {})) { if (constraint.max <= 0) continue; const definition = inputLabels[name]; if (!definition) continue; values.push(spec.capability === "text" ? `支持${definition.label}` : `${definition.label}最多 ${constraint.max}${definition.unit}`); } for (const [name, constraint] of Object.entries(spec.options || {})) { const label = optionLabels[name]; if (!label) continue; if (constraint.values?.length) values.push(`${label} ${constraint.values.map(publicScalarLabel).join("/")}`); else if (constraint.min !== undefined && constraint.max !== undefined) values.push(`${label} ${constraint.min}-${constraint.max}`); } return values.slice(0, 2).join(" · ") || "智能匹配当前输入"; } function publicScalarLabel(value: unknown) { if (value === true) return "支持"; if (value === false) return "关闭"; return String(value); } function formatDurationSummary(profile: NonNullable["video"]>) { const values = videoDurationOptions(profile); if (profile.duration.selection === "enum") return values.map((item) => `${item}s`).join("/"); return `${profile.duration.min || values[0]}-${profile.duration.max || values[values.length - 1]}s`; } type ModelMenuPrice = { kind: "tiers"; label: string; compactLabel: string; title: string } | { kind: "estimate"; label?: string; title?: string } | { kind: "fixed"; value: number; unit: "次" | "秒" }; function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapability, summary = false, requirements?: ModelRequirements): ModelMenuPrice | null | undefined { if (!model) return undefined; const channel = resolveModelChannel(config, model); const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model)); if (!cost) return channel.scope === "system" ? null : undefined; if (cost.pricePolicy === "channel") { const tiers = cost.logicalPriceTiers || []; if (!tiers.length) return null; const matched = summary ? tiers : priceTiersForCurrentSelection(tiers, capability, config, requirements); if (!summary && !matched.length) return { kind: "tiers", label: "当前规格无报价", compactLabel: "当前规格无报价", title: "请调整参数,或选择支持当前规格的渠道" }; return channelTierPriceSummary(matched.length ? matched : tiers, tiers, capability); } if (cost.billingMode === "token") { const rate = cost.outputTokenPriceMicrocredits; return capability === "video" && typeof rate === "number" && Number.isFinite(rate) && rate >= 0 ? { kind: "estimate", label: formatPriceRange([rate / 1_000_000], "积分/百万视频 Token"), title: "按视频 Token 单价预估,优先按有效上游用量结算;未返回用量时按视频公式结算" } : { kind: "estimate" }; } return { kind: "fixed", value: cost.unitPriceMicrocredits / 1_000_000, unit: cost.billingMode === "per_second" ? "秒" : "次" }; } function pickerModelDisplayName(config: AiConfig, model: string, showConfiguredModelName: boolean) { const name = showConfiguredModelName ? configuredModelDisplayName(config, model) : modelDisplayName(config, model); return isDirectSystemModel(config, model) ? `${name} · ${modelChannelLabel(config, model)}` : name; } function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredModelName: boolean) { const displayName = showConfiguredModelName ? configuredModelDisplayName(config, model) : modelDisplayName(config, model); const channel = resolveModelChannel(config, model); return channel.scope === "system" ? pickerModelDisplayName(config, model, showConfiguredModelName) : `${displayName}(${channel.name})`; } function channelTierPriceSummary( visibleTiers: NonNullable[number]["logicalPriceTiers"]>, allTiers: NonNullable[number]["logicalPriceTiers"]>, capability?: ModelCapability, ): Extract { const label = priceTierSummaryLabel(visibleTiers, capability); return { kind: "tiers", label, compactLabel: label, title: `系统规格价格:${allTiers.map((tier) => `${tierSpecificationLabel(tier)} ${priceTierSummaryLabel([tier], capability)}`).join(";")}${allTiers.some((tier) => tier.billingMode === "token") ? (capability === "video" ? ";优先按有效上游用量结算,未返回用量时按视频公式结算" : ";Token 费用为预估,最终按成功任务的实际用量结算") : ""}`, }; } function tierResolutionLabel(value: string) { const normalized = normalizeTierResolution(value); return normalized === "*" ? "全部分辨率" : normalized.toUpperCase(); } function tierDurationLabel(seconds: number) { return seconds > 0 ? `${seconds} 秒` : "全部时长"; } 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} 张参考图` : "", selector.videoGenerateAudio === "true" ? "有声" : selector.videoGenerateAudio === "false" ? "无声" : "", ].filter(Boolean); return details.length ? details.join(" / ") : "默认规格"; } function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice | null | undefined; quote?: LogicalModelQuote; compact?: boolean }) { if (quote) { const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 }); const label = quote.estimated ? `预估:${amount}` : `${amount}`; return ( {compact ? label : `${label} 积分`} ); } if (price === undefined) return null; if (price === null) return compact ? null : 未配置; if (price.kind === "tiers") { return ( {compact ? price.compactLabel : price.label} ); } if (price.kind === "estimate") { return {price.label || "按量预估"}; } return ( {price.value.toLocaleString("zh-CN", { maximumFractionDigits: compact ? 3 : 6 })}/{price.unit} ); } function modelMenuMeta(model: string, capability?: ModelCapability): { description: string; time?: string } { const name = modelOptionName(model).toLowerCase(); if (capability === "image") { if (name.includes("nano banana") || name.includes("nanobanana") || name.includes("imagen")) return { description: "Gemini 高质量图片生成,适合角色和商业成片" }; if (name.includes("nano") || name.includes("pro")) return { description: "高质量图片生成,适合角色和商业成片" }; if (name.includes("seedream")) return { description: "快速出图,适合批量探索风格" }; if (name.includes("gpt") || name.includes("image")) return { description: "通用图片模型,提示词理解稳定" }; return { description: "图片生成模型" }; } if (capability === "video") { if (name.includes("veo") || name.includes("omni flash") || name.includes("omni-flash")) return { description: "Gemini 镜头生成与图生视频,适合成片流程", time: "3m" }; if (name.includes("seedance") || name.includes("sora")) return { description: "镜头生成与图生视频,适合成片流程", time: "3m" }; return { description: "视频生成模型", time: "3m" }; } if (capability === "audio") return { description: "语音、音效或音乐生成", time: "20s" }; if (name.includes("claude")) return { description: "长文本、推理与创意写作", time: "10s" }; if (name.includes("gemini")) return { description: "多模态理解与快速文本生成", time: "10s" }; if (name.includes("deepseek")) return { description: "推理、代码和结构化文本", time: "10s" }; return { description: capability === "text" ? "文本生成模型" : "当前模型", time: "10s" }; } export function ModelIcon({ config, model, icon }: { config?: AiConfig; model?: string; icon?: string }) { return ; }