From cb3a7cbc8869db611c4b3b1fe28310aad8710c7c Mon Sep 17 00:00:00 2001 From: shrimbly Date: Mon, 2 Mar 2026 20:50:49 +1300 Subject: [PATCH] feat(15-01): add external link icon to generate node headers - Create shared getModelPageUrl utility in src/utils/providerUrls.ts - Add external-link icon to GenerateImageNode header (before Browse button) - Add external-link icon to GenerateVideoNode header (before Browse button) - Add external-link icon to Generate3DNode header (before Browse button) - Icon only appears when model is selected and provider has a page URL - Gemini models show no link (returns null); Replicate/fal/Kie/WaveSpeed show links Co-Authored-By: Claude Opus 4.6 --- src/components/nodes/Generate3DNode.tsx | 39 ++++++++++++++---- src/components/nodes/GenerateImageNode.tsx | 42 ++++++++++++++----- src/components/nodes/GenerateVideoNode.tsx | 39 ++++++++++++++---- src/utils/providerUrls.ts | 47 ++++++++++++++++++++++ 4 files changed, 142 insertions(+), 25 deletions(-) create mode 100644 src/utils/providerUrls.ts diff --git a/src/components/nodes/Generate3DNode.tsx b/src/components/nodes/Generate3DNode.tsx index 732140ab..beaf038a 100644 --- a/src/components/nodes/Generate3DNode.tsx +++ b/src/components/nodes/Generate3DNode.tsx @@ -11,6 +11,7 @@ import { ProviderModel, ModelCapability } from "@/lib/providers/types"; import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; import { useToast } from "@/components/Toast"; import { ProviderBadge } from "./ProviderBadge"; +import { getModelPageUrl, getProviderDisplayName } from "@/utils/providerUrls"; // 3D generation capabilities const THREE_D_CAPABILITIES: ModelCapability[] = ["text-to-3d", "image-to-3d"]; @@ -92,15 +93,37 @@ export function Generate3DNode({ id, data, selected }: NodeProps ), [currentProvider]); - // Header action element - browse button + // Compute model page URL for external link + const modelPageUrl = useMemo(() => { + if (!nodeData.selectedModel?.modelId) return null; + return getModelPageUrl(currentProvider, nodeData.selectedModel.modelId); + }, [currentProvider, nodeData.selectedModel?.modelId]); + + // Header action element - external link + browse button const headerAction = useMemo(() => ( - - ), []); + <> + {modelPageUrl && nodeData.selectedModel?.modelId && ( + e.stopPropagation()} + className="nodrag nopan text-neutral-500 hover:text-neutral-300 transition-colors" + title={`View on ${getProviderDisplayName(currentProvider)}`} + > + + + + + )} + + + ), [modelPageUrl, nodeData.selectedModel?.modelId, currentProvider]); // Track previous status to detect error transitions const prevStatusRef = useRef(nodeData.status); diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index 57838d49..36ea522a 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -13,6 +13,7 @@ import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; import { useToast } from "@/components/Toast"; import { getImageDimensions, calculateNodeSizePreservingHeight } from "@/utils/nodeDimensions"; import { ProviderBadge } from "./ProviderBadge"; +import { getModelPageUrl, getProviderDisplayName } from "@/utils/providerUrls"; // Base 10 aspect ratios (all Gemini image models) const BASE_ASPECT_RATIOS: AspectRatio[] = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]; @@ -389,21 +390,44 @@ export function GenerateImageNode({ id, data, selected }: NodeProps ), [currentProvider]); + // Compute model page URL for external link + const modelPageUrl = useMemo(() => { + if (!nodeData.selectedModel?.modelId) return null; + return getModelPageUrl(currentProvider, nodeData.selectedModel.modelId); + }, [currentProvider, nodeData.selectedModel?.modelId]); + // Header action element based on provider mode const headerAction = useMemo(() => { + const linkIcon = modelPageUrl && nodeData.selectedModel?.modelId ? ( + e.stopPropagation()} + className="nodrag nopan text-neutral-500 hover:text-neutral-300 transition-colors" + title={`View on ${getProviderDisplayName(currentProvider)}`} + > + + + + + ) : null; + if (!isGeminiOnly) { - // External providers: browse button return ( - + <> + {linkIcon} + + ); } - return null; - }, [isGeminiOnly]); + return linkIcon; + }, [isGeminiOnly, modelPageUrl, nodeData.selectedModel?.modelId, currentProvider]); // Use selectedModel.modelId for Gemini models, fallback to legacy model field const currentModelId = isGeminiProvider ? (nodeData.selectedModel?.modelId || nodeData.model) : null; const supportsResolution = currentModelId === "nano-banana-pro" || currentModelId === "nano-banana-2"; diff --git a/src/components/nodes/GenerateVideoNode.tsx b/src/components/nodes/GenerateVideoNode.tsx index f2770b3e..a96fafe2 100644 --- a/src/components/nodes/GenerateVideoNode.tsx +++ b/src/components/nodes/GenerateVideoNode.tsx @@ -14,6 +14,7 @@ import { useToast } from "@/components/Toast"; import { getVideoDimensions, calculateNodeSizePreservingHeight } from "@/utils/nodeDimensions"; import { ProviderBadge } from "./ProviderBadge"; import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl"; +import { getModelPageUrl, getProviderDisplayName } from "@/utils/providerUrls"; // Video generation capabilities const VIDEO_CAPABILITIES: ModelCapability[] = ["text-to-video", "image-to-video"]; @@ -329,15 +330,37 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps ), [currentProvider]); - // Header action element - browse button + // Compute model page URL for external link + const modelPageUrl = useMemo(() => { + if (!nodeData.selectedModel?.modelId) return null; + return getModelPageUrl(currentProvider, nodeData.selectedModel.modelId); + }, [currentProvider, nodeData.selectedModel?.modelId]); + + // Header action element - external link + browse button const headerAction = useMemo(() => ( - - ), []); + <> + {modelPageUrl && nodeData.selectedModel?.modelId && ( + e.stopPropagation()} + className="nodrag nopan text-neutral-500 hover:text-neutral-300 transition-colors" + title={`View on ${getProviderDisplayName(currentProvider)}`} + > + + + + + )} + + + ), [modelPageUrl, nodeData.selectedModel?.modelId, currentProvider]); const hasCarouselVideos = (nodeData.videoHistory || []).length > 1; diff --git a/src/utils/providerUrls.ts b/src/utils/providerUrls.ts new file mode 100644 index 00000000..d91f1680 --- /dev/null +++ b/src/utils/providerUrls.ts @@ -0,0 +1,47 @@ +import { ProviderType } from "@/types"; + +/** + * Get the URL to a model's page on its provider's website. + * Returns null for providers without model pages (e.g., Gemini). + */ +export function getModelPageUrl( + provider: ProviderType, + modelId: string, + pageUrl?: string +): string | null { + if (pageUrl) return pageUrl; + switch (provider) { + case "replicate": { + const baseModelId = modelId.split(":")[0]; + return `https://replicate.com/${baseModelId}`; + } + case "fal": + return `https://fal.ai/models/${modelId}`; + case "kie": + return `https://docs.kie.ai/`; + case "wavespeed": + return `https://wavespeed.ai`; + default: + return null; + } +} + +/** + * Get the display name for a provider. + */ +export function getProviderDisplayName(provider: ProviderType): string { + switch (provider) { + case "gemini": + return "Gemini"; + case "replicate": + return "Replicate"; + case "fal": + return "fal.ai"; + case "kie": + return "Kie.ai"; + case "wavespeed": + return "WaveSpeed"; + default: + return provider; + } +}