Browse Source

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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
cb3a7cbc88
  1. 39
      src/components/nodes/Generate3DNode.tsx
  2. 42
      src/components/nodes/GenerateImageNode.tsx
  3. 39
      src/components/nodes/GenerateVideoNode.tsx
  4. 47
      src/utils/providerUrls.ts

39
src/components/nodes/Generate3DNode.tsx

@ -11,6 +11,7 @@ import { ProviderModel, ModelCapability } from "@/lib/providers/types";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { ProviderBadge } from "./ProviderBadge"; import { ProviderBadge } from "./ProviderBadge";
import { getModelPageUrl, getProviderDisplayName } from "@/utils/providerUrls";
// 3D generation capabilities // 3D generation capabilities
const THREE_D_CAPABILITIES: ModelCapability[] = ["text-to-3d", "image-to-3d"]; const THREE_D_CAPABILITIES: ModelCapability[] = ["text-to-3d", "image-to-3d"];
@ -92,15 +93,37 @@ export function Generate3DNode({ id, data, selected }: NodeProps<Generate3DNodeT
<ProviderBadge provider={currentProvider} /> <ProviderBadge provider={currentProvider} />
), [currentProvider]); ), [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(() => ( const headerAction = useMemo(() => (
<button <>
onClick={() => setIsBrowseDialogOpen(true)} {modelPageUrl && nodeData.selectedModel?.modelId && (
className="nodrag nopan text-[10px] py-0.5 px-1.5 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors" <a
> href={modelPageUrl}
Browse target="_blank"
</button> rel="noopener noreferrer"
), []); onClick={(e) => e.stopPropagation()}
className="nodrag nopan text-neutral-500 hover:text-neutral-300 transition-colors"
title={`View on ${getProviderDisplayName(currentProvider)}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
<button
onClick={() => setIsBrowseDialogOpen(true)}
className="nodrag nopan text-[10px] py-0.5 px-1.5 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Browse
</button>
</>
), [modelPageUrl, nodeData.selectedModel?.modelId, currentProvider]);
// Track previous status to detect error transitions // Track previous status to detect error transitions
const prevStatusRef = useRef(nodeData.status); const prevStatusRef = useRef(nodeData.status);

42
src/components/nodes/GenerateImageNode.tsx

@ -13,6 +13,7 @@ import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { getImageDimensions, calculateNodeSizePreservingHeight } from "@/utils/nodeDimensions"; import { getImageDimensions, calculateNodeSizePreservingHeight } from "@/utils/nodeDimensions";
import { ProviderBadge } from "./ProviderBadge"; import { ProviderBadge } from "./ProviderBadge";
import { getModelPageUrl, getProviderDisplayName } from "@/utils/providerUrls";
// Base 10 aspect ratios (all Gemini image models) // 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"]; 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<NanoBananaNo
<ProviderBadge provider={currentProvider} /> <ProviderBadge provider={currentProvider} />
), [currentProvider]); ), [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 // Header action element based on provider mode
const headerAction = useMemo(() => { const headerAction = useMemo(() => {
const linkIcon = modelPageUrl && nodeData.selectedModel?.modelId ? (
<a
href={modelPageUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="nodrag nopan text-neutral-500 hover:text-neutral-300 transition-colors"
title={`View on ${getProviderDisplayName(currentProvider)}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
) : null;
if (!isGeminiOnly) { if (!isGeminiOnly) {
// External providers: browse button
return ( return (
<button <>
onClick={() => setIsBrowseDialogOpen(true)} {linkIcon}
className="nodrag nopan text-[10px] py-0.5 px-1.5 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors" <button
> onClick={() => setIsBrowseDialogOpen(true)}
Browse className="nodrag nopan text-[10px] py-0.5 px-1.5 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
</button> >
Browse
</button>
</>
); );
} }
return null; return linkIcon;
}, [isGeminiOnly]); }, [isGeminiOnly, modelPageUrl, nodeData.selectedModel?.modelId, currentProvider]);
// Use selectedModel.modelId for Gemini models, fallback to legacy model field // Use selectedModel.modelId for Gemini models, fallback to legacy model field
const currentModelId = isGeminiProvider ? (nodeData.selectedModel?.modelId || nodeData.model) : null; const currentModelId = isGeminiProvider ? (nodeData.selectedModel?.modelId || nodeData.model) : null;
const supportsResolution = currentModelId === "nano-banana-pro" || currentModelId === "nano-banana-2"; const supportsResolution = currentModelId === "nano-banana-pro" || currentModelId === "nano-banana-2";

39
src/components/nodes/GenerateVideoNode.tsx

@ -14,6 +14,7 @@ import { useToast } from "@/components/Toast";
import { getVideoDimensions, calculateNodeSizePreservingHeight } from "@/utils/nodeDimensions"; import { getVideoDimensions, calculateNodeSizePreservingHeight } from "@/utils/nodeDimensions";
import { ProviderBadge } from "./ProviderBadge"; import { ProviderBadge } from "./ProviderBadge";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl"; import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
import { getModelPageUrl, getProviderDisplayName } from "@/utils/providerUrls";
// Video generation capabilities // Video generation capabilities
const VIDEO_CAPABILITIES: ModelCapability[] = ["text-to-video", "image-to-video"]; const VIDEO_CAPABILITIES: ModelCapability[] = ["text-to-video", "image-to-video"];
@ -329,15 +330,37 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
<ProviderBadge provider={currentProvider} /> <ProviderBadge provider={currentProvider} />
), [currentProvider]); ), [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(() => ( const headerAction = useMemo(() => (
<button <>
onClick={() => setIsBrowseDialogOpen(true)} {modelPageUrl && nodeData.selectedModel?.modelId && (
className="nodrag nopan text-[10px] py-0.5 px-1.5 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors" <a
> href={modelPageUrl}
Browse target="_blank"
</button> rel="noopener noreferrer"
), []); onClick={(e) => e.stopPropagation()}
className="nodrag nopan text-neutral-500 hover:text-neutral-300 transition-colors"
title={`View on ${getProviderDisplayName(currentProvider)}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
<button
onClick={() => setIsBrowseDialogOpen(true)}
className="nodrag nopan text-[10px] py-0.5 px-1.5 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Browse
</button>
</>
), [modelPageUrl, nodeData.selectedModel?.modelId, currentProvider]);
const hasCarouselVideos = (nodeData.videoHistory || []).length > 1; const hasCarouselVideos = (nodeData.videoHistory || []).length > 1;

47
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;
}
}
Loading…
Cancel
Save