Browse Source

refactor: replace provider/model dropdowns with Browse button and provider badge

Match the GenerateAudioNode layout to other external-provider generate
nodes (GenerateVideo, GenerateImage). Remove provider and model dropdown
selects in favor of a Browse button in the header and a ProviderBadge
icon next to the title. This simplifies the UI and makes it consistent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
22a5670d73
  1. 55
      src/components/__tests__/GenerateAudioNode.test.tsx
  2. 168
      src/components/nodes/GenerateAudioNode.tsx

55
src/components/__tests__/GenerateAudioNode.test.tsx

@ -195,32 +195,30 @@ describe("GenerateAudioNode", () => {
});
});
describe("Provider Selection", () => {
it("should show fal.ai as default provider", () => {
describe("Provider Badge", () => {
it("should show provider badge for default provider (fal)", () => {
const { container } = render(
<TestWrapper>
<GenerateAudioNode {...createNodeProps()} />
</TestWrapper>
);
// fal.ai badge should be the default (check via select value)
const select = container.querySelector('select') as HTMLSelectElement;
expect(select).toBeInTheDocument();
expect(select.value).toBe("fal");
// Provider badge should be rendered with fal.ai title
const badge = container.querySelector('[title="fal.ai"]');
expect(badge).toBeInTheDocument();
});
it("should update provider on change", () => {
render(
it("should show provider badge matching selected model provider", () => {
const { container } = render(
<TestWrapper>
<GenerateAudioNode {...createNodeProps({
selectedModel: { provider: "fal", modelId: "", displayName: "Select model..." },
selectedModel: { provider: "kie", modelId: "elevenlabs-turbo-v2.5", displayName: "ElevenLabs" },
})} />
</TestWrapper>
);
// Provider select should be present
const selects = screen.getAllByRole("combobox");
expect(selects.length).toBeGreaterThan(0);
const badge = container.querySelector('[title="Kie.ai"]');
expect(badge).toBeInTheDocument();
});
});
@ -449,28 +447,14 @@ describe("GenerateAudioNode", () => {
});
});
describe("Fetch Models on Mount", () => {
it("should fetch models with audio capabilities", async () => {
render(
<TestWrapper>
<GenerateAudioNode {...createNodeProps({
selectedModel: { provider: "fal", modelId: "", displayName: "Select model..." },
})} />
</TestWrapper>
);
await waitFor(() => {
const fetchCalls = mockFetch.mock.calls.filter(call =>
typeof call[0] === 'string' && call[0].includes('text-to-audio')
);
expect(fetchCalls.length).toBeGreaterThan(0);
describe("ModelParameters Component", () => {
it("should render ModelParameters when model is selected", () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ parameters: [], inputs: [] }),
});
});
});
describe("ModelParameters Component", () => {
it("should render ModelParameters when model is selected", async () => {
render(
const { container } = render(
<TestWrapper>
<GenerateAudioNode {...createNodeProps({
selectedModel: { provider: "kie", modelId: "elevenlabs-turbo-v2.5", displayName: "ElevenLabs Turbo v2.5" },
@ -478,10 +462,9 @@ describe("GenerateAudioNode", () => {
</TestWrapper>
);
// ModelParameters should attempt to load schema
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
// ModelParameters component should be rendered (it contains the parameters section)
// The node should show the model name as its title
expect(screen.getByText("ElevenLabs Turbo v2.5")).toBeInTheDocument();
});
});

168
src/components/nodes/GenerateAudioNode.tsx

@ -3,29 +3,22 @@
import React, { useCallback, useState, useEffect, useMemo, useRef } from "react";
import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { ProviderBadge } from "./ProviderBadge";
import { useCommentNavigation } from "@/hooks/useCommentNavigation";
import { ModelParameters } from "./ModelParameters";
import { useWorkflowStore, useProviderApiKeys } from "@/store/workflowStore";
import { deduplicatedFetch } from "@/utils/deduplicatedFetch";
import { useWorkflowStore } from "@/store/workflowStore";
import { GenerateAudioNodeData, ProviderType, SelectedModel, ModelInputDef } from "@/types";
import { ProviderModel, ModelCapability } from "@/lib/providers/types";
import { ProviderModel } from "@/lib/providers/types";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useAudioVisualization } from "@/hooks/useAudioVisualization";
// Audio generation capabilities
const AUDIO_CAPABILITIES: ModelCapability[] = ["text-to-audio"];
type GenerateAudioNodeType = Node<GenerateAudioNodeData, "generateAudio">;
export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudioNodeType>) {
const nodeData = data;
const commentNavigation = useCommentNavigation(id);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const { replicateApiKey, falApiKey, kieApiKey, wavespeedApiKey, replicateEnabled, kieEnabled } = useProviderApiKeys();
const generationsPath = useWorkflowStore((state) => state.generationsPath);
const [externalModels, setExternalModels] = useState<ProviderModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [modelsFetchError, setModelsFetchError] = useState<string | null>(null);
const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false);
const [isLoadingCarouselAudio, setIsLoadingCarouselAudio] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
@ -37,26 +30,6 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
// Get the current selected provider (default to fal)
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal";
// Get enabled providers
const enabledProviders = useMemo(() => {
const providers: { id: ProviderType; name: string }[] = [];
// fal.ai is always available
providers.push({ id: "fal", name: "fal.ai" });
// Add Replicate if configured
if (replicateEnabled && replicateApiKey) {
providers.push({ id: "replicate", name: "Replicate" });
}
// Add Kie.ai if configured
if (kieEnabled && kieApiKey) {
providers.push({ id: "kie", name: "Kie.ai" });
}
// Add WaveSpeed if configured (always check for key since enabled flag not in hook)
if (wavespeedApiKey) {
providers.push({ id: "wavespeed", name: "WaveSpeed" });
}
return providers;
}, [replicateEnabled, replicateApiKey, kieEnabled, kieApiKey, wavespeedApiKey]);
// Convert base64 data URL to Blob for visualization
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const { waveformData, isLoading: isLoadingWaveform } = useAudioVisualization(audioBlob);
@ -191,80 +164,6 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
}
}, [isPlaying, currentTime, nodeData.duration, waveformData, drawWaveform]);
// Fetch models from external providers
const fetchModels = useCallback(async () => {
setIsLoadingModels(true);
setModelsFetchError(null);
try {
const capabilities = AUDIO_CAPABILITIES.join(",");
const headers: HeadersInit = {};
if (replicateApiKey) {
headers["X-Replicate-Key"] = replicateApiKey;
}
if (falApiKey) {
headers["X-Fal-Key"] = falApiKey;
}
if (kieApiKey) {
headers["X-Kie-Key"] = kieApiKey;
}
if (wavespeedApiKey) {
headers["X-WaveSpeed-Key"] = wavespeedApiKey;
}
const response = await deduplicatedFetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers });
if (response.ok) {
const data = await response.json();
setExternalModels(data.models || []);
setModelsFetchError(null);
} else {
const errorData = await response.json().catch(() => ({}));
const errorMsg = errorData.error || `Failed to load models (${response.status})`;
setExternalModels([]);
setModelsFetchError(errorMsg);
}
} catch (error) {
console.error("Failed to fetch audio models:", error);
setExternalModels([]);
setModelsFetchError("Failed to load models. Check your connection.");
} finally {
setIsLoadingModels(false);
}
}, [currentProvider, replicateApiKey, falApiKey, kieApiKey, wavespeedApiKey]);
useEffect(() => {
fetchModels();
}, [fetchModels]);
// Handle provider change
const handleProviderChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const provider = e.target.value as ProviderType;
const newSelectedModel: SelectedModel = {
provider,
modelId: "",
displayName: "Select model...",
};
updateNodeData(id, { selectedModel: newSelectedModel, parameters: {} });
},
[id, updateNodeData]
);
// Handle model change
const handleModelChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const modelId = e.target.value;
const model = externalModels.find(m => m.id === modelId);
if (model) {
const newSelectedModel: SelectedModel = {
provider: currentProvider,
modelId: model.id,
displayName: model.name,
};
updateNodeData(id, { selectedModel: newSelectedModel, parameters: {} });
}
},
[id, currentProvider, externalModels, updateNodeData]
);
const handleClearAudio = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
@ -420,6 +319,21 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
// Provider badge as title prefix
const titlePrefix = useMemo(() => (
<ProviderBadge provider={currentProvider} />
), [currentProvider]);
// Header action element - browse button
const headerAction = useMemo(() => (
<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>
), []);
// Dynamic handles based on inputSchema
const dynamicHandles = useMemo(() => {
if (!nodeData.inputSchema || nodeData.inputSchema.length === 0) return null;
@ -448,6 +362,8 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
<BaseNode
id={id}
title={displayTitle}
titlePrefix={titlePrefix}
headerAction={headerAction}
customTitle={nodeData.customTitle}
comment={nodeData.comment}
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })}
@ -457,50 +373,6 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
minWidth={300}
minHeight={250}
>
{/* Provider and model selection */}
<div className="flex flex-col gap-2 mb-2">
<div className="flex gap-2">
<select
value={currentProvider}
onChange={handleProviderChange}
className="flex-1 px-2 py-1 text-xs bg-neutral-800 border border-neutral-700 rounded"
>
{enabledProviders.map(provider => (
<option key={provider.id} value={provider.id}>
{provider.name}
</option>
))}
</select>
<button
onClick={() => setIsBrowseDialogOpen(true)}
className="px-2 py-1 text-xs bg-violet-600 hover:bg-violet-500 rounded transition-colors"
title="Browse all models"
>
Browse
</button>
</div>
{isLoadingModels ? (
<div className="text-xs text-neutral-500">Loading models...</div>
) : modelsFetchError ? (
<div className="text-xs text-red-400">{modelsFetchError}</div>
) : (
<select
value={nodeData.selectedModel?.modelId || ""}
onChange={handleModelChange}
className="w-full px-2 py-1 text-xs bg-neutral-800 border border-neutral-700 rounded"
disabled={externalModels.length === 0}
>
<option value="">Select model...</option>
{externalModels.map(model => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
)}
</div>
{/* Model parameters */}
{nodeData.selectedModel?.modelId && (
<ModelParameters

Loading…
Cancel
Save