hadinicknam's picture
replace the logo , Add an option for HF_TOKEN
fe90915
import React, { useState, useCallback, useMemo, useContext, createContext } from 'react';
import api from './api'; // Import the centralized api
import ReactFlow, {
MiniMap,
Controls,
Background,
useNodesState,
useEdgesState,
addEdge,
} from 'reactflow';
import CustomNode from './CustomNode';
import InputNode from './InputNode';
import OutputNode from './OutputNode';
import RunNode from './RunNode';
import LogPanel from './LogPanel';
import { LogContext } from './LogContext';
import { HuggingfaceTokenProvider } from './HuggingfaceTokenContext';
import TokenInput from './TokenInput';
import 'reactflow/dist/style.css';
import './CustomNode.css';
import './InputNode.css';
import './OutputNode.css';
import './RunNode.css';
import './LogPanel.css';
import './TokenInput.css';
export const WorkflowContext = createContext();
const initialNodes = [];
const initialEdges = [];
let nodeIdCounter = 0;
function App() {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [isRunning, setIsRunning] = useState(false);
const { addLog } = useContext(LogContext);
const nodeTypes = useMemo(() => ({
custom: CustomNode,
inputNode: InputNode,
outputNode: OutputNode,
runNode: RunNode,
}), []);
const onConnect = useCallback(
(params) => {
const { source, sourceHandle, target, targetHandle } = params;
const sourceNode = nodes.find(n => n.id === source);
const targetNode = nodes.find(n => n.id === target);
addLog(`Attempting to connect ${sourceNode.type} ('${source}') to ${targetNode.type} ('${target}')`);
if (sourceNode.type === 'runNode') {
const newEdge = { ...params, animated: false, type: 'step', style: { stroke: '#28a745', strokeWidth: 2 } };
setEdges((eds) => addEdge(newEdge, eds));
addLog(`Connected Run trigger to ${targetNode.data.label || targetNode.id}`, 'SUCCESS');
return;
}
if (targetNode.type === 'outputNode') {
const sourceCompId = sourceHandle.replace('output-', '');
const sourceOutput = sourceNode.data.outputs.find(o => String(o.id) === sourceCompId);
if (sourceOutput) {
// Get selected JSON path if this is a JSON output
const isJsonOutput = sourceOutput.type.toLowerCase() === 'json';
const jsonPath = isJsonOutput ? sourceNode.data.jsonPaths?.[sourceCompId] : null;
const newEdge = {
...params,
animated: true,
type: 'smoothstep',
data: isJsonOutput ? { jsonPath } : undefined
};
setEdges((eds) => addEdge(newEdge, eds));
setNodes((nds) =>
nds.map((node) =>
node.id === target
? {
...node,
data: {
...node.data,
connectedType: sourceOutput.type,
label: `Result: ${sourceOutput.label}${jsonPath ? ` (${jsonPath})` : ''}`
}
}
: node
)
);
const logMsg = isJsonOutput && jsonPath
? `Connected ${sourceNode.data.label}'s output to Output Node using JSON path: ${jsonPath}`
: `Connected ${sourceNode.data.label}'s output to Output Node.`;
addLog(logMsg, 'SUCCESS');
}
return;
}
if (sourceNode.type === 'outputNode' && targetNode.type === 'custom') {
// Mark this OutputNode as being used as a source
setNodes((nds) =>
nds.map((node) =>
node.id === source
? { ...node, data: { ...node.data, isSource: true } }
: node
)
);
const targetCompId = targetHandle.replace('input-', '');
const targetInput = targetNode.data.inputs.find(i => String(i.id) === targetCompId);
const sourceOutputType = sourceNode.data.connectedType;
// Enhanced connection validation - allow image to connect to text inputs
const isTypeMatch = sourceOutputType === targetInput.type;
const isImageToTextPrompt =
sourceOutputType === 'image' &&
['textbox', 'string', 'text'].includes(targetInput.type.toLowerCase()) &&
(targetInput.name === 'prompt' || targetInput.label.toLowerCase().includes('prompt'));
if (targetInput && sourceOutputType && (isTypeMatch || isImageToTextPrompt)) {
const newEdge = {
...params,
animated: true,
type: 'smoothstep',
data: {
sourceType: sourceOutputType, // Track the original source type
conversionType: isImageToTextPrompt ? 'imageToText' : null
}
};
setEdges((eds) => addEdge(newEdge, eds));
setNodes((nds) =>
nds.map((node) => {
if (node.id === target) {
const newConnections = { ...node.data.connections, targets: [...(node.data.connections?.targets || []), targetCompId] };
return { ...node, data: { ...node.data, connections: newConnections } };
}
return node;
})
);
if (isImageToTextPrompt) {
addLog(`Connected Output Node with image to ${targetNode.data.label}'s '${targetInput.label}' input. The image URL will be used as prompt.`, 'INFO');
} else {
addLog(`Connected Output Node to ${targetNode.data.label}'s input '${targetInput.label}'.`, 'SUCCESS');
}
} else {
addLog(`Connection failed: Incompatible types between Output (${sourceOutputType}) and Input (${targetInput?.type}).`, 'WARN');
}
return;
}
if (sourceNode.type === 'custom' && targetNode.type === 'custom') {
const sourceCompId = sourceHandle.replace('output-', '');
const targetCompId = targetHandle.replace('input-', '');
const sourceOutput = sourceNode.data.outputs.find(o => String(o.id) === sourceCompId);
const targetInput = targetNode.data.inputs.find(i => String(i.id) === targetCompId);
if (sourceOutput && targetInput && sourceOutput.type === targetInput.type) {
// Get selected JSON path if this is a JSON output
const isJsonOutput = sourceOutput.type.toLowerCase() === 'json';
const jsonPath = isJsonOutput ? sourceNode.data.jsonPaths?.[sourceCompId] : null;
const newEdge = {
...params,
animated: true,
type: 'smoothstep',
data: isJsonOutput ? { jsonPath } : undefined
};
setEdges((eds) => addEdge(newEdge, eds));
setNodes((nds) =>
nds.map((node) => {
if (node.id === target) {
const newConnections = { ...node.data.connections, targets: [...(node.data.connections?.targets || []), targetCompId] };
return { ...node, data: { ...node.data, connections: newConnections } };
}
return node;
})
);
const logMsg = isJsonOutput && jsonPath
? `Connected ${sourceNode.data.label} to ${targetNode.data.label} using JSON path: ${jsonPath}`
: `Connected ${sourceNode.data.label} to ${targetNode.data.label}.`;
addLog(logMsg, 'SUCCESS');
} else {
addLog(`Connection failed: Incompatible types between ${sourceOutput?.type} and ${targetInput?.type}.`, 'WARN');
}
}
},
[nodes, setEdges, setNodes, addLog],
);
// Helper to check if an output node is used as a source
const isOutputNodeUsedAsSource = useCallback((nodeId) => {
return edges.some(edge => edge.source === nodeId &&
nodes.find(n => n.id === nodeId)?.type === 'outputNode');
}, [edges, nodes]);
// Helper to extract image URL from various formats
const extractImageUrl = (data) => {
if (typeof data === 'string') {
return data;
} else if (Array.isArray(data) && typeof data[0] === 'string') {
return data[0];
} else if (typeof data === 'object' && data !== null) {
return data.url || data.path || data.src || '';
}
return '';
};
const addNode = (type) => {
const newId = `node-${nodeIdCounter++}`;
let newNode;
if (type === 'input') {
newNode = { id: newId, type: 'inputNode', position: { x: Math.random() * 200, y: Math.random() * 200 }, data: { onLoad: getSpaceDetails } };
addLog("Added new Space Node. Please provide a Hugging Face Space ID.", 'INFO');
} else if (type === 'output') {
newNode = { id: newId, type: 'outputNode', position: { x: Math.random() * 200 + 500, y: Math.random() * 200 }, data: { label: 'Output' } };
addLog("Added new Output Node.", 'INFO');
} else {
newNode = { id: newId, type: 'runNode', position: { x: 50, y: Math.random() * 200 }, data: {} }; // No more onRun prop
addLog("Added new Run Node. Connect it to start your workflow.", 'INFO');
}
setNodes((nds) => nds.concat(newNode));
};
const handleEndpointChange = useCallback((nodeId, newApiName) => {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
const newEndpoint = node.data.endpoints.find(e => e.api_name === newApiName);
if (newEndpoint) {
addLog(`[${node.data.label}] Changed main API to: ${newApiName}`, 'INFO');
const newData = {
...node.data,
apiName: newEndpoint.api_name,
inputs: newEndpoint.inputs,
outputs: newEndpoint.outputs,
connections: { targets: [] },
status: null,
result: null,
jsonPaths: {}, // Reset JSON path selections on endpoint change
};
return { ...node, data: newData };
}
}
return node;
})
);
setEdges((eds) => eds.filter(edge => edge.target !== nodeId));
}, [addLog, setNodes, setEdges]);
// Handle JSON path selection changes
const handleOutputPathChange = useCallback((nodeId, outputId, path) => {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
// Store the selected path in the node data
const jsonPaths = { ...(node.data.jsonPaths || {}), [outputId]: path };
addLog(`[${node.data.label}] Set JSON path for output ${outputId} to: ${path}`, 'INFO');
// Update the edges that use this output
setEdges((eds) =>
eds.map(edge => {
if (edge.source === nodeId && edge.sourceHandle === `output-${outputId}`) {
return { ...edge, data: { ...edge.data, jsonPath: path } };
}
return edge;
})
);
return { ...node, data: { ...node.data, jsonPaths } };
}
return node;
})
);
}, [addLog, setNodes, setEdges]);
const getSpaceDetails = async (nodeIdToReplace, spaceId) => {
addLog(`Fetching details for Space ID: ${spaceId}...`);
setNodes(nds => nds.map(n => n.id === nodeIdToReplace ? { ...n, data: { ...n.data, error: null } } : n));
try {
const response = await api.get(`/space/?space_id=${spaceId}`);
const { endpoints } = response.data;
const preferredEndpoint = endpoints.find(e => e.is_preferred) || endpoints[0];
if (preferredEndpoint) {
const originalNode = nodes.find(n => n.id === nodeIdToReplace);
const loadedNode = {
id: nodeIdToReplace,
type: 'custom',
position: originalNode?.position || { x: 100, y: 100 },
data: {
label: spaceId,
apiName: preferredEndpoint.api_name,
inputs: preferredEndpoint.inputs,
outputs: preferredEndpoint.outputs,
connections: { targets: [] },
endpoints: endpoints,
onEndpointChange: handleEndpointChange,
onOutputPathChange: handleOutputPathChange, // Add the new callback
jsonPaths: {}, // Initialize empty JSON paths
},
};
setNodes((nds) => nds.map((node) => (node.id === nodeIdToReplace ? loadedNode : node)));
addLog(`Successfully loaded Space: ${spaceId}. Main API: ${preferredEndpoint.api_name}`, 'SUCCESS');
} else {
throw new Error("No usable API endpoints found for this space.");
}
} catch (err) {
const errorMessage = err.response?.data?.error || err.message || 'Failed to fetch space details.';
addLog(`Failed to load Space: ${errorMessage}`, 'ERROR');
setNodes(nds => nds.map(n => n.id === nodeIdToReplace ? { ...n, data: { ...n.data, error: errorMessage, onLoad: getSpaceDetails } } : n));
}
};
// Find all nodes reachable from a starting node (for isolated workflow execution)
const findReachableNodes = useCallback((startNodeId) => {
const reachableNodes = new Set([startNodeId]);
const nodesToProcess = [startNodeId];
while (nodesToProcess.length > 0) {
const currentNodeId = nodesToProcess.shift();
// Find all edges where the current node is the source
edges.forEach(edge => {
if (edge.source === currentNodeId && !reachableNodes.has(edge.target)) {
reachableNodes.add(edge.target);
nodesToProcess.push(edge.target);
}
});
}
return Array.from(reachableNodes);
}, [edges]);
const resetNodeStatuses = () => {
setNodes(nds => nds.map(n => {
// If this is an OutputNode being used as a source, preserve its result
if (n.type === 'outputNode' && isOutputNodeUsedAsSource(n.id)) {
// Preserve the result but clear status and error
const { status, error, ...restData } = n.data;
return { ...n, data: { ...restData, isSource: true } };
}
// For all other nodes, clear status, result and error
if (n.data && ('status' in n.data || 'result' in n.data || 'error' in n.data)) {
const { status, result, error, ...restData } = n.data;
return { ...n, data: restData };
}
return n;
}));
};
const pollForResult = async (nodeId, jobId, executionResults, nodesMap) => {
const node = nodesMap.get(nodeId);
addLog(`[${node.data.label}] Polling for result (Job ID: ${jobId})...`);
const processLogs = (logs) => {
if (logs && logs.length > 0) {
logs.forEach(logMsg => {
addLog(`[BACKEND] ${logMsg}`, 'INFO');
});
}
};
const poll = async () => {
try {
const response = await api.get(`/result/${jobId}`);
// Always process logs, whether the job is running or complete
processLogs(response.data.logs);
if (response.data.status === 'processing') {
setTimeout(poll, 2000); // Continue polling
} else if (response.data.status === 'completed') {
addLog(`[${node.data.label}] Job completed successfully.`, 'SUCCESS');
executionResults.set(nodeId, response.data.result);
setNodes(nds => nds.map(n => n.id === nodeId ? { ...n, data: { ...n.data, status: 'success', result: response.data.result } } : n));
await processNextNodes(nodeId, executionResults, nodesMap);
} else if (response.data.status === 'error' || response.data.status === 'failed' || response.data.status === 'cancelled') {
throw new Error(response.data.error || `Job ended with status: ${response.data.status}`);
}
} catch (e) {
addLog(`[${node.data.label}] Polling failed: ${e.message}`, 'ERROR');
setNodes(nds => nds.map(n => n.id === nodeId ? { ...n, data: { ...n.data, status: 'error', error: e.message } } : n));
}
};
poll();
};
// Helper function to get a value from a JSON object using a path string
const getValueByJsonPath = (obj, path) => {
if (!path || path === '(entire object)') return obj;
return path.split('.').reduce((prev, curr) => {
return prev && prev[curr] !== undefined ? prev[curr] : undefined;
}, obj);
};
const processNextNodes = async (currentNodeId, executionResults, nodesMap) => {
const adj = new Map(nodes.map(n => [n.id, []]));
edges.forEach(edge => {
if (!adj.has(edge.source)) adj.set(edge.source, []);
adj.get(edge.source).push({ nodeId: edge.target, edge });
});
const nextConnections = adj.get(currentNodeId) || [];
addLog(`Processing next nodes for '${nodesMap.get(currentNodeId).data.label || currentNodeId}': ${nextConnections.length} found.`);
for (const { nodeId, edge } of nextConnections) {
await executeNode(nodesMap.get(nodeId), executionResults, nodesMap, edge);
}
};
const executeNode = async (node, executionResults, nodesMap, incomingEdge = null) => {
if (!node || executionResults.has(node.id)) return;
addLog(`Executing node: ${node.data.label || node.type} (ID: ${node.id})`);
setNodes(nds => nds.map(n => n.id === node.id ? { ...n, data: { ...n.data, status: 'running' } } : n));
try {
if (node.type === 'runNode') {
setNodes(nds => nds.map(n => n.id === node.id ? { ...n, data: { ...n.data, status: 'success' } } : n));
await processNextNodes(node.id, executionResults, nodesMap);
} else if (node.type === 'inputNode') {
addLog(`Node '${node.id}' is an unconfigured Space Node. Please load a Space ID to use it.`, 'WARN');
setNodes(nds => nds.map(n => n.id === node.id ? { ...n, data: { ...n.data, status: 'error', error: 'Unconfigured Node' } } : n));
return;
} else if (node.type === 'custom') {
const apiInputs = await Promise.all(node.data.inputs.map(async (input) => {
const incomingEdge = edges.find(e => e.target === node.id && e.targetHandle === `input-${input.id}`);
if (incomingEdge) {
const sourceNode = nodesMap.get(incomingEdge.source);
// Special handling for OutputNode sources - get the result directly from the node data or executionResults
if (sourceNode && sourceNode.type === 'outputNode') {
// Get either the stored result or the latest execution result
const sourceResult = sourceNode.data.result || executionResults.get(incomingEdge.source);
if (sourceResult !== undefined) {
const sourceOutputType = sourceNode.data.connectedType;
addLog(`[${node.data.label}] Input '${input.label}' from Output Node (type: ${sourceOutputType})`, 'INFO');
// Check if we need to convert an image to text (for prompt inputs)
const isImageToTextConversion =
incomingEdge.data?.conversionType === 'imageToTextPrompt' ||
(sourceOutputType === 'image' &&
['textbox', 'string', 'text'].includes(input.type.toLowerCase()) &&
(input.name === 'prompt' || input.label.toLowerCase().includes('prompt')));
if (isImageToTextConversion) {
// Extract the image URL and use it as the prompt text
const imageUrl = extractImageUrl(sourceResult);
addLog(`[${node.data.label}] Converting image to URL for prompt input: ${imageUrl}`, 'INFO');
return imageUrl;
}
return sourceResult;
}
} else if (sourceNode && sourceNode.type === 'custom') {
const sourceResult = executionResults.get(incomingEdge.source);
if (sourceResult !== undefined) {
const outputId = incomingEdge.sourceHandle.replace('output-', '');
let outputValue = Array.isArray(sourceResult)
? sourceResult[sourceNode.data.outputs.findIndex(o => String(o.id) === outputId)]
: sourceResult;
// Check if this is a JSON type with a path selection
const output = sourceNode.data.outputs.find(o => String(o.id) === outputId);
const isJsonOutput = output && output.type.toLowerCase() === 'json';
const jsonPath = incomingEdge.data?.jsonPath;
if (isJsonOutput && jsonPath && typeof outputValue === 'object') {
outputValue = getValueByJsonPath(outputValue, jsonPath);
addLog(`[${node.data.label}] Using JSON path '${jsonPath}' to extract from input '${input.label}'`);
}
addLog(`[${node.data.label}] Input '${input.label}' from Custom Node, using value: ${JSON.stringify(outputValue)}`, 'INFO');
// If the value is a file path from an output node, we need to re-upload it for the next node.
if (typeof outputValue === 'string' && (outputValue.startsWith('http') || outputValue.startsWith('/media'))) {
try {
addLog(`[${node.data.label}] Detected output file URL. Fetching and re-uploading for next stage...`);
const response = await fetch(outputValue);
const blob = await response.blob();
const fileName = outputValue.substring(outputValue.lastIndexOf('/') + 1);
const file = new File([blob], fileName);
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await api.post('/upload/', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
addLog(`[${node.data.label}] File re-uploaded. New path: ${uploadResponse.data.path}`);
return uploadResponse.data.path;
} catch (e) {
addLog(`[${node.data.label}] Failed to re-upload file: ${e.message}`, 'ERROR');
return null; // Skip this input if re-upload fails
}
}
return outputValue;
}
}
}
// If no connection or the connection doesn't have a value, use the form value
const controlId = `node-${node.id}-input-${input.id}`;
const element = document.getElementById(controlId);
if (!element) {
addLog(`[${node.data.label}] Input '${input.label}' has no form element. Skipping.`, 'WARN');
return null;
}
const value = element.type === 'checkbox' ? element.checked : element.value;
addLog(`[${node.data.label}] Input '${input.label}' from form, using value: ${value}`, 'INFO');
return value;
}));
const filteredInputs = apiInputs.filter(input => input !== null);
// Check for required fields before sending the request
const missingRequiredInputs = node.data.inputs
.filter(input => input.required && !filteredInputs.some((_, index) => index === node.data.inputs.indexOf(input)))
.map(input => input.name || input.label);
if (missingRequiredInputs.length > 0) {
throw new Error(`Missing required inputs: ${missingRequiredInputs.join(', ')}`);
}
addLog(`[${node.data.label}] Sending request to /predict/ with inputs: ${JSON.stringify(filteredInputs)}`);
const response = await api.post('/predict/', {
space_id: node.data.label,
api_name: node.data.apiName,
inputs: filteredInputs,
});
if (response.data.error) throw new Error(response.data.error);
if (response.data.job_id) {
setNodes(nds => nds.map(n => n.id === node.id ? { ...n, data: { ...n.data, status: 'polling' } } : n));
await pollForResult(node.id, response.data.job_id, executionResults, nodesMap);
} else {
addLog(`[${node.data.label}] Request successful, no job ID returned.`, 'SUCCESS');
executionResults.set(node.id, response.data.result);
setNodes(nds => nds.map(n => n.id === node.id ? { ...n, data: { ...n.data, status: 'success', result: response.data.result } } : n));
await processNextNodes(node.id, executionResults, nodesMap);
}
} else if (node.type === 'outputNode') {
// If this OutputNode is being used as a source and already has content,
// we should preserve its content and just add it to execution results
if (node.data.isSource && node.data.result !== undefined && node.data.result !== null) {
addLog(`[OutputNode: ${node.data.label}] Using existing content as source for downstream nodes`, 'INFO');
// Add this node's result to executionResults so downstream nodes can use it
executionResults.set(node.id, node.data.result);
// Mark it as success without changing its content
setNodes(nds => nds.map(n => n.id === node.id ? { ...n, data: { ...n.data, status: 'success' } } : n));
} else {
// Original logic for displaying output from an incoming connection
const incomingEdge = edges.find(e => e.target === node.id);
if (incomingEdge) {
const sourceNode = nodesMap.get(incomingEdge.source);
if (sourceNode && sourceNode.type === 'custom') {
const outputId = incomingEdge.sourceHandle.replace('output-', '');
const sourceResult = executionResults.get(incomingEdge.source);
let displayResult = Array.isArray(sourceResult)
? sourceResult[sourceNode.data.outputs.findIndex(o => `output-${o.id}` === incomingEdge.sourceHandle)]
: sourceResult;
// Check if this is a JSON type with a path selection
const output = sourceNode.data.outputs.find(o => String(o.id) === outputId);
const isJsonOutput = output && output.type.toLowerCase() === 'json';
const jsonPath = incomingEdge.data?.jsonPath;
if (isJsonOutput && jsonPath && typeof displayResult === 'object') {
displayResult = getValueByJsonPath(displayResult, jsonPath);
addLog(`[OutputNode] Using JSON path '${jsonPath}' to extract result`, 'INFO');
}
addLog(`[OutputNode] Displaying result: ${JSON.stringify(displayResult)}`, 'SUCCESS');
// Store the result in node data and execution results
setNodes(nds => nds.map(n => n.id === node.id ? { ...n, data: { ...n.data, status: 'success', result: displayResult } } : n));
executionResults.set(node.id, displayResult);
}
}
}
// Always process next nodes regardless of whether we're displaying or forwarding content
await processNextNodes(node.id, executionResults, nodesMap);
}
} catch (e) {
addLog(`Execution failed at node ${node.data.label || node.id}: ${e.message}`, 'ERROR');
setNodes(nds => nds.map(n => n.id === node.id ? { ...n, data: { ...n.data, status: 'error', error: e.message } } : n));
}
};
// Function to run workflow from a specific run node
const handleRunFromNode = async (startNodeId) => {
if (isRunning) {
addLog("A workflow is already running. Please wait for it to complete.", 'WARN');
return;
}
setIsRunning(true);
try {
addLog(`================== WORKFLOW RUNNING FROM NODE ${startNodeId} ==================`, 'INFO');
resetNodeStatuses();
// Get only the nodes reachable from this specific Run Node
const reachableNodeIds = findReachableNodes(startNodeId);
addLog(`This workflow includes ${reachableNodeIds.length} node(s)`, 'INFO');
// Map of all nodes for easier access
const nodesMap = new Map(nodes.map(n => [n.id, n]));
// Track execution results
const executionResults = new Map();
// Start execution from the run node
await executeNode(nodesMap.get(startNodeId), executionResults, nodesMap);
addLog(`================== WORKFLOW COMPLETED ==================`, 'SUCCESS');
} catch (error) {
addLog(`Workflow execution failed: ${error.message}`, 'ERROR');
} finally {
setIsRunning(false);
}
};
// For backward compatibility or general run
const handleRun = async () => {
// Find all run nodes
const runNodes = nodes.filter(node => node.type === 'runNode');
if (runNodes.length > 0) {
// Run from the first run node found (for backward compatibility)
await handleRunFromNode(runNodes[0].id);
} else {
addLog("No Run Nodes found in the workflow. Please add a Run Node.", 'WARN');
}
};
return (
<HuggingfaceTokenProvider>
<div style={{ height: '100vh', width: '100vw', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '10px', borderBottom: '1px solid #eee', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
<div>
<button onClick={() => addNode('run')}>+ Add Run Node</button>
<button onClick={() => addNode('input')}>+ Add Space Node</button>
<button onClick={() => addNode('output')}>+ Add Output Node</button>
</div>
<TokenInput />
</div>
<div style={{ flexGrow: 1, position: 'relative' }}>
<WorkflowContext.Provider value={{ handleRun, handleRunFromNode, isRunning }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
>
<Controls />
<MiniMap />
<Background variant="dots" gap={12} size={1} />
</ReactFlow>
</WorkflowContext.Provider>
</div>
<LogPanel />
</div>
</HuggingfaceTokenProvider>
);
}
export default App;