import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { Check, ChevronDown, Coins, Cpu } from "lucide-react"; import { Popover } from "antd"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities"; import { compatibleModelInGroup, configuredModelDisplayName, 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"; import { useUserStore } from "@/stores/use-user-store"; type ModelPickerProps = { config: AiConfig; value?: string; onChange: (model: string) => void; capability?: ModelCapability; className?: string; fullWidth?: boolean; placeholder?: string; onMissingConfig?: () => void; showSelectedPrice?: boolean; variant?: "default" | "creation"; requirements?: ModelRequirements; showConfiguredModelName?: boolean; }; export function ModelPicker({ config, value, onChange, capability, className, 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'" 崩溃 const rawTheme = useThemeStore((state) => state.theme); const theme = (canvasThemes[rawTheme as keyof typeof canvasThemes] ?? canvasThemes.dark) as CanvasTheme; const [open, setOpen] = useState(false); const menuRef = useRef(null); const triggerRef = useRef(null); const options = useMemo(() => { const filtered = selectableModelsByCapability(config, capability); const current = value?.trim(); const currentIncluded = current ? filtered.includes(current) : true; return Array.from(new Set([...filtered, ...(!currentIncluded && current ? [current] : [])].filter((model): model is string => Boolean(model)))); }, [capability, config, value]); const optionGroups = useMemo(() => { const channelGroups = config.channels .map((channel) => ({ key: channel.id, label: channel.name || "未命名渠道", scope: channel.scope === "system" ? "系统渠道" : "自定义渠道", 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.flatMap((modelGroup) => modelGroup.models))); const ungroupedModels = options.filter((model) => !groupedModels.has(model)); return ungroupedModels.length ? [...channelGroups, { key: "ungrouped", label: "其他模型", scope: "未指定渠道", models: groupModelsByDisplayName(config, ungroupedModels) }] : channelGroups; }, [config, options]); const current = value || ""; const resolvedCurrent = resolveCompatibleModel(config, current, requirements) || current; const currentPrice = modelMenuPrice(config, resolvedCurrent); const creationVariant = variant === "creation"; 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 && config.channelMode === "local") onMissingConfig?.(); if (nextOpen) window.dispatchEvent(new CustomEvent("model-picker-open", { detail: pickerId })); setOpen(nextOpen); }; const focusMenuOption = (last = false) => { window.requestAnimationFrame(() => { const buttons = menuRef.current?.querySelectorAll('[role="option"]'); 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 (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; const buttons = Array.from(event.currentTarget.querySelectorAll('[role="option"]')); 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()} > {creationVariant ? (
选择模型 {current ? {pickerModelDisplayName(config, current, showConfiguredModelName)} : null}
) : null} {optionGroups.length ? ( optionGroups.map((group) => (
{group.label} {group.scope}
{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 ( ); })}
)) ) : (
{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, showPrice, disabledReason, }: { config: AiConfig; model: string; capability?: ModelCapability; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; creationVariant: boolean; showConfiguredModelName: boolean; showPrice: boolean; disabledReason?: string; }) { const meta = modelMenuMeta(model, capability); const videoProfile = capability === "video" ? modelCapabilityConfigFor(config, model).video : undefined; const capabilitySummary = disabledReason || (videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description); return ( {pickerModelDisplayName(config, model, showConfiguredModelName)} {capabilitySummary} {showPrice ? : null} {!creationVariant && meta.time ? ( {meta.time} ) : null} ); } 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 = { value: number; unit: "次" | "秒" | "百万 Token" }; function modelMenuPrice(config: AiConfig, model: string): 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.billingMode === "token") { return { value: (cost.outputTokenPriceMicrocredits || 0) / 1_000_000, unit: "百万 Token" }; } return { value: cost.unitPriceMicrocredits / 1_000_000, unit: cost.billingMode === "per_second" ? "秒" : "次" }; } function pickerModelDisplayName(config: AiConfig, model: string, showConfiguredModelName: boolean) { return showConfiguredModelName ? configuredModelDisplayName(config, model) : modelDisplayName(config, model); } function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredModelName: boolean) { return showConfiguredModelName ? `${configuredModelDisplayName(config, model)}(${resolveModelChannel(config, model).name})` : modelOptionLabel(config, model); } function ModelPrice({ price, compact = false }: { price: ModelMenuPrice | null | undefined; compact?: boolean }) { if (price === undefined) return null; if (price === null) return compact ? null : 未配置; 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({ model }: { model: string }) { const icon = resolveModelIcon(modelOptionName(model)); const monochrome = icon === "/icons/openai.svg" || icon === "/icons/grok.svg"; return icon ? : ; } export function resolveModelIcon(model: string) { const name = model.toLowerCase(); if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg"; // Flow2API 短名:Nano Banana / Imagen / Veo / Omni 均属 Google Gemini 系。 if (name.includes("gemini") || name.includes("google") || name.includes("nano banana") || name.includes("nanobanana") || name.includes("imagen") || name.includes("veo") || name.includes("omni flash") || name.includes("omni-flash")) { return "/icons/gemini.svg"; } if (name.includes("gpt") || name.includes("openai") || name.includes("dall-e") || name.includes("dalle")) return "/icons/openai.svg"; if (name.includes("grok")) return "/icons/grok.svg"; if (name.includes("deepseek")) return "/icons/deepseek.svg"; if (name.includes("glm") || name.includes("chatglm")) return "/icons/glm.svg"; return ""; }