diff --git a/chainforge/flask_app.py b/chainforge/flask_app.py index dd58d11b3..b08768664 100644 --- a/chainforge/flask_app.py +++ b/chainforge/flask_app.py @@ -14,6 +14,7 @@ # RAG-specific imports from markitdown import MarkItDown +from chainforge.providers.cf_to_weave import export_to_weave """ ================= @@ -1061,6 +1062,35 @@ def media_to_text(uid): # traceback.print_exc() return jsonify({"error": f"Failed to process file {uid}. Internal server error."}), 500 +@app.route('/api/exportToWandB', methods=['POST']) +def export_to_wandb(): + try: + + data = request.json + + if not data: + return jsonify({"success": False, "message": "No JSON payload received."}), 400 + + flow_data = data.get('flowData') + api_key = data.get('apiKey') + project_name = data.get('projectName') + # Ensure that flow_data is not empty before proceeding + if not flow_data: + return jsonify({"success": False, "message": "No flow data provided in the request."}), 400 + + print(api_key, project_name) + + response = export_to_weave(flow_data, project_name, api_key) + print(response) + return jsonify(response) + + except Exception as e: + # Catch any unexpected errors and return a consistent error message + print(f"Error in /api/exportToWandB: {e}", file=sys.stderr) + return jsonify({"success": False, "message": f"An unexpected error occurred: {str(e)}"}), 500 + + + @app.route('/api/exportFlowBundle', methods=['POST']) def export_flow_bundle(): """ @@ -1311,6 +1341,12 @@ def verify_media_file_integrity(uid): raise ValueError(f"Hash mismatch: expected {expected_hash}, got {actual_hash}") +@app.route('/') +def serve_public_file(filename): + """Serve files from the public directory""" + public_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'react-server', 'public') + return send_from_directory(public_dir, filename) + @app.route('/api/proxyImage', methods=['GET']) def proxy_image(): """Proxy for fetching images to avoid CORS restrictions""" diff --git a/chainforge/providers/cf_to_weave.py b/chainforge/providers/cf_to_weave.py new file mode 100644 index 000000000..0aac04ef1 --- /dev/null +++ b/chainforge/providers/cf_to_weave.py @@ -0,0 +1,162 @@ + +import os +import json +import re +from pprint import pprint +from datetime import datetime +import hashlib +import base64 +from io import BytesIO + +import weave +from weave import EvaluationLogger +from PIL import Image + +def sanitize_name(name): + """Sanitize a name to be valid for Weave (alphanumeric and underscores only)""" + # Replace hyphens and other invalid chars with underscores + sanitized = re.sub(r'[^a-zA-Z0-9_]', '_', name) + # Ensure it starts with a letter or underscore + if sanitized and not sanitized[0].isalpha() and sanitized[0] != '_': + sanitized = 'model_' + sanitized + return sanitized + +def resolve_string_references(data_item, string_cache, media_cache=None): + """Recursively resolves integer references in data_item using the string_cache and converts image objects to PIL Images.""" + if isinstance(data_item, int): + # Try to fetch the string from __s using index, otherwise return the integer itself + try: + return string_cache[data_item] + except (IndexError, TypeError): + return data_item + elif isinstance(data_item, dict): + # Check if this is an image object + if data_item.get("t") == "img" and "d" in data_item and media_cache: + image_cache_id = data_item["d"] + if image_cache_id in media_cache: + # Extract base64 data from data URI + base64_data = media_cache[image_cache_id] + if base64_data.startswith("data:image"): + # Remove the data URI prefix (e.g., "data:image/jpeg;base64,") + header, encoded = base64_data.split(',', 1) + # Decode base64 to bytes + image_bytes = base64.b64decode(encoded) + # Create PIL Image from bytes + pil_image = Image.open(BytesIO(image_bytes)) + return pil_image + else: + # If not a data URI, return the original data + return base64_data + else: + # If image not found in cache, return the original object + return data_item + else: + # Regular dictionary processing + return {k: resolve_string_references(v, string_cache, media_cache) for k, v in data_item.items()} + elif isinstance(data_item, list): + return [resolve_string_references(elem, string_cache, media_cache) for elem in data_item] + else: + return data_item + +def log_to_weave(data, cforge_filename="test1"): + """Log the .cforge file data to Weave using EvaluationLogger""" + cache = data.get('cache', {}) + string_cache = cache.get('__s', []) # Get the string cache + media_cache = cache.get('__media', {}).get('cache', {}) # Get the media cache + + # Use regex to find all evaluation keys in cache + eval_keys = [key for key in cache.keys() if re.search(r'Eval', key, re.IGNORECASE) and key.endswith('.json')] + + if not eval_keys: + raise Exception("The cforge flow cannot be exported to weave because there are no evaluation nodes found.") + + print(f"Found {len(eval_keys)} evaluation keys: {eval_keys}") + + # Loop through each evaluation key + for eval_key in eval_keys: + eval_results = cache.get(eval_key, []) + + if not eval_results: + raise Exception("No evaluation results found in cache. Please run the evaluators.") + + # Determine model and dataset names dynamically from the first evaluation result + first_eval_result = eval_results[0] + llm_info = first_eval_result.get('llm', {}) + + # Handle case where llm_info can be either a string or dict + if isinstance(llm_info, str): + raw_model_name = llm_info + elif isinstance(llm_info, dict): + raw_model_name = llm_info.get('name', 'unknown_model') + else: + raw_model_name = 'unknown_model' + + model_name = sanitize_name(raw_model_name) # Sanitize the model name + dataset_name = f"chainforge_{cforge_filename}" + + raw_eval_logger_name = eval_key.replace('.json', '') + eval_logger_name = sanitize_name(raw_eval_logger_name) # Sanitize the eval logger name + + print(f"Processing evaluation: {eval_logger_name}") + print(f"Model name: {raw_model_name} -> {model_name}") + print(f"Eval Logger name: {raw_eval_logger_name} -> {eval_logger_name}") + + # Initialize EvaluationLogger + eval_logger = EvaluationLogger( + model=model_name, + dataset=dataset_name, + name=eval_logger_name + ) + + # Iterate through evaluation results and log each prediction + for eval_result in eval_results: + raw_inputs = eval_result.get('vars', {}) + raw_outputs = eval_result.get('responses', [''])[0] # Take the first response + score = eval_result.get('eval_res', {}).get('items', [None])[0] # Take the first score + + # Resolve string references for inputs and outputs, now including media cache + resolved_inputs = resolve_string_references(raw_inputs, string_cache, media_cache) + # Ensure inputs is explicitly a dictionary + if isinstance(resolved_inputs, dict): + inputs = resolved_inputs + else: + # This case should ideally not happen if 'vars' is always a dict + print(f"Warning: Resolved inputs for {eval_key} is not a dictionary. Converting to empty dict.") + inputs = {} + + outputs = resolve_string_references(raw_outputs, string_cache, media_cache) + + # Log the prediction input and output + pred_logger = eval_logger.log_prediction( + inputs=inputs, + output=outputs + ) + + # Log the score if available + if score is not None: + pred_logger.log_score( + scorer=eval_logger_name, + score=score + ) + + pred_logger.finish() + + # Log a summary for the evaluation run + eval_logger.log_summary({ + "total_evaluations": len(eval_results), + "evaluation_type": eval_logger_name + }) + + print(f"Successfully logged {len(eval_results)} evaluations for '{eval_logger_name}' to Weave") + +def export_to_weave(data, project_name, api_key=""): + try: + # Set wandb api key + os.environ['WANDB_API_KEY'] = api_key + # Initialize Weave + weave.init(project_name) + log_to_weave(data) + + return {"success": True, "message": "Successfully exported to W&B Weave"} + except Exception as e: + return {"success": False, "message": f"Error exporting to W&B Weave: {str(e)}"} \ No newline at end of file diff --git a/chainforge/react-server/eslint.config.mjs b/chainforge/react-server/eslint.config.mjs new file mode 100644 index 000000000..9c3ca54a1 --- /dev/null +++ b/chainforge/react-server/eslint.config.mjs @@ -0,0 +1,96 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default [ + { + ignores: [ + "node_modules/**/*", + "build/**/*", + "**/craco.config.js", + "src/backend/pyodide/**/*", + "src/backend/__test__/**/*", + ], + }, + ...compat.extends( + "semistandard", + "plugin:react/recommended", + "plugin:prettier/recommended", + "plugin:@typescript-eslint/recommended", + ), + { + settings: { + react: { + createClass: "createReactClass", + pragma: "React", + fragment: "Fragment", + version: "detect", + flowVersion: "0.53", + }, + + propWrapperFunctions: [ + "forbidExtraProps", + { + property: "freeze", + object: "Object", + }, + { + property: "myFavoriteWrapper", + }, + { + property: "forbidExtraProps", + exact: true, + }, + ], + + componentWrapperFunctions: [ + "observer", + { + property: "styled", + }, + { + property: "observer", + object: "Mobx", + }, + { + property: "observer", + object: "", + }, + ], + + formComponents: [ + "CustomForm", + { + name: "Form", + formAttribute: "endpoint", + }, + ], + + linkComponents: [ + "Hyperlink", + { + name: "Link", + linkAttribute: "to", + }, + ], + }, + + rules: { + semi: ["error", "always"], + camelcase: ["off"], + "react/prop-types": ["off"], + "@typescript-eslint/no-explicit-any": ["off"], + "@typescript-eslint/no-empty-function": ["off"], + "no-control-regex": ["off"], + }, + }, +]; diff --git a/chainforge/react-server/package-lock.json b/chainforge/react-server/package-lock.json index 64621179e..7c5693085 100644 --- a/chainforge/react-server/package-lock.json +++ b/chainforge/react-server/package-lock.json @@ -109,6 +109,8 @@ }, "devDependencies": { "@craco/craco": "^7.1.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.31.0", "@types/lodash": "^4.17.0", "@types/papaparse": "^5.3.14", "@types/react-beautiful-dnd": "^13.1.8", @@ -3625,14 +3627,15 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -3640,7 +3643,7 @@ "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -3650,6 +3653,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3664,17 +3668,45 @@ "node_modules/@eslint/eslintrc/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, "dependencies": { - "type-fest": "^0.20.2" + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -3684,6 +3716,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -3694,25 +3727,19 @@ "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, - "node_modules/@eslint/eslintrc/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "node_modules/@eslint/js": { + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz", + "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==", + "dev": true, "engines": { - "node": ">=10" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", - "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "url": "https://eslint.org/donate" } }, "node_modules/@floating-ui/core": { @@ -7707,9 +7734,9 @@ "integrity": "sha512-wkJp+Wz8MRHtCVdt65L/jPFLAQ0iqJZ2EeD2XWOvKGbIi4mZNwHlpHRLRB8ZnQ07VoiB0TLFWwIjjm2FL9gUcQ==" }, "node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "bin": { "acorn": "bin/acorn" }, @@ -12218,6 +12245,36 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/eslint/node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", + "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/eslint/node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", diff --git a/chainforge/react-server/package.json b/chainforge/react-server/package.json index 229a67686..71a636956 100644 --- a/chainforge/react-server/package.json +++ b/chainforge/react-server/package.json @@ -135,6 +135,8 @@ }, "devDependencies": { "@craco/craco": "^7.1.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.31.0", "@types/lodash": "^4.17.0", "@types/papaparse": "^5.3.14", "@types/react-beautiful-dnd": "^13.1.8", diff --git a/chainforge/react-server/public/wandb-logo.png b/chainforge/react-server/public/wandb-logo.png new file mode 100644 index 000000000..78eba8c78 Binary files /dev/null and b/chainforge/react-server/public/wandb-logo.png differ diff --git a/chainforge/react-server/src/App.tsx b/chainforge/react-server/src/App.tsx index ffb2dbde0..b0a813589 100644 --- a/chainforge/react-server/src/App.tsx +++ b/chainforge/react-server/src/App.tsx @@ -24,6 +24,7 @@ import { Tooltip, Flex, useMantineColorScheme, + Menu, } from "@mantine/core"; import { useClipboard } from "@mantine/hooks"; import { useContextMenu } from "mantine-contextmenu"; @@ -117,6 +118,7 @@ import NestedMenu, { NestedMenuItemProps } from "./NestedMenu"; import RequestClarificationModal, { RequestClarificationModalProps, } from "./RequestClarificationModal"; +import { useExportToWandB } from "./components/ExportToWandB"; // Import the new hook const IS_ACCEPTED_BROWSER = (isChrome || @@ -226,6 +228,14 @@ const nodeEmojis = { join: , split: , media: "📺", + exportCforge: "💾", + exportWandB: ( + W&B + ), }; const edgeTypes = { @@ -330,6 +340,57 @@ const App = () => { // Context menu for "Add Node +" list const { hideContextMenu } = useContextMenu(); + 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], + ); + + // Use the custom hook for Weights & Biases export + const { handleExportToWandB: callExportToWandBFunc, ProjectNameModal } = + useExportToWandB({ + showAlert: showAlert || (() => {}), // Ensure showAlert is a function + rfInstance, + nodes, + handleError, + }); + + const exportMenuItems = useMemo(() => { + // All initial nodes available in ChainForge + const initNodes = [ + { + // Menu.Label + key: "To File", + }, + { + key: "cforge", + title: ".cforge", + icon: nodeEmojis.exportCforge, + tooltip: "Export to .cforge file", + onClick: () => exportFlow(), + }, + { + key: "divider", + }, + { + // Menu.Label + key: "Platforms", + }, + { + key: "wandb", + title: "W&B Weave", + icon: nodeEmojis.exportWandB, + tooltip: "Export to weights and biases weave platform", + onClick: () => callExportToWandBFunc(), // Corrected call + }, + ] as NestedMenuItemProps[]; + return initNodes; + }, [callExportToWandBFunc]); // Add Nodes list const addNodesMenuItems = useMemo(() => { // All initial nodes available in ChainForge @@ -630,17 +691,6 @@ const App = () => { if (settingsModal && settingsModal.current) settingsModal.current.trigger(); }; - 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) */ @@ -1477,6 +1527,12 @@ const App = () => { ); }, [flowFileName, importFlowFromJSON, showAlert, setFlowFileNameAndCache]); + // Export to Weights & Biases handler (stub for now) + const handleExportToWandB = useCallback(() => { + // TODO: Implement actual export logic to Weights & Biases + alert("Export to Weights & Biases is not yet implemented."); + }, []); + if (!IS_ACCEPTED_BROWSER) { return ( @@ -1527,6 +1583,7 @@ const App = () => { ref={examplesModal} handleOnSelect={onSelectExampleFlow} /> + {ProjectNameModal} {flowSidebar} {/* @@ -1562,17 +1619,21 @@ const App = () => { )} /> - + ( + + )} + />