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;
+ }
+}