From dedd00e5212b6f96451b08fe4ddc0fdee60d9e45 Mon Sep 17 00:00:00 2001 From: Frikky Date: Tue, 26 Aug 2025 16:10:16 +0200 Subject: [PATCH] Added functions for workflow generation onprem --- .../components/HighlightedValueInSearch.jsx | 257 +++++++++++++++++ .../components/WorkflowGenerationModal.jsx | 268 ++++++++++++++++++ 2 files changed, 525 insertions(+) create mode 100644 frontend/src/components/HighlightedValueInSearch.jsx create mode 100644 frontend/src/components/WorkflowGenerationModal.jsx diff --git a/frontend/src/components/HighlightedValueInSearch.jsx b/frontend/src/components/HighlightedValueInSearch.jsx new file mode 100644 index 00000000..f707a6fc --- /dev/null +++ b/frontend/src/components/HighlightedValueInSearch.jsx @@ -0,0 +1,257 @@ +import React, { useEffect, useRef, useCallback, useMemo } from 'react'; +import ReactJson from 'react-json-view-ssr'; +import CodeMirror from '@uiw/react-codemirror'; +import { python } from '@codemirror/lang-python'; +import { vscodeDark } from '@uiw/codemirror-theme-vscode'; + +const HighlightedValueInSearch = ({ value, searchTerm, theme }) => { + const containerRef = useRef(null); + const preRef = useRef(null); + const scrollTimeoutRef = useRef(null); + + const isJson = useCallback((str) => { + if (typeof str === 'object' && str !== null) return true; + if (typeof str !== 'string') return false; + + const trimmed = str.trim(); + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return false; + + try { + JSON.parse(trimmed); + return true; + } catch { + return false; + } + }, []); + + const getJsonValue = useCallback((val) => { + try { + return typeof val === 'object' ? val : JSON.parse(val.trim()); + } catch { + return null; + } + }, []); + + const highlightInJson = useCallback((jsonStr, term) => { + if (!term) return jsonStr; + + try { + const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escapedTerm})`, 'gi'); + const highlightId = `highlight-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + return jsonStr.replace(regex, `$1`); + } catch { + return jsonStr; + } + }, []); + + const isPythonCode = useCallback((str) => { + if (typeof str !== 'string') return false; + + const pythonPatterns = [ + 'import ', 'from ', 'def ', 'class ', 'if __name__', + 'print(', 'return ', 'elif ', 'except:', 'try:', 'with ', + 'lambda ', 'yield ', 'async def', 'await ' + ]; + + const trimmed = str.trim(); + return pythonPatterns.some(pattern => trimmed.includes(pattern)) && + (trimmed.includes('def') || trimmed.includes('class')); + }, []); + + useEffect(() => { + if (!searchTerm || !containerRef.current || !preRef.current) return; + + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + + scrollTimeoutRef.current = setTimeout(() => { + try { + const container = containerRef.current; + const firstHighlight = preRef.current?.querySelector('mark'); + + if (firstHighlight && container) { + const containerRect = container.getBoundingClientRect(); + const highlightRect = firstHighlight.getBoundingClientRect(); + const scrollTop = highlightRect.top - containerRect.top + container.scrollTop - (container.clientHeight / 2); + + container.scrollTo({ top: Math.max(0, scrollTop), behavior: 'smooth' }); + } + } catch (error) { + console.warn('Auto-scroll failed:', error); + } + }, 100); + + return () => { + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + }; + }, [searchTerm, value]); + + const RegularText = useCallback(({ value: textValue, searchTerm: term }) => { + if (!textValue) return No content; + + const stringValue = String(textValue); + + // Check if it's Python code + if (isPythonCode(stringValue)) { + const lines = stringValue.split('\n'); + const isNodeView = lines[0]?.startsWith('Node:'); + const nodeTitle = isNodeView ? lines[0] : null; + const codeContent = isNodeView ? lines.slice(1).join('\n') : stringValue; + + return ( +
+ {nodeTitle && ( +
+ {nodeTitle} +
+ )} + +
+ +
+
+ ); + } + + if (!term) { + return ( + + {stringValue} + + ); + } + + try { + const parts = stringValue.split(term); + return ( + + {parts.map((part, index) => ( + + {part} + {index < parts.length - 1 && ( + + {term} + + )} + + ))} + + ); + } catch { + return {stringValue}; + } + }, [isPythonCode]); + + if (value == null) { + return null; + } + + try { + if (isJson(value)) { + const jsonValue = getJsonValue(value); + + if (jsonValue === null) { + return ; + } + + const jsonString = JSON.stringify(jsonValue, null, 2); + const hasMatch = searchTerm && jsonString.toLowerCase().includes(searchTerm.toLowerCase()); + + return ( +
+ {hasMatch ? ( +
+          ) : (
+            
+          )}
+        
+ ); + } + + return ; + + } catch (error) { + console.error('HighlightedValueInSearch error:', error); + return ; + } +}; + +export default React.memo(HighlightedValueInSearch); \ No newline at end of file diff --git a/frontend/src/components/WorkflowGenerationModal.jsx b/frontend/src/components/WorkflowGenerationModal.jsx new file mode 100644 index 00000000..156057ed --- /dev/null +++ b/frontend/src/components/WorkflowGenerationModal.jsx @@ -0,0 +1,268 @@ +import React, { useEffect, useState } from "react"; + +import { getTheme } from '../theme.jsx'; +import { toast } from "react-toastify" + +import { + Button, + Dialog, + IconButton, + Typography, + CircularProgress, + Tooltip, + TextareaAutosize, + TextField, + ButtonGroup, +} from "@mui/material"; + +import { + Close as CloseIcon, + DragIndicator as DragIndicatorIcon, + Send as SendIcon, +} from '@mui/icons-material'; + +const WorkflowGenerationModal = (props) => { + + const { + open = false, + supportEmail = "support@shuffler.io", + isMobile = false, + theme = null, + workflow={}, + setWorkflow = () => {}, + saveWorkflow = () => {}, + setWorkflowGenerationModalOpen = () => {}, + isCloud = false, + globalUrl = "", + } = props; + + const [isFocused, setIsFocused] = useState(false); + const [workflowDescription, setWorkflowDescription] = useState(""); + const [isAiEditing, setIsAiEditing] = React.useState(false); + const [backupWorkflow, setBackupWorkflow] = React.useState(null); + + const currentTheme = theme || getTheme("dark"); + const hasBackup = backupWorkflow !== null && backupWorkflow !== undefined + + + const handleKeyDown = (event) => { + + if (open === false) { + return + } + + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault() + const tryItButton = document.getElementById("try-it-button") + if (tryItButton !== undefined && tryItButton !== null) { + tryItButton.click() + } else { + const keepChangesButton = document.getElementById("keep-changes-button") + if (keepChangesButton !== undefined && keepChangesButton !== null) { + keepChangesButton.click() + } + } + } + + // Ctrl+Z + if ((event.metaKey || event.ctrlKey) && event.key === 'z') { + event.preventDefault() + const discardChangesButton = document.getElementById("discard-changes-button") + if (discardChangesButton !== undefined && discardChangesButton !== null) { + discardChangesButton.click() + } + } + + // Escape + if (event.key === 'Escape') { + setWorkflowDescription(""); + setIsAiEditing(false); + setWorkflowGenerationModalOpen(false); + } +} + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [handleKeyDown]) + + const discardAiWorkflow = () => { + if (backupWorkflow === null || backupWorkflow === undefined) { + toast("No backup workflow to discard to."); + return; + } + + // Deep copy to avoid reference issues and reset state + const restored = JSON.parse(JSON.stringify(backupWorkflow)); + setWorkflow(restored); + setBackupWorkflow(null); + setWorkflowDescription(""); + setIsAiEditing(false); // Reset loading state + setWorkflowGenerationModalOpen(false); + + toast.success("Changes discarded and previous workflow restored."); + }; + + const editAIWorkflow = () => { + setIsAiEditing(true); + setBackupWorkflow(null); + + var envToSend = isCloud ? "Cloud" : "Shuffle" + for (var actionkey in workflow?.actions) { + envToSend = workflow?.actions[actionkey]?.environment + break + } + + const data = { + query: workflowDescription, + workflow_id: workflow?.id, + environment: envToSend, + } + + fetch(`${globalUrl}/api/v2/workflows/edit/llm`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(data), + credentials: "include", + }) + .then((response) => { + return response.json().then((json) => { + if (response.status !== 200) { + toast.error(json.message || "Unexpected response. Please contact support@shuffler.io if this persists."); + setIsAiEditing(false); + return null; + } + + if (json.success === true && typeof json.message === "string") { + toast(json.message); + setIsAiEditing(false); + return null; + } + + if (json.success === false) { + toast(json.message || "Operation failed"); + setIsAiEditing(false); + return null; + } + + if (!json || Object.keys(json).length === 0) { + toast("AI edit failed: empty response"); + setIsAiEditing(false); + return null; + } + + toast.success("Workflow load done. Choose what to do."); + setBackupWorkflow(JSON.parse(JSON.stringify(workflow))); + setWorkflow(json); + setIsAiEditing(false); + return json; + }); + }) + .catch((error) => { + console.error("AI Workflow Edit Error:", error); + toast.error(`Failed to load LLM response due to: ${error.message || error}`); + setIsAiEditing(false); + }); + }; + + const handleEdit = () => { + if (workflowDescription.trim() === "") { + return + } + + editAIWorkflow() + }; + + const handleDiscard = () => { + setWorkflowDescription(""); + + } + + const handleKeep = () => { + // Keep the AI-provided workflow: do not restore the backup. + // Clear the stored backup and reset modal state. + saveWorkflow(workflow); + setBackupWorkflow(null) + setWorkflowDescription("") + setIsAiEditing(false) + setWorkflowGenerationModalOpen(false); + }; + + if (open === false) { + if (hasBackup) { + } + + return null + } + + return ( +
+ + + {hasBackup && !isAiEditing ? + + + + + + : + + setWorkflowDescription(e.target.value)} + disabled={isAiEditing} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + fullWidth + + InputProps={{ + endAdornment: ( + + ) + }} + /> + } + + + AI Edits require you to manually review and accept changes.
You can discard unwanted edits. Uses your configured LLM or shuffler.io AI credits. Alpha feature. +
+ +
+ ); +}; + +export default WorkflowGenerationModal; \ No newline at end of file