diff --git a/chainforge/react-server/src/App.tsx b/chainforge/react-server/src/App.tsx index 2cbcbee..e46e8c4 100644 --- a/chainforge/react-server/src/App.tsx +++ b/chainforge/react-server/src/App.tsx @@ -4,6 +4,7 @@ import React, { useRef, useEffect, useContext, + useMemo, } from "react"; import ReactFlow, { Controls, Background, ReactFlowInstance } from "reactflow"; import { @@ -196,6 +197,15 @@ const getSharedFlowURLParam = () => { return undefined; }; +const getWindowSize = () => ({ + width: window.innerWidth, + height: window.innerHeight, +}); +const getWindowCenter = () => { + const { width, height } = getWindowSize(); + return { centerX: width / 2.0, centerY: height / 2.0 }; +}; + const MenuTooltip = ({ label, children, @@ -277,71 +287,35 @@ const App = () => { const [isLoading, setIsLoading] = useState(true); // Helper - const getWindowSize = () => ({ - width: window.innerWidth, - height: window.innerHeight, - }); - const getWindowCenter = () => { - const { width, height } = getWindowSize(); - return { centerX: width / 2.0, centerY: height / 2.0 }; - }; - const getViewportCenter = () => { + const getViewportCenter = useCallback(() => { const { centerX, centerY } = getWindowCenter(); if (rfInstance === null) return { x: centerX, y: centerY }; // Support Zoom const { x, y, zoom } = rfInstance.getViewport(); return { x: -(x / zoom) + centerX / zoom, y: -(y / zoom) + centerY / zoom }; - }; + }, [rfInstance]); - const addNode = ( - id: string, - type?: string, - data?: Dict, - offsetX?: number, - offsetY?: number, - ) => { - const { x, y } = getViewportCenter(); - addNodeToStore({ - id: `${id}-` + Date.now(), - type: type ?? id, - data: data ?? {}, - position: { - x: x - 200 + (offsetX || 0), - y: y - 100 + (offsetY || 0), - }, - }); - }; - - const addTextFieldsNode = () => addNode("textFieldsNode", "textfields"); - const addPromptNode = () => addNode("promptNode", "prompt", { prompt: "" }); - const addChatTurnNode = () => addNode("chatTurn", "chat", { prompt: "" }); - const addSimpleEvalNode = () => addNode("simpleEval", "simpleval"); - const addEvalNode = (progLang: string) => { - let code = ""; - if (progLang === "python") - code = "def evaluate(response):\n return len(response.text)"; - else if (progLang === "javascript") - code = "function evaluate(response) {\n return response.text.length;\n}"; - addNode("evalNode", "evaluator", { language: progLang, code }); - }; - const addVisNode = () => addNode("visNode", "vis", {}); - const addInspectNode = () => addNode("inspectNode", "inspect"); - const addScriptNode = () => addNode("scriptNode", "script"); - const addItemsNode = () => addNode("csvNode", "csv"); - const addTabularDataNode = () => addNode("table"); - const addCommentNode = () => addNode("comment"); - const addLLMEvalNode = () => addNode("llmeval"); - const addMultiEvalNode = () => addNode("multieval"); - const addJoinNode = () => addNode("join"); - const addSplitNode = () => addNode("split"); - const addProcessorNode = (progLang: string) => { - let code = ""; - if (progLang === "python") - code = "def process(response):\n return response.text;"; - else if (progLang === "javascript") - code = "function process(response) {\n return response.text;\n}"; - addNode("process", "processor", { language: progLang, code }); - }; + const addNode = useCallback( + ( + id: string, + type?: string, + data?: Dict, + offsetX?: number, + offsetY?: number, + ) => { + const { x, y } = getViewportCenter(); + addNodeToStore({ + id: `${id}-` + Date.now(), + type: type ?? id, + data: data ?? {}, + position: { + x: x - 200 + (offsetX || 0), + y: y - 100 + (offsetY || 0), + }, + }); + }, + [addNodeToStore], + ); const onClickExamples = () => { if (examplesModal && examplesModal.current) examplesModal.current.trigger(); @@ -350,13 +324,16 @@ const App = () => { if (settingsModal && settingsModal.current) settingsModal.current.trigger(); }; - const handleError = (err: Error | string) => { - const msg = typeof err === "string" ? err : err.message; - setIsLoading(false); - setWaitingForShare(false); - if (showAlert) showAlert(msg); - console.error(msg); - }; + const handleError = useCallback( + (err: Error | string) => { + const msg = typeof err === "string" ? err : err.message; + setIsLoading(false); + setWaitingForShare(false); + if (showAlert) showAlert(msg); + console.error(msg); + }, + [showAlert], + ); /** * SAVING / LOADING, IMPORT / EXPORT (from JSON) @@ -405,6 +382,42 @@ const App = () => { [rfInstance], ); + // Initialize auto-saving + const initAutosaving = useCallback( + (rf_inst: ReactFlowInstance) => { + if (autosavingInterval !== undefined) return; // autosaving interval already set + console.log("Init autosaving"); + + // Autosave the flow to localStorage every minute: + const interv = setInterval(() => { + // Check the visibility of the browser tab --if it's not visible, don't autosave + if (!browserTabIsActive()) return; + + // Start a timer, in case the saving takes a long time + const startTime = Date.now(); + + // Save the flow to localStorage + saveFlow(rf_inst); + + // Check how long the save took + const duration = Date.now() - startTime; + if (duration > 1500) { + // If the operation took longer than 1.5 seconds, that's not good. + // Although this function is called async inside setInterval, + // calls to localStorage block the UI in JavaScript, freezing the screen. + // We smart-disable autosaving here when we detect it's starting the freeze the UI: + console.warn( + "Autosaving disabled. The time required to save to localStorage exceeds 1 second. This can happen when there's a lot of data in your flow. Make sure to export frequently to save your work.", + ); + clearInterval(interv); + setAutosavingInterval(undefined); + } + }, 60000); // 60000 milliseconds = 1 minute + setAutosavingInterval(interv); + }, + [autosavingInterval, saveFlow], + ); + // Triggered when user confirms 'New Flow' button const resetFlow = useCallback(() => { resetLLMColors(); @@ -436,58 +449,64 @@ const App = () => { if (rfInstance) rfInstance.setViewport({ x: 200, y: 80, zoom: 1 }); }, [setNodes, setEdges, resetLLMColors, rfInstance]); - const loadFlow = async (flow?: Dict, rf_inst?: ReactFlowInstance | null) => { - if (flow === undefined) return; - if (rf_inst) { - if (flow.viewport) - rf_inst.setViewport({ - x: flow.viewport.x || 0, - y: flow.viewport.y || 0, - zoom: flow.viewport.zoom || 1, - }); - else rf_inst.setViewport({ x: 0, y: 0, zoom: 1 }); - } - resetLLMColors(); + const loadFlow = useCallback( + async (flow?: Dict, rf_inst?: ReactFlowInstance | null) => { + if (flow === undefined) return; + if (rf_inst) { + if (flow.viewport) + rf_inst.setViewport({ + x: flow.viewport.x || 0, + y: flow.viewport.y || 0, + zoom: flow.viewport.zoom || 1, + }); + else rf_inst.setViewport({ x: 0, y: 0, zoom: 1 }); + } + resetLLMColors(); - // First, clear the ReactFlow state entirely - // NOTE: We need to do this so it forgets any node/edge ids, which might have cross-over in the loaded flow. - setNodes([]); - setEdges([]); + // First, clear the ReactFlow state entirely + // NOTE: We need to do this so it forgets any node/edge ids, which might have cross-over in the loaded flow. + setNodes([]); + setEdges([]); - // After a delay, load in the new state. - setTimeout(() => { - setNodes(flow.nodes || []); - setEdges(flow.edges || []); + // After a delay, load in the new state. + setTimeout(() => { + setNodes(flow.nodes || []); + setEdges(flow.edges || []); - // Save flow that user loaded to autosave cache, in case they refresh the browser - StorageCache.saveToLocalStorage("chainforge-flow", flow); + // Save flow that user loaded to autosave cache, in case they refresh the browser + StorageCache.saveToLocalStorage("chainforge-flow", flow); - // Cancel loading spinner - setIsLoading(false); - }, 10); + // Cancel loading spinner + setIsLoading(false); + }, 10); - // Start auto-saving, if it's not already enabled - if (rf_inst) initAutosaving(rf_inst); - }; + // Start auto-saving, if it's not already enabled + if (rf_inst) initAutosaving(rf_inst); + }, + [resetLLMColors, setNodes, setEdges, initAutosaving], + ); const importGlobalStateFromCache = useCallback(() => { importState(StorageCache.getAllMatching((key) => key.startsWith("r."))); }, [importState]); - const autosavedFlowExists = () => { + const autosavedFlowExists = useCallback(() => { return window.localStorage.getItem("chainforge-flow") !== null; - }; - const loadFlowFromAutosave = async (rf_inst: ReactFlowInstance) => { - const saved_flow = StorageCache.loadFromLocalStorage( - "chainforge-flow", - false, - ) as Dict; - if (saved_flow) { - StorageCache.loadFromLocalStorage("chainforge-state", true); - importGlobalStateFromCache(); - loadFlow(saved_flow, rf_inst); - } - }; + }, []); + const loadFlowFromAutosave = useCallback( + async (rf_inst: ReactFlowInstance) => { + const saved_flow = StorageCache.loadFromLocalStorage( + "chainforge-flow", + false, + ) as Dict; + if (saved_flow) { + StorageCache.loadFromLocalStorage("chainforge-state", true); + importGlobalStateFromCache(); + loadFlow(saved_flow, rf_inst); + } + }, + [importGlobalStateFromCache, loadFlow], + ); // Export / Import (from JSON) const exportFlow = useCallback(() => { @@ -754,107 +773,83 @@ const App = () => { waitingForShare, ]); - // Initialize auto-saving - const initAutosaving = (rf_inst: ReactFlowInstance) => { - if (autosavingInterval !== undefined) return; // autosaving interval already set - console.log("Init autosaving"); - - // Autosave the flow to localStorage every minute: - const interv = setInterval(() => { - // Check the visibility of the browser tab --if it's not visible, don't autosave - if (!browserTabIsActive()) return; - - // Start a timer, in case the saving takes a long time - const startTime = Date.now(); - - // Save the flow to localStorage - saveFlow(rf_inst); - - // Check how long the save took - const duration = Date.now() - startTime; - if (duration > 1500) { - // If the operation took longer than 1.5 seconds, that's not good. - // Although this function is called async inside setInterval, - // calls to localStorage block the UI in JavaScript, freezing the screen. - // We smart-disable autosaving here when we detect it's starting the freeze the UI: - console.warn( - "Autosaving disabled. The time required to save to localStorage exceeds 1 second. This can happen when there's a lot of data in your flow. Make sure to export frequently to save your work.", - ); - clearInterval(interv); - setAutosavingInterval(undefined); - } - }, 60000); // 60000 milliseconds = 1 minute - setAutosavingInterval(interv); - }; - // Run once upon ReactFlow initialization - const onInit = (rf_inst: ReactFlowInstance) => { - setRfInstance(rf_inst); + const onInit = useCallback( + (rf_inst: ReactFlowInstance) => { + setRfInstance(rf_inst); - if (IS_RUNNING_LOCALLY) { - // If we're running locally, try to fetch API keys from Python os.environ variables in the locally running Flask backend: - fetchEnvironAPIKeys() - .then((api_keys) => { - setAPIKeys(api_keys); - }) - .catch((err) => { - // Soft fail - console.warn( - "Warning: Could not fetch API key environment variables from Flask server. Error:", - err.message, - ); - }); - } else { - // Check if there's a shared flow UID in the URL as a GET param - // If so, we need to look it up in the database and attempt to load it: - const shared_flow_uid = getSharedFlowURLParam(); - if (shared_flow_uid !== undefined) { - try { - // The format passed a basic smell test; - // now let's query the server for a flow with that UID: - fetch("/db/get_sharedflow.php", { - method: "POST", - body: shared_flow_uid, + if (IS_RUNNING_LOCALLY) { + // If we're running locally, try to fetch API keys from Python os.environ variables in the locally running Flask backend: + fetchEnvironAPIKeys() + .then((api_keys) => { + setAPIKeys(api_keys); }) - .then((r) => r.text()) - .then((response) => { - if (!response || response.startsWith("Error")) { - // Error encountered during the query; alert the user - // with the error message: - throw new Error(response || "Unknown error"); - } - - // Attempt to parse the response as a compressed flow + import it: - const cforge_json = JSON.parse( - LZString.decompressFromUTF16(response), - ); - importFlowFromJSON(cforge_json, rf_inst); + .catch((err) => { + // Soft fail + console.warn( + "Warning: Could not fetch API key environment variables from Flask server. Error:", + err.message, + ); + }); + } else { + // Check if there's a shared flow UID in the URL as a GET param + // If so, we need to look it up in the database and attempt to load it: + const shared_flow_uid = getSharedFlowURLParam(); + if (shared_flow_uid !== undefined) { + try { + // The format passed a basic smell test; + // now let's query the server for a flow with that UID: + fetch("/db/get_sharedflow.php", { + method: "POST", + body: shared_flow_uid, }) - .catch(handleError); - } catch (err) { - // Soft fail - setIsLoading(false); - console.error(err); + .then((r) => r.text()) + .then((response) => { + if (!response || response.startsWith("Error")) { + // Error encountered during the query; alert the user + // with the error message: + throw new Error(response || "Unknown error"); + } + + // Attempt to parse the response as a compressed flow + import it: + const cforge_json = JSON.parse( + LZString.decompressFromUTF16(response), + ); + importFlowFromJSON(cforge_json, rf_inst); + }) + .catch(handleError); + } catch (err) { + // Soft fail + setIsLoading(false); + console.error(err); + } + + // Since we tried to load from the shared flow ID, don't try to load from autosave + return; } - - // Since we tried to load from the shared flow ID, don't try to load from autosave - return; } - } - // Attempt to load an autosaved flow, if one exists: - if (autosavedFlowExists()) loadFlowFromAutosave(rf_inst); - else { - // Load an interesting default starting flow for new users - importFlowFromJSON(EXAMPLEFLOW_1, rf_inst); + // Attempt to load an autosaved flow, if one exists: + if (autosavedFlowExists()) loadFlowFromAutosave(rf_inst); + else { + // Load an interesting default starting flow for new users + importFlowFromJSON(EXAMPLEFLOW_1, rf_inst); - // Open a welcome pop-up - // openWelcomeModal(); - } + // Open a welcome pop-up + // openWelcomeModal(); + } - // Turn off loading wheel - setIsLoading(false); - }; + // Turn off loading wheel + setIsLoading(false); + }, + [ + setAPIKeys, + handleError, + importFlowFromJSON, + autosavedFlowExists, + loadFlowFromAutosave, + ], + ); useEffect(() => { // Cleanup the autosaving interval upon component unmount: @@ -863,6 +858,273 @@ const App = () => { }; }, []); + const reactFlowUI = useMemo(() => { + return ( +