This commit is contained in:
Ian Arawjo 2025-03-02 10:40:18 -05:00
parent 8a9c1d72be
commit b88416cd1a
6 changed files with 410 additions and 33 deletions

View File

@ -8,6 +8,7 @@ from flask_cors import CORS
from chainforge.providers.dalai import call_dalai
from chainforge.providers import ProviderRegistry
import requests as py_requests
from platformdirs import user_data_dir
""" =================
SETUP AND GLOBALS
@ -26,6 +27,7 @@ app = Flask(__name__, static_folder=STATIC_DIR, template_folder=BUILD_DIR)
cors = CORS(app, resources={r"/*": {"origins": "*"}})
# The cache and examples files base directories
FLOWS_DIR = user_data_dir("chainforge") # platform-agnostic local storage that persists outside the package install location
CACHE_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'cache')
EXAMPLES_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'examples')
@ -721,6 +723,77 @@ async def callCustomProvider():
# Return the response
return jsonify({'response': response})
"""
LOCALLY SAVED FLOWS
"""
@app.route('/api/flows', methods=['GET'])
def get_flows():
"""Return a list of all saved flows. If the directory does not exist, try to create it."""
os.makedirs(FLOWS_DIR, exist_ok=True) # Creates the directory if it doesn't exist
flows = [f for f in os.listdir(FLOWS_DIR) if f.endswith('.cforge')]
return jsonify(flows)
@app.route('/api/flows/<filename>', methods=['GET'])
def get_flow(filename):
"""Return the content of a specific flow"""
if not filename.endswith('.cforge'):
filename += '.cforge'
try:
with open(os.path.join(FLOWS_DIR, filename), 'r') as f:
return jsonify(json.load(f))
except FileNotFoundError:
return jsonify({"error": "Flow not found"}), 404
@app.route('/api/flows/<filename>', methods=['DELETE'])
def delete_flow(filename):
"""Delete a flow"""
try:
os.remove(os.path.join(FLOWS_DIR, filename))
return jsonify({"message": f"Flow {filename} deleted successfully"})
except FileNotFoundError:
return jsonify({"error": "Flow not found"}), 404
@app.route('/api/flows/<filename>', methods=['PUT'])
def save_or_rename_flow(filename):
"""Save or rename a flow"""
data = request.json
if not filename.endswith('.cforge'):
filename += '.cforge'
if data.get('flow'):
# Save flow (overwriting any existing flow file with the same name)
flow_data = data.get('flow')
try:
filepath = os.path.join(FLOWS_DIR, filename)
with open(filepath, 'w') as f:
json.dump(flow_data, f)
return jsonify({"message": f"Flow '{filename}' saved!"})
except FileNotFoundError:
return jsonify({"error": f"Could not save flow '{filename}' to local filesystem. See terminal for more details."}), 404
elif data.get('newName'):
# Rename flow
new_name = data.get('newName')
if not new_name.endswith('.cforge'):
new_name += '.cforge'
try:
old_path = os.path.join(FLOWS_DIR, filename)
new_path = os.path.join(FLOWS_DIR, new_name)
with open(old_path, 'r') as f:
flow_data = json.load(f)
with open(new_path, 'w') as f:
json.dump(flow_data, f)
os.remove(old_path)
return jsonify({"message": f"Flow renamed from {filename} to {new_name}"})
except FileNotFoundError:
return jsonify({"error": "Flow not found"}), 404
def run_server(host="", port=8000, cmd_args=None):
global HOSTNAME, PORT

View File

@ -86,6 +86,7 @@ import {
fetchExampleFlow,
fetchOpenAIEval,
importCache,
saveFlowToLocalFilesystem,
} from "./backend/backend";
// Device / Browser detection
@ -97,6 +98,7 @@ import {
isChromium,
} from "react-device-detect";
import MultiEvalNode from "./MultiEvalNode";
import FlowSidebar from "./FlowSidebar";
const IS_ACCEPTED_BROWSER =
(isChrome ||
@ -258,6 +260,9 @@ const App = () => {
NodeJS.Timeout | undefined
>(undefined);
// The 'name' of the current flow, to use when saving/loading
const [flowFileName, setFlowFileName] = useState(`flow-${Date.now()}`);
// For 'share' button
const clipboard = useClipboard({ timeout: 1500 });
const [waitingForShare, setWaitingForShare] = useState(false);
@ -371,6 +376,36 @@ const App = () => {
URL.revokeObjectURL(downloadLink.href);
};
// Export flow to JSON
const exportFlow = useCallback(
(flowData?: unknown, saveToLocalFilesystem?: boolean) => {
if (!rfInstance && !flowData) return;
// We first get the data of the flow, if we haven't already
const flow = flowData ?? rfInstance?.toObject();
// Then we grab all the relevant cache files from the backend
const all_node_ids = nodes.map((n) => n.id);
return exportCache(all_node_ids)
.then(function (cacheData) {
// Now we append the cache file data to the flow
const flow_and_cache = {
flow,
cache: cacheData,
};
// Save!
const flowFile = `${flowFileName}.cforge`;
if (saveToLocalFilesystem)
return saveFlowToLocalFilesystem(flow_and_cache, flowFile);
// @ts-expect-error The exported RF instance is JSON compatible but TypeScript won't read it as such.
else downloadJSON(flow_and_cache, flowFile);
})
.catch(handleError);
},
[rfInstance, nodes, flowFileName, handleError],
);
// Save the current flow to localStorage for later recall. Useful to getting
// back progress upon leaving the site / browser crash / system restart.
const saveFlow = useCallback(
@ -390,14 +425,21 @@ const App = () => {
// the StorageCache. (This does LZ compression to save space.)
StorageCache.saveToLocalStorage("chainforge-state");
console.log("Flow saved!");
setShowSaveSuccess(true);
setTimeout(() => {
setShowSaveSuccess(false);
}, 1000);
const onFlowSaved = () => {
console.log("Flow saved!");
setShowSaveSuccess(true);
setTimeout(() => {
setShowSaveSuccess(false);
}, 1000);
};
// If running locally, aattempt to save a copy of the flow to the lcoal filesystem,
// so it shows up in the list of saved flows.
if (IS_RUNNING_LOCALLY) exportFlow(flow, true)?.then(onFlowSaved);
else onFlowSaved();
});
},
[rfInstance],
[rfInstance, exportFlow],
);
// Keyboard save handler
@ -538,30 +580,6 @@ const App = () => {
[importGlobalStateFromCache, loadFlow],
);
// Export / Import (from JSON)
const exportFlow = useCallback(() => {
if (!rfInstance) return;
// We first get the data of the flow
const flow = rfInstance.toObject();
// Then we grab all the relevant cache files from the backend
const all_node_ids = nodes.map((n) => n.id);
exportCache(all_node_ids)
.then(function (cacheData) {
// Now we append the cache file data to the flow
const flow_and_cache = {
flow,
cache: cacheData,
};
// Save!
// @ts-expect-error The exported RF instance is JSON compatible but TypeScript won't read it as such.
downloadJSON(flow_and_cache, `flow-${Date.now()}.cforge`);
})
.catch(handleError);
}, [rfInstance, nodes, handleError]);
// Import data to the cache stored on the local filesystem (in backend)
const handleImportCache = useCallback(
(cache_data: Dict<Dict>) =>
@ -1216,6 +1234,19 @@ const App = () => {
message={confirmationDialogProps.message}
onConfirm={confirmationDialogProps.onConfirm}
/>
<FlowSidebar
onLoadFlow={(flowData, name) => {
setFlowFileName(name);
try {
importFlowFromJSON(flowData);
} catch (error) {
console.error(error);
setIsLoading(false);
if (showAlert) showAlert(error as Error);
}
}}
/>
{/* <Modal title={'Welcome to ChainForge'} size='400px' opened={welcomeModalOpened} onClose={closeWelcomeModal} yOffset={'6vh'} styles={{header: {backgroundColor: '#FFD700'}, root: {position: 'relative', left: '-80px'}}}>
<Box m='lg' mt='xl'>
@ -1227,12 +1258,12 @@ const App = () => {
<div
id="custom-controls"
style={{ position: "fixed", left: "10px", top: "10px", zIndex: 8 }}
style={{ position: "fixed", left: "44px", top: "10px", zIndex: 8 }}
>
<Flex>
{addNodeMenu}
<Button
onClick={exportFlow}
onClick={() => exportFlow()}
size="sm"
variant="outline"
bg="#eee"

View File

@ -0,0 +1,255 @@
import React, { useState, useEffect, useContext } from "react";
import {
IconEdit,
IconTrash,
IconMenu2,
IconX,
IconCheck,
} from "@tabler/icons-react";
import axios from "axios";
import { AlertModalContext } from "./AlertModal";
import { Dict } from "./backend/typing";
import {
ActionIcon,
Box,
Button,
Drawer,
Group,
Stack,
TextInput,
Text,
Flex,
Header,
Title,
Divider,
} from "@mantine/core";
import { FLASK_BASE_URL } from "./backend/utils";
interface FlowSidebarProps {
onLoadFlow: (flowFile: Dict<any>, flowName: string) => void;
}
const FlowSidebar: React.FC<FlowSidebarProps> = ({ onLoadFlow }) => {
const [isOpen, setIsOpen] = useState(false);
const [savedFlows, setSavedFlows] = useState<string[]>([]);
// const [editingFlow, setEditingFlow] = useState(null);
const [editName, setEditName] = useState<string | null>(null);
const [newEditName, setNewEditName] = useState<string>("newName");
// // Pop-up to edit name of a flow
// const editTextRef = useState<RenameValueModalRef | null>(null);
// For displaying alerts
const showAlert = useContext(AlertModalContext);
// Fetch saved flows from the Flask backend
const fetchSavedFlowList = async () => {
try {
const response = await axios.get(`${FLASK_BASE_URL}api/flows`);
setSavedFlows(
response.data.map((filename: string) =>
filename.replace(".cforge", ""),
),
);
} catch (error) {
console.error("Error fetching saved flows:", error);
}
};
// Load a flow when clicked, and push it to the caller
const handleLoadFlow = async (filename: string) => {
try {
// Fetch the flow
const response = await axios.get(
`${FLASK_BASE_URL}api/flows/${filename}`,
);
// Push the flow to the ReactFlow UI. We also pass the filename
// so that the caller can use that info to save the right flow when the user presses save.
onLoadFlow(response.data, filename);
setIsOpen(false); // Close sidebar after loading
} catch (error) {
console.error(`Error loading flow ${filename}:`, error);
if (showAlert) showAlert(error as Error);
}
};
// Delete a flow
const handleDeleteFlow = async (
filename: string,
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
event.stopPropagation(); // Prevent triggering the parent click
if (window.confirm(`Are you sure you want to delete "${filename}"?`)) {
try {
await axios.delete(`${FLASK_BASE_URL}api/flows/${filename}`);
fetchSavedFlowList(); // Refresh the list
} catch (error) {
console.error(`Error deleting flow ${filename}:`, error);
if (showAlert) showAlert(error as Error);
}
}
};
// Start editing a flow name
const handleEditClick = (
flowFile: string,
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
event.stopPropagation(); // Prevent triggering the parent click
setEditName(flowFile);
setNewEditName(flowFile);
};
// Cancel editing
const handleCancelEdit = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
event.stopPropagation(); // Prevent triggering the parent click
setEditName(null);
};
// Save the edited flow name
const handleSaveEdit = async (
oldFilename: string,
newFilename: string,
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
event?.stopPropagation(); // Prevent triggering the parent click
if (newFilename && newFilename !== oldFilename) {
try {
await axios.put(`${FLASK_BASE_URL}api/flows/${oldFilename}`, {
newName: newFilename,
});
fetchSavedFlowList(); // Refresh the list
} catch (error) {
console.error(`Error renaming flow ${oldFilename}:`, error);
if (showAlert) showAlert(error as Error);
}
}
// No longer editing
setEditName(null);
setNewEditName("newName");
};
// Load flows when component mounts
useEffect(() => {
if (isOpen) {
fetchSavedFlowList();
}
}, [isOpen]);
return (
<div className="relative">
{/* <RenameValueModal title="Rename flow" label="Edit name" initialValue="" onSubmit={handleEditName} /> */}
{/* Toggle Button */}
<ActionIcon
variant="gradient"
size="1.625rem"
style={{
position: "absolute",
top: "10px",
left: "10px",
// left: isOpen ? "250px" : "10px",
// transition: "left 0.3s ease-in-out",
zIndex: 10,
}}
onClick={() => setIsOpen(!isOpen)}
>
{isOpen ? <IconX /> : <IconMenu2 />}
</ActionIcon>
{/* Sidebar */}
<Drawer
opened={isOpen}
onClose={() => setIsOpen(false)}
position="left"
size="250px" // Adjust sidebar width
padding="md"
withCloseButton={false} // Hide default close button
>
<Stack spacing="4px" mt="0px">
<Flex justify="space-between">
<Title order={4} align="center" color="#333">
Saved Flows
</Title>
<ActionIcon onClick={() => setIsOpen(false)}>
<IconX />
</ActionIcon>
</Flex>
<Divider />
{savedFlows.length === 0 ? (
<Text color="dimmed">No saved flows found</Text>
) : (
savedFlows.map((flow) => (
<Box
key={flow}
p="6px"
sx={(theme) => ({
borderRadius: theme.radius.sm,
cursor: "pointer",
"&:hover": {
backgroundColor:
theme.colorScheme === "dark"
? theme.colors.dark[6]
: theme.colors.gray[0],
},
})}
onClick={() => {
if (editName !== flow) handleLoadFlow(flow);
}}
>
{editName === flow ? (
<Group spacing="xs">
<TextInput
value={newEditName}
onChange={(e) => setNewEditName(e.target.value)}
style={{ flex: 1 }}
autoFocus
/>
<ActionIcon
color="green"
onClick={(e) => handleSaveEdit(editName, newEditName, e)}
>
<IconCheck size={18} />
</ActionIcon>
<ActionIcon color="gray" onClick={handleCancelEdit}>
<IconX size={18} />
</ActionIcon>
</Group>
) : (
<Flex
justify="space-between"
align="center"
gap="0px"
h="auto"
>
<Text size="sm">{flow}</Text>
<ActionIcon
color="blue"
onClick={(e) => handleEditClick(flow, e)}
>
<IconEdit size={18} />
</ActionIcon>
<ActionIcon
color="red"
onClick={(e) => handleDeleteFlow(flow, e)}
>
<IconTrash size={18} />
</ActionIcon>
</Flex>
)}
<Divider />
</Box>
))
)}
</Stack>
</Drawer>
</div>
);
};
export default FlowSidebar;

View File

@ -1,4 +1,5 @@
import MarkdownIt from "markdown-it";
import axios from "axios";
import { v4 as uuid } from "uuid";
import {
Dict,
@ -697,6 +698,21 @@ export async function fetchEnvironAPIKeys(): Promise<Dict<string>> {
}).then((res) => res.json());
}
export async function saveFlowToLocalFilesystem(
flowJSON: Dict,
filename: string,
): Promise<void> {
try {
await axios.put(`${FLASK_BASE_URL}api/flows/${filename}`, {
flow: flowJSON,
});
} catch (error) {
throw new Error(
`Error saving flow with name ${filename}: ${(error as Error).toString()}`,
);
}
}
/**
* Queries LLM(s) with root prompt template `prompt` and prompt input variables `vars`, `n` times per prompt.
* Soft-fails if API calls fail, and collects the errors in `errors` property of the return object.

View File

@ -7,4 +7,5 @@ dalaipy==2.0.2
urllib3==1.26.6
anthropic
google-generativeai
mistune>=2.0
mistune>=2.0
platformdirs

View File

@ -21,6 +21,7 @@ setup(
"flask[async]",
"flask_cors",
"requests",
"platformdirs",
"urllib3==1.26.6",
"openai",
"anthropic",