Added functions for workflow generation onprem
This commit is contained in:
@@ -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, `<mark id="${highlightId}" style="background: rgba(255,255,0,0.4); padding: 0 2px; border-radius: 2px;">$1</mark>`);
|
||||||
|
} 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 <span>No content</span>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#1e1e1e',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{nodeTitle && (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
color: '#E0E0E0',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
{nodeTitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '8px',
|
||||||
|
maxHeight: '400px',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}>
|
||||||
|
<CodeMirror
|
||||||
|
value={codeContent}
|
||||||
|
theme={vscodeDark}
|
||||||
|
extensions={[python()]}
|
||||||
|
basicSetup={{
|
||||||
|
lineNumbers: true,
|
||||||
|
foldGutter: true,
|
||||||
|
highlightActiveLine: false,
|
||||||
|
highlightActiveLineGutter: false,
|
||||||
|
highlightSpecialChars: false,
|
||||||
|
drawSelection: false,
|
||||||
|
}}
|
||||||
|
editable={false}
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace',
|
||||||
|
}}
|
||||||
|
height="auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!term) {
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
display: 'inline-block',
|
||||||
|
maxWidth: '100%'
|
||||||
|
}}>
|
||||||
|
{stringValue}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = stringValue.split(term);
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
display: 'inline-block',
|
||||||
|
maxWidth: '100%'
|
||||||
|
}}>
|
||||||
|
{parts.map((part, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{part}
|
||||||
|
{index < parts.length - 1 && (
|
||||||
|
<span style={{
|
||||||
|
backgroundColor: 'rgba(255, 255, 0, 0.3)',
|
||||||
|
padding: '0 2px',
|
||||||
|
borderRadius: '2px',
|
||||||
|
}}>
|
||||||
|
{term}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return <span>{stringValue}</span>;
|
||||||
|
}
|
||||||
|
}, [isPythonCode]);
|
||||||
|
|
||||||
|
if (value == null) {
|
||||||
|
return <span style={{ color: '#888', fontStyle: 'italic' }}>null</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isJson(value)) {
|
||||||
|
const jsonValue = getJsonValue(value);
|
||||||
|
|
||||||
|
if (jsonValue === null) {
|
||||||
|
return <RegularText value={String(value)} searchTerm={searchTerm} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(jsonValue, null, 2);
|
||||||
|
const hasMatch = searchTerm && jsonString.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.2)',
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflow: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasMatch ? (
|
||||||
|
<pre
|
||||||
|
ref={preRef}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Monaco, monospace',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#d4d4d4',
|
||||||
|
margin: 0,
|
||||||
|
whiteSpace: 'pre-wrap'
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{ __html: highlightInJson(jsonString, searchTerm) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ReactJson
|
||||||
|
src={jsonValue}
|
||||||
|
theme={theme?.palette?.jsonTheme || 'monokai'}
|
||||||
|
name={false}
|
||||||
|
collapsed={2}
|
||||||
|
enableClipboard={true}
|
||||||
|
style={{ backgroundColor: 'transparent', fontSize: '12px' }}
|
||||||
|
displayDataTypes={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <RegularText value={value} searchTerm={searchTerm} />;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('HighlightedValueInSearch error:', error);
|
||||||
|
return <RegularText value={String(value)} searchTerm={searchTerm} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(HighlightedValueInSearch);
|
||||||
@@ -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 (
|
||||||
|
<div style={{position: "fixed", bottom: 100, left: "40%", minWidth: 540, border: "1px solid rgba(255,255,255,0.3)", padding: 10, borderRadius: theme?.palette?.borderRadius || 8, background: currentTheme?.palette?.background?.default || "#222", }}>
|
||||||
|
|
||||||
|
|
||||||
|
{hasBackup && !isAiEditing ?
|
||||||
|
<ButtonGroup fullWidth style={{MarginBottom: 5, }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
id="discard-changes-button"
|
||||||
|
onClick={discardAiWorkflow}
|
||||||
|
>
|
||||||
|
Discard (Ctrl+z)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="aiButtonGhost"
|
||||||
|
size="small"
|
||||||
|
onClick={handleKeep}
|
||||||
|
id="keep-changes-button"
|
||||||
|
>
|
||||||
|
Keep (Ctrl+enter)
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
|
||||||
|
:
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
placeholder={`Describe how you want to edit your workflow here...`}
|
||||||
|
multiline
|
||||||
|
minRows={1}
|
||||||
|
value={workflowDescription}
|
||||||
|
onChange={(e) => setWorkflowDescription(e.target.value)}
|
||||||
|
disabled={isAiEditing}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
fullWidth
|
||||||
|
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<Button
|
||||||
|
id="try-it-button"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
variant="aiButton"
|
||||||
|
disabled={isAiEditing || workflowDescription.trim() === ""}
|
||||||
|
onClick={handleEdit}
|
||||||
|
style={{maxHeight: 40, minHeight: 40, whiteSpace: 'nowrap'}}
|
||||||
|
>
|
||||||
|
{isAiEditing
|
||||||
|
? <CircularProgress size={16} />
|
||||||
|
: <><SendIcon style={{ marginRight: 5, }} /> Ctrl+enter</>
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Typography variant="body2" style={{ fontSize: 10, textAlign: "center", color: currentTheme.palette.text.secondary || "#ccc", marginTop: 10 }}>
|
||||||
|
AI Edits require you to manually review and accept changes.<br/> You can discard unwanted edits. Uses your configured LLM or shuffler.io AI credits. <b>Alpha</b> feature.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkflowGenerationModal;
|
||||||
Reference in New Issue
Block a user