You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
160 lines
5.4 KiB
160 lines
5.4 KiB
"use client";
|
|
|
|
/**
|
|
* LLMFallbackPopover
|
|
*
|
|
* Small centered modal for selecting a fallback LLM for an llmGenerate node.
|
|
* Persists the selection as a SelectedModel on nodeData.fallbackModel, with
|
|
* "google" mapped to "gemini" as ProviderType (matches how NBP stores LLM
|
|
* provider info so JSON round-trips cleanly).
|
|
*/
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useWorkflowStore } from "@/store/workflowStore";
|
|
import type {
|
|
LLMGenerateNodeData,
|
|
LLMProvider,
|
|
LLMModelType,
|
|
ProviderType,
|
|
SelectedModel,
|
|
} from "@/types";
|
|
|
|
const LLM_PROVIDERS: { value: LLMProvider; label: string }[] = [
|
|
{ value: "google", label: "Google" },
|
|
{ value: "openai", label: "OpenAI" },
|
|
{ value: "anthropic", label: "Anthropic" },
|
|
];
|
|
|
|
const LLM_MODELS: Record<LLMProvider, { value: LLMModelType; label: string }[]> = {
|
|
google: [
|
|
{ value: "gemini-3-flash-preview", label: "Gemini 3 Flash" },
|
|
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
|
{ value: "gemini-3-pro-preview", label: "Gemini 3.0 Pro" },
|
|
{ value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro" },
|
|
],
|
|
openai: [
|
|
{ value: "gpt-4.1-mini", label: "GPT-4.1 Mini" },
|
|
{ value: "gpt-4.1-nano", label: "GPT-4.1 Nano" },
|
|
],
|
|
anthropic: [
|
|
{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5" },
|
|
{ value: "claude-haiku-4.5", label: "Claude Haiku 4.5" },
|
|
{ value: "claude-opus-4.6", label: "Claude Opus 4.6" },
|
|
],
|
|
};
|
|
|
|
const mapLlmToProviderType = (p: LLMProvider): ProviderType =>
|
|
p === "google" ? "gemini" : p;
|
|
|
|
const mapProviderTypeToLlm = (p: ProviderType): LLMProvider =>
|
|
p === "gemini" ? "google" : (p as LLMProvider);
|
|
|
|
interface LLMFallbackPopoverProps {
|
|
nodeId: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function LLMFallbackPopover({ nodeId, onClose }: LLMFallbackPopoverProps) {
|
|
const updateNodeData = useWorkflowStore((s) => s.updateNodeData);
|
|
const node = useWorkflowStore((s) => s.nodes.find((n) => n.id === nodeId));
|
|
const data = node?.data as LLMGenerateNodeData | undefined;
|
|
const existing = data?.fallbackModel;
|
|
|
|
const initialProvider: LLMProvider = existing
|
|
? mapProviderTypeToLlm(existing.provider)
|
|
: "anthropic";
|
|
const initialModel: LLMModelType = (existing?.modelId as LLMModelType) ||
|
|
LLM_MODELS[initialProvider][0].value;
|
|
|
|
const [provider, setProvider] = useState<LLMProvider>(initialProvider);
|
|
const [model, setModel] = useState<LLMModelType>(initialModel);
|
|
|
|
// Ensure model is valid whenever provider changes
|
|
useEffect(() => {
|
|
const valid = LLM_MODELS[provider].some((m) => m.value === model);
|
|
if (!valid) setModel(LLM_MODELS[provider][0].value);
|
|
}, [provider, model]);
|
|
|
|
const handleSave = () => {
|
|
const label = LLM_MODELS[provider].find((m) => m.value === model)?.label || model;
|
|
const fallbackModel: SelectedModel = {
|
|
provider: mapLlmToProviderType(provider),
|
|
modelId: model,
|
|
displayName: label,
|
|
};
|
|
updateNodeData(nodeId, { fallbackModel });
|
|
onClose();
|
|
};
|
|
|
|
const handleRemove = () => {
|
|
updateNodeData(nodeId, { fallbackModel: undefined, fallbackParameters: undefined });
|
|
onClose();
|
|
};
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="w-80 bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl p-4"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h2 className="text-sm font-semibold text-neutral-200 mb-3">
|
|
Select fallback LLM
|
|
</h2>
|
|
|
|
<label className="block text-xs text-neutral-400 mb-1">Provider</label>
|
|
<select
|
|
value={provider}
|
|
onChange={(e) => setProvider(e.target.value as LLMProvider)}
|
|
className="w-full mb-3 px-2 py-1.5 text-sm bg-neutral-800 border border-neutral-700 rounded text-neutral-200 focus:outline-none focus:ring-1 focus:ring-neutral-600"
|
|
>
|
|
{LLM_PROVIDERS.map((p) => (
|
|
<option key={p.value} value={p.value}>
|
|
{p.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
<label className="block text-xs text-neutral-400 mb-1">Model</label>
|
|
<select
|
|
value={model}
|
|
onChange={(e) => setModel(e.target.value as LLMModelType)}
|
|
className="w-full mb-4 px-2 py-1.5 text-sm bg-neutral-800 border border-neutral-700 rounded text-neutral-200 focus:outline-none focus:ring-1 focus:ring-neutral-600"
|
|
>
|
|
{LLM_MODELS[provider].map((m) => (
|
|
<option key={m.value} value={m.value}>
|
|
{m.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
<button
|
|
onClick={handleRemove}
|
|
className="px-2 py-1 text-xs text-red-400 hover:text-red-300 transition-colors"
|
|
>
|
|
Remove fallback
|
|
</button>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-3 py-1 text-xs text-neutral-400 hover:text-neutral-200 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
className="px-3 py-1 text-xs text-white bg-emerald-600 hover:bg-emerald-500 rounded transition-colors"
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|
|
|