Browse Source

feat(quick-16): convert LLM node to full-bleed, add max tokens, add panel shadow

- LLMGenerateNode: Remove inline provider/model/parameter controls
- LLMGenerateNode: Convert to full-bleed layout with fullBleed prop
- LLMGenerateNode: Show only text output area in node body
- LLMGenerateNode: Keep floating action buttons (copy, regenerate, clear)
- ControlPanel: Add Max Tokens slider to LLMControls (256-16384 range)
- ControlPanel: Add shadow/gradient overlay on left edge of panel
- WorkflowCanvas: Fix type errors with node.data union types (Rule 1 bug fix)
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
721d735440
  1. 18
      src/components/WorkflowCanvas.tsx
  2. 24
      src/components/nodes/ControlPanel.tsx
  3. 296
      src/components/nodes/LLMGenerateNode.tsx

18
src/components/WorkflowCanvas.tsx

@ -352,13 +352,13 @@ export function WorkflowCanvas() {
const getNodeTitle = useCallback((node: Node) => {
// For generate nodes, check for selectedModel display name
if (node.type === "nanoBanana" || node.type === "generateVideo" || node.type === "generate3d" || node.type === "generateAudio") {
const model = node.data?.selectedModel;
const model = (node.data as any)?.selectedModel;
if (model?.displayName) return model.displayName;
}
// For LLM nodes, check for selectedLLMModel or selectedModel
if (node.type === "llmGenerate") {
const model = node.data?.selectedLLMModel || node.data?.selectedModel;
const model = (node.data as any)?.selectedLLMModel || (node.data as any)?.selectedModel;
if (model?.displayName) return model.displayName;
if (model?.name) return model.name;
}
@ -368,7 +368,7 @@ export function WorkflowCanvas() {
// Helper to get title prefix (provider badge for generate/LLM nodes)
const getNodeTitlePrefix = useCallback((node: Node): React.ReactNode => {
const provider = node.data?.selectedModel?.provider;
const provider = (node.data as any)?.selectedModel?.provider;
if (provider) {
return <ProviderBadge provider={provider} />;
}
@ -400,9 +400,9 @@ export function WorkflowCanvas() {
return () => {
const node = getNodeById(nodeId);
if (!node) return;
const imageToEdit = node.data?.outputImage || node.data?.image;
const imageToEdit = (node.data as any)?.outputImage || (node.data as any)?.image;
if (!imageToEdit) return;
openAnnotationModal(nodeId, imageToEdit, node.data?.annotations);
openAnnotationModal(nodeId, imageToEdit, (node.data as any)?.annotations);
};
}
@ -2058,7 +2058,7 @@ export function WorkflowCanvas() {
<ViewportPortal>
{allNodes.map((node) => {
// Groups don't get floating headers
if (node.type === "group") return null;
if (node.type === "group" as any) return null;
const defaultWidth = defaultNodeDimensions[node.type as NodeType]?.width ?? 250;
const headerWidth = node.measured?.width || (node.style?.width as number) || defaultWidth;
@ -2129,7 +2129,7 @@ export function WorkflowCanvas() {
return createPortal(
<PromptEditorModal
isOpen={true}
initialPrompt={node.data?.prompt || ''}
initialPrompt={(node.data as any)?.prompt || ''}
onSubmit={(prompt) => {
updateNodeData(expandingNode.id, { prompt });
setExpandingNode(null);
@ -2146,7 +2146,7 @@ export function WorkflowCanvas() {
return createPortal(
<PromptEditorModal
isOpen={true}
initialPrompt={node.data?.template || ''}
initialPrompt={(node.data as any)?.template || ''}
onSubmit={(template) => {
updateNodeData(expandingNode.id, { template });
setExpandingNode(null);
@ -2163,7 +2163,7 @@ export function WorkflowCanvas() {
return (
<SplitGridSettingsModal
nodeId={expandingNode.id}
nodeData={node.data}
nodeData={node.data as any}
onClose={() => setExpandingNode(null)}
/>
);

24
src/components/nodes/ControlPanel.tsx

@ -117,6 +117,8 @@ export function ControlPanel() {
return (
<div className="fixed top-0 right-6 h-screen z-[50] flex items-center pointer-events-none">
{/* Shadow/gradient overlay on left edge */}
<div className="absolute right-full top-0 h-full w-24 bg-gradient-to-r from-transparent to-black/20 pointer-events-none" />
<div className="w-80 bg-neutral-800 border border-neutral-700 rounded-xl shadow-xl max-h-[80vh] overflow-y-auto pointer-events-auto transition-opacity duration-200 nowheel">
<div className="p-4">
{/* Header */}
@ -852,6 +854,13 @@ function LLMControls({ node }: { node: Node }) {
[node.id, updateNodeData]
);
const handleMaxTokensChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateNodeData(node.id, { maxTokens: parseInt(e.target.value, 10) });
},
[node.id, updateNodeData]
);
const provider = nodeData.provider || "google";
const availableModels = LLM_MODELS[provider] || LLM_MODELS.google;
@ -897,6 +906,21 @@ function LLMControls({ node }: { node: Node }) {
className="nodrag nopan w-full h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1">
Max Tokens: {(nodeData.maxTokens || 2048).toLocaleString()}
</label>
<input
type="range"
min="256"
max="16384"
step="256"
value={nodeData.maxTokens || 2048}
onChange={handleMaxTokensChange}
className="nodrag nopan w-full h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</div>
</div>
);
}

296
src/components/nodes/LLMGenerateNode.tsx

@ -4,31 +4,7 @@ import { useCallback, useState } from "react";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useWorkflowStore } from "@/store/workflowStore";
import { LLMGenerateNodeData, LLMProvider, LLMModelType } from "@/types";
const PROVIDERS: { value: LLMProvider; label: string }[] = [
{ value: "google", label: "Google" },
{ value: "openai", label: "OpenAI" },
{ value: "anthropic", label: "Anthropic" },
];
const 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" },
],
};
import { LLMGenerateNodeData } from "@/types";
type LLMGenerateNodeType = Node<LLMGenerateNodeData, "llmGenerate">;
@ -36,46 +12,6 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
const nodeData = data;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const handleProviderChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const newProvider = e.target.value as LLMProvider;
const firstModelForProvider = MODELS[newProvider][0].value;
const updates: Partial<LLMGenerateNodeData> = {
provider: newProvider,
model: firstModelForProvider,
};
// Anthropic limits temperature to 0-1
if (newProvider === "anthropic" && nodeData.temperature > 1) {
updates.temperature = 1;
}
updateNodeData(id, updates);
},
[id, updateNodeData, nodeData.temperature]
);
const handleModelChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
updateNodeData(id, { model: e.target.value as LLMModelType });
},
[id, updateNodeData]
);
const handleTemperatureChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateNodeData(id, { temperature: parseFloat(e.target.value) });
},
[id, updateNodeData]
);
const handleMaxTokensChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateNodeData(id, { maxTokens: parseInt(e.target.value, 10) });
},
[id, updateNodeData]
);
const [showParams, setShowParams] = useState(false);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.isRunning);
@ -101,18 +37,13 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
}
}, [nodeData.outputText]);
const provider = nodeData.provider || "google";
const availableModels = MODELS[provider] || MODELS.google;
const model = availableModels.some(m => m.value === nodeData.model)
? nodeData.model
: availableModels[0].value;
return (
<BaseNode
id={id}
selected={selected}
hasError={nodeData.status === "error"}
isExecuting={isRunning}
fullBleed
>
{/* Image input - optional */}
<Handle
@ -138,160 +69,91 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
data-handletype="text"
/>
<div className="flex-1 flex flex-col min-h-0 gap-2">
{/* Output preview area */}
<div className="nodrag nopan nowheel relative w-full flex-1 min-h-[80px] border border-dashed border-neutral-600 rounded p-2 overflow-auto">
{nodeData.status === "loading" ? (
<div className="h-full flex items-center justify-center">
<svg
className="w-4 h-4 animate-spin text-neutral-400"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
) : nodeData.status === "error" ? (
<span className="text-[10px] text-red-400">
{nodeData.error || "Failed"}
</span>
) : nodeData.outputText ? (
<>
<p className="text-[10px] text-neutral-300 whitespace-pre-wrap break-words pr-6">
{nodeData.outputText}
</p>
<div className="absolute top-1 right-1 flex gap-1">
<button
onClick={handleCopyOutput}
className={`w-5 h-5 ${copied ? "bg-green-600/80" : "bg-neutral-900/80 hover:bg-neutral-700/80"} rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors`}
title={copied ? "Copied!" : "Copy to clipboard"}
>
{copied ? (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
<button
onClick={handleRegenerate}
disabled={isRunning}
className="w-5 h-5 bg-neutral-900/80 hover:bg-blue-600/80 disabled:opacity-50 disabled:cursor-not-allowed rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Regenerate"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
onClick={handleClearOutput}
className="w-5 h-5 bg-neutral-900/80 hover:bg-red-600/80 rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Clear output"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</>
) : (
<div className="h-full flex items-center justify-center">
<span className="text-neutral-500 text-[10px]">
Run to generate
</span>
</div>
)}
</div>
{/* Provider selector */}
<select
value={provider}
onChange={handleProviderChange}
className="w-full text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 shrink-0"
>
{PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
{/* Model selector */}
<select
value={model}
onChange={handleModelChange}
className="w-full text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 shrink-0"
>
{availableModels.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
{/* Collapsible parameters section */}
<div className="shrink-0">
<button
onClick={() => setShowParams(!showParams)}
className="w-full flex items-center justify-between text-[9px] text-neutral-400 hover:text-neutral-300 py-1"
>
<span>Parameters</span>
<div className="relative w-full h-full min-h-0 overflow-hidden rounded-lg">
{nodeData.status === "loading" ? (
<div className="w-full h-full bg-neutral-900/40 flex items-center justify-center">
<svg
className="w-4 h-4 animate-spin text-neutral-400"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
) : nodeData.status === "error" ? (
<div className="w-full h-full bg-red-900/40 flex flex-col items-center justify-center gap-1">
<svg
className={`w-3 h-3 transition-transform ${showParams ? "rotate-180" : ""}`}
className="w-6 h-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
{showParams && (
<div className="flex flex-col gap-2 pt-1 border-t border-neutral-700/50">
{/* Temperature slider */}
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-neutral-500">Temperature: {nodeData.temperature.toFixed(1)}</label>
<input
type="range"
min="0"
max={provider === "anthropic" ? "1" : "2"}
step="0.1"
value={nodeData.temperature}
onChange={handleTemperatureChange}
className="nodrag w-full h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-neutral-400"
/>
</div>
{/* Max tokens slider */}
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-neutral-500">Max Tokens: {nodeData.maxTokens.toLocaleString()}</label>
<input
type="range"
min="256"
max="16384"
step="256"
value={nodeData.maxTokens}
onChange={handleMaxTokensChange}
className="nodrag w-full h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-neutral-400"
/>
</div>
<span className="text-white text-xs font-medium">Generation failed</span>
</div>
) : nodeData.outputText ? (
<div className="relative w-full h-full bg-neutral-900/40 p-2 overflow-auto nodrag nopan nowheel">
<p className="text-[10px] text-neutral-300 whitespace-pre-wrap break-words pr-6">
{nodeData.outputText}
</p>
<div className="absolute top-1 right-1 flex gap-1">
<button
onClick={handleCopyOutput}
className={`w-5 h-5 ${copied ? "bg-green-600/80" : "bg-neutral-900/80 hover:bg-neutral-700/80"} rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors`}
title={copied ? "Copied!" : "Copy to clipboard"}
>
{copied ? (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
<button
onClick={handleRegenerate}
disabled={isRunning}
className="w-5 h-5 bg-neutral-900/80 hover:bg-blue-600/80 disabled:opacity-50 disabled:cursor-not-allowed rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Regenerate"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
onClick={handleClearOutput}
className="w-5 h-5 bg-neutral-900/80 hover:bg-red-600/80 rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Clear output"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
</div>
</div>
) : (
<div className="w-full h-full bg-neutral-900/40 flex items-center justify-center">
<span className="text-neutral-500 text-[10px]">
Run to generate
</span>
</div>
)}
</div>
</BaseNode>
);

Loading…
Cancel
Save