├── src
├── vite-env.d.ts
├── App.tsx
├── Doc
│ └── DocPage.tsx
├── graph
│ ├── GraphApp.css
│ ├── ResizeIcon.tsx
│ ├── GraphPanel.css
│ ├── CustomEdge.tsx
│ ├── NodeData.ts
│ ├── GraphContext.test.tsx
│ ├── JsonUtil.tsx
│ ├── GraphContext.tsx
│ ├── NodeData.fromjson.test.tsx
│ ├── GraphApp.tsx
│ ├── CustomNode.tsx
│ ├── JsonUtil.tojson.test.tsx
│ ├── NodeData.tojson.test.tsx
│ ├── GraphPanel.tsx
│ ├── JsonUtil.fromjson.test.tsx
│ └── GraphActions.tsx
├── redux
│ ├── store.ts
│ └── userInfo.store.ts
├── main.tsx
├── routes
│ └── AppRoutes.tsx
├── main.test.tsx
├── index.css
├── utils
│ ├── ConfigManager.ts
│ └── JsonIO.ts
└── GraphMenu
│ ├── MenuLayout.tsx
│ ├── FileTransmit.ts
│ ├── ConfigWindow.tsx
│ ├── MenuToggleButton.tsx
│ └── RunWindow.tsx
├── postcss.config.js
├── tsconfig.json
├── README.md
├── .gitignore
├── Dockerfile
├── tailwind.config.js
├── index.html
├── setupTests.ts
├── tsconfig.node.json
├── tsconfig.app.json
├── eslint.config.js
├── LICENSE
├── vite.config.ts
└── package.json
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | // App.tsx
2 |
3 | import React from 'react';
4 | import AppRoutes from './routes/AppRoutes';
5 |
6 | const App: React.FC = () => {
7 | return (
8 |
9 | );
10 | };
11 |
12 | export default App;
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # reactflow-ts
2 |
3 | ## Dev
4 | * compile
5 | * ```npm run tsc```
6 | * lint
7 | * ```npm run lint```
8 | * hold
9 | * ```npm run dev```
10 | * vitest
11 | * ```npm run test```
12 |
13 | ## Serve
14 | ``` bash
15 | npm run build
16 | npm run preview -- --host 0.0.0.0 --port 3000
17 | ```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | package-lock.json
2 |
3 | # Logs
4 | logs
5 | *.log
6 | npm-debug.log*
7 | yarn-debug.log*
8 | yarn-error.log*
9 | pnpm-debug.log*
10 | lerna-debug.log*
11 |
12 | node_modules
13 | dist
14 | dist-ssr
15 | *.local
16 |
17 | # Editor directories and files
18 | .vscode/*
19 | !.vscode/extensions.json
20 | .idea
21 | .DS_Store
22 | *.suo
23 | *.ntvs*
24 | *.njsproj
25 | *.sln
26 | *.sw?
27 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20
2 |
3 | # Create and set the working directory
4 | WORKDIR /app
5 |
6 | # Copy all files to the container
7 | COPY . .
8 |
9 | # Install dependencies
10 | RUN npm install
11 |
12 | RUN npm run build
13 |
14 | # Expose the port the app runs on
15 | EXPOSE 3000
16 |
17 | # Start the application
18 | CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0", "--port", "3000"]
--------------------------------------------------------------------------------
/src/Doc/DocPage.tsx:
--------------------------------------------------------------------------------
1 | // Graph/Doc.tsx
2 |
3 | import React from 'react';
4 |
5 | const DocPage: React.FC = () => {
6 | return (
7 |
12 | );
13 | };
14 |
15 | export default DocPage;
--------------------------------------------------------------------------------
/src/graph/GraphApp.css:
--------------------------------------------------------------------------------
1 | /* GraphApp.css */
2 |
3 | .react-flow__controls button svg {
4 | fill: black;
5 | stroke: black;
6 | }
7 |
8 | .react-flow__background {
9 | background-color: #f0f0f0; /* Example: light gray background */
10 | /* You can also add a background pattern: */
11 | /* background-image: repeating-linear-gradient(45deg, #e0e0e0, #e0e0e0 10px, #f0f0f0 10px, #f0f0f0 20px); */
12 | }
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | // tailwind.config.js
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | content: [
6 | "./src/**/*.{js,jsx,ts,tsx}",
7 | ],
8 | theme: {
9 | extend: {
10 | colors: {
11 | primary: '#007bff',
12 | secondary: '#333',
13 | lightGray: '#ddd',
14 | background: '#f9f9f9',
15 | },
16 | },
17 | },
18 | plugins: [],
19 | };
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | LangGraph-GUI
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/setupTests.ts:
--------------------------------------------------------------------------------
1 | // setupTests.ts
2 |
3 | import "@testing-library/jest-dom"; // important to import this for rendering react components
4 |
5 | if (typeof ResizeObserver === 'undefined') {
6 | global.ResizeObserver = class ResizeObserver {
7 | observe() {
8 | // do nothing
9 | }
10 | unobserve() {
11 | // do nothing
12 | }
13 | disconnect() {
14 | // do nothing
15 | }
16 | };
17 | }
--------------------------------------------------------------------------------
/src/redux/store.ts:
--------------------------------------------------------------------------------
1 | // redux/store.ts
2 |
3 | import { configureStore, } from '@reduxjs/toolkit';
4 | import userInfoReducer from './userInfo.store';
5 |
6 | export const store = configureStore(
7 | {
8 | reducer: {
9 | userInfo: userInfoReducer,
10 | },
11 | },
12 | );
13 |
14 | // Optional: Attach store to the window object for debugging (use conditionally)
15 | if (process.env.NODE_ENV === 'DEBUG') {
16 | (window as any).store = store;
17 | }
18 |
19 | export type RootState = ReturnType;
20 | export type AppDispatch = typeof store.dispatch;
21 | export type AppStore = typeof store;
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | // main.tsx
2 |
3 | import { StrictMode } from 'react'
4 | import { createRoot } from 'react-dom/client'
5 | import './index.css'
6 | import App from './App.tsx'
7 |
8 | import { Provider } from 'react-redux';
9 | import {store} from "./redux/store.ts"
10 | import { ReactFlowProvider } from '@xyflow/react';
11 | import { GraphProvider } from './Graph/GraphContext';
12 |
13 |
14 | createRoot(document.getElementById('root')!).render(
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | ,
24 | )
--------------------------------------------------------------------------------
/src/graph/ResizeIcon.tsx:
--------------------------------------------------------------------------------
1 | // ResizeIcon.tsx
2 |
3 | function ResizeIcon(): JSX.Element {
4 | return (
5 |
23 | );
24 | }
25 |
26 | export default ResizeIcon;
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true,
24 | "exactOptionalPropertyTypes": true,
25 | "noImplicitOverride": true
26 | },
27 | "include": ["src", "setupTests.ts"],
28 | "exclude": ["node_modules"]
29 | }
--------------------------------------------------------------------------------
/src/graph/GraphPanel.css:
--------------------------------------------------------------------------------
1 | .dropdown-menu {
2 | background-color: white;
3 | border: 1px solid #e2e8f0;
4 | border-radius: 0.25rem;
5 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1);
6 | color: black;
7 | }
8 |
9 | .dropdown-menu button {
10 | color: black; /* Ensure text is black */
11 | background-color: white; /* set default background white */
12 | }
13 |
14 | .dropdown-menu button:hover {
15 | background-color: #f3f4f6; /* Tailwind's gray-100 */
16 | }
17 |
18 | .dropdown-menu select {
19 | color: black; /* Ensure text is black */
20 | background-color: white; /* set default background white */
21 | -webkit-appearance: none;
22 | -moz-appearance: none;
23 | appearance: none;
24 | }
25 |
26 | .dropdown-menu select option {
27 | color: black;
28 | background-color: white;
29 | }
30 |
31 | .dropdown-menu select option:hover {
32 | background-color: #f3f4f6;
33 | }
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | 'indent': ['error', 4],
27 | // Disable the no-explicit-any rule
28 | '@typescript-eslint/no-explicit-any': 'off',
29 | },
30 | },
31 | )
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 HomunMage
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import { defineConfig } from "vite";
3 | import react from "@vitejs/plugin-react";
4 |
5 | type BackendUrlMode = 'development' | 'production';
6 |
7 | const backendUrls: Record = {
8 | development: 'http://localhost:5000', // Docker Compose
9 | production: 'http://yourdomain.com' // K8s deploy
10 | };
11 |
12 | export default defineConfig(({ mode }) => {
13 | const safeMode = mode as BackendUrlMode;
14 | const backendUrl = backendUrls[safeMode] || backendUrls.production;
15 |
16 | return {
17 | plugins: [react()],
18 | server: {
19 | host: '0.0.0.0',
20 | port: 3000,
21 | allowedHosts: [
22 | 'localhost',
23 | '127.0.0.1',
24 | 'yourdomain.com',
25 | ],
26 | },
27 | test: {
28 | globals: true,
29 | environment: "jsdom",
30 | setupFiles: "./setupTests.ts",
31 | },
32 | define: {
33 | 'import.meta.env.VITE_BACKEND_URL': JSON.stringify(backendUrl)
34 | }
35 | }
36 | })
--------------------------------------------------------------------------------
/src/routes/AppRoutes.tsx:
--------------------------------------------------------------------------------
1 | // routes/AppRoutes.tsx
2 |
3 | import React from 'react';
4 | import { BrowserRouter, Routes, Route } from 'react-router-dom';
5 |
6 | import GraphApp from '../Graph/GraphApp';
7 | import MenuLayout from '../GraphMenu/MenuLayout';
8 | import DocPage from '../Doc/DocPage';
9 |
10 | // Example Components
11 | const HomePage = () => Home Page
;
12 | const NotFoundPage = () => 404 Not Found
;
13 |
14 | const AppRoutes: React.FC = () => {
15 | return (
16 |
17 |
18 | {/* Apply MenuLayout ONLY on the root (/) */}
19 | }>
20 | } />
21 |
22 |
23 | {/* Other paths, without MenuLayout */}
24 | } />
25 | } />
26 |
27 | {/* Catch-all for 404 */}
28 | } />
29 |
30 |
31 | );
32 | };
33 |
34 | export default AppRoutes;
35 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "langgraph-gui",
3 | "private": true,
4 | "version": "1.3.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "NODE_ENV=DEBUG vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint --fix .",
10 | "preview": "vite preview",
11 | "tsc": "tsc -p tsconfig.app.json",
12 | "test": "vitest"
13 | },
14 | "dependencies": {
15 | "@redux-devtools/extension": "^3.3.0",
16 | "@reduxjs/toolkit": "^2.5.0",
17 | "@xyflow/react": "^12.3.6",
18 | "react": "^18.3.1",
19 | "react-dom": "^18.3.1",
20 | "react-redux": "^9.2.0",
21 | "react-router-dom": "^7.1.1",
22 | "uuid": "^11.0.5"
23 | },
24 | "devDependencies": {
25 | "@eslint/js": "^9.17.0",
26 | "@testing-library/jest-dom": "^6.6.3",
27 | "@testing-library/react": "^16.2.0",
28 | "@types/node": "^22.10.10",
29 | "@types/react": "^18.3.18",
30 | "@types/react-dom": "^18.3.5",
31 | "@vitejs/plugin-react": "^4.3.4",
32 | "autoprefixer": "^10.4.20",
33 | "eslint": "^9.17.0",
34 | "eslint-plugin-react-hooks": "^5.0.0",
35 | "eslint-plugin-react-refresh": "^0.4.16",
36 | "globals": "^15.14.0",
37 | "jsdom": "^26.0.0",
38 | "postcss": "^8.4.49",
39 | "tailwindcss": "^3.4.17",
40 | "typescript": "~5.6.2",
41 | "typescript-eslint": "^8.18.2",
42 | "vite": "^6.0.5",
43 | "vitest": "^3.0.2"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/main.test.tsx:
--------------------------------------------------------------------------------
1 | // src/main.test.tsx
2 | import { render, screen } from '@testing-library/react';
3 | import { expect, describe, it } from 'vitest';
4 |
5 | import { Provider } from 'react-redux';
6 | import { store } from "./redux/store";
7 | import { ReactFlowProvider } from '@xyflow/react';
8 | import { GraphProvider } from './Graph/GraphContext';
9 | import { StrictMode } from 'react';
10 | import App from './App';
11 |
12 | describe('Application Rendering', () => {
13 | it('renders the main application with providers', () => {
14 | render(
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 |
28 | // Now you can assert that elements from your App or child components are rendered.
29 | // Example: Replace this with your actual test assertion.
30 | // This example assumes you have an element with the text "My App" inside App component or child.
31 | const appElement = screen.getByRole("main");
32 | expect(appElement).toBeInTheDocument();
33 | });
34 | });
--------------------------------------------------------------------------------
/src/redux/userInfo.store.ts:
--------------------------------------------------------------------------------
1 | // redux/userInfo.store.ts
2 |
3 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
4 |
5 | interface UserInfoState {
6 | user_id: string;
7 | llmModel: string;
8 | apiKey: string;
9 | }
10 |
11 | const initialState: UserInfoState = {
12 | user_id: localStorage.getItem('user_id') || '',
13 | llmModel: localStorage.getItem('llmModel') || '',
14 | apiKey: localStorage.getItem('apiKey') || '',
15 | };
16 |
17 | const userInfoSlice = createSlice({
18 | name: 'userInfo',
19 | initialState,
20 | reducers: {
21 | setSettings: (state, action: PayloadAction<{ newLlmModel: string; newapiKey: string; newUserId: string }>) => {
22 | const { newLlmModel, newapiKey, newUserId } = action.payload;
23 | state.llmModel = newLlmModel;
24 | state.apiKey = newapiKey;
25 | state.user_id = newUserId;
26 |
27 | localStorage.setItem('llmModel', newLlmModel);
28 | localStorage.setItem('apiKey', newapiKey);
29 | localStorage.setItem('user_id', newUserId);
30 | },
31 | setUserId: (state, action: PayloadAction) => {
32 | state.user_id = action.payload;
33 | localStorage.setItem('user_id', action.payload);
34 | }
35 | },
36 | });
37 |
38 | export const { setSettings, setUserId } = userInfoSlice.actions;
39 | export default userInfoSlice.reducer;
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
7 | line-height: 1.5;
8 | font-weight: 400;
9 |
10 | color-scheme: light dark;
11 | color: rgba(255, 255, 255, 0.87);
12 | background-color: #242424;
13 |
14 | font-synthesis: none;
15 | text-rendering: optimizeLegibility;
16 | -webkit-font-smoothing: antialiased;
17 | -moz-osx-font-smoothing: grayscale;
18 | }
19 |
20 | a {
21 | font-weight: 500;
22 | color: #646cff;
23 | text-decoration: inherit;
24 | }
25 | a:hover {
26 | color: #535bf2;
27 | }
28 |
29 | body {
30 | margin: 0;
31 | display: flex;
32 | place-items: center;
33 | min-width: 320px;
34 | min-height: 100vh;
35 | }
36 |
37 | h1 {
38 | font-size: 3.2em;
39 | line-height: 1.1;
40 | }
41 |
42 | button {
43 | border-radius: 8px;
44 | border: 1px solid transparent;
45 | padding: 0.6em 1.2em;
46 | font-size: 1em;
47 | font-weight: 500;
48 | font-family: inherit;
49 | background-color: #1a1a1a;
50 | cursor: pointer;
51 | transition: border-color 0.25s;
52 | }
53 | button:hover {
54 | border-color: #646cff;
55 | }
56 | button:focus,
57 | button:focus-visible {
58 | outline: 4px auto -webkit-focus-ring-color;
59 | }
60 |
61 | @media (prefers-color-scheme: light) {
62 | :root {
63 | color: #213547;
64 | background-color: #ffffff;
65 | }
66 | a:hover {
67 | color: #747bff;
68 | }
69 | button {
70 | background-color: #f9f9f9;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/graph/CustomEdge.tsx:
--------------------------------------------------------------------------------
1 | // Graph/CustomEdge.tsx
2 |
3 | import React from 'react';
4 | import { EdgeProps, getBezierPath, Position } from '@xyflow/react';
5 |
6 | interface CustomEdgeProps extends Omit {
7 | sourcePosition: Position;
8 | sourceNode: string;
9 | targetNode: string;
10 | }
11 |
12 | const CustomEdge: React.FC = ({
13 | id,
14 | sourceX,
15 | sourceY,
16 | targetX,
17 | targetY,
18 | sourcePosition,
19 | targetPosition,
20 | style,
21 | sourceNode,
22 | targetNode
23 | }) => {
24 |
25 | const edgePathArray = getBezierPath({
26 | sourceX,
27 | sourceY,
28 | targetX,
29 | targetY,
30 | sourcePosition,
31 | targetPosition
32 | });
33 |
34 |
35 | const edgePath = Array.isArray(edgePathArray) ? edgePathArray[0] : "";
36 |
37 | let strokeColor = 'gray';
38 |
39 | if (sourcePosition === Position.Top) {
40 | strokeColor = 'green';
41 | } else if (sourcePosition === Position.Bottom) {
42 | strokeColor = 'red';
43 | }
44 |
45 | const edgeStyle = {
46 | ...style,
47 | strokeWidth: 4,
48 | stroke: strokeColor,
49 | };
50 |
51 | const markerEndId = `arrowhead-${id}`;
52 | const markerFillColor = strokeColor;
53 |
54 |
55 |
56 | return (
57 | <>
58 |
59 |
69 |
70 |
71 |
72 |
82 | >
83 | );
84 | };
85 |
86 | export default React.memo(CustomEdge);
87 |
--------------------------------------------------------------------------------
/src/utils/ConfigManager.ts:
--------------------------------------------------------------------------------
1 | // ConfigManager.ts
2 |
3 | interface ConfigSettings {
4 | username: string;
5 | llmModel: string;
6 | apiKey: string;
7 | }
8 |
9 | class ConfigManager {
10 | private static instance: ConfigManager;
11 | private llmModel: string = 'gpt';
12 | private apiKey: string = '';
13 | private username: string = 'unknown';
14 |
15 | constructor() {
16 | if (ConfigManager.instance) {
17 | return ConfigManager.instance;
18 | }
19 |
20 | const storedLlmModel = localStorage.getItem('llmModel');
21 | if(storedLlmModel) {
22 | this.llmModel = storedLlmModel;
23 | }
24 |
25 | const storedApiKey = localStorage.getItem('apiKey');
26 | if(storedApiKey) {
27 | this.apiKey = storedApiKey;
28 | }
29 |
30 | this.fetchUsername(); // Initiate username fetch
31 |
32 | ConfigManager.instance = this;
33 | }
34 |
35 | // Method to fetch username from Nginx API
36 | private async fetchUsername() {
37 | try {
38 | const response = await fetch('/api/username', {
39 | method: 'GET',
40 | headers: {
41 | 'Content-Type': 'application/json',
42 | },
43 | });
44 |
45 | if (response.ok) {
46 | const data = await response.json() as {username: string};
47 | this.username = data.username;
48 | } else {
49 | console.error('Failed to fetch username:', response.status);
50 | }
51 | } catch (error) {
52 | if(error instanceof Error) {
53 | console.error('Error fetching username:', error.message);
54 | } else {
55 | console.error('Error fetching username:', error)
56 | }
57 | }
58 | }
59 |
60 |
61 | // Method to get the current settings
62 | getSettings(): ConfigSettings {
63 | return {
64 | username: this.username,
65 | llmModel: this.llmModel,
66 | apiKey: this.apiKey,
67 | };
68 | }
69 |
70 | // Method to update settings
71 | setSettings(newLlmModel: string, newapiKey: string): void {
72 | this.llmModel = newLlmModel;
73 | this.apiKey = newapiKey;
74 |
75 | localStorage.setItem('llmModel', newLlmModel);
76 | localStorage.setItem('apiKey', newapiKey);
77 | }
78 | }
79 |
80 | const instance = new ConfigManager();
81 | export default instance;
--------------------------------------------------------------------------------
/src/utils/JsonIO.ts:
--------------------------------------------------------------------------------
1 | // utils/JsonIO.ts
2 |
3 | export const saveJsonToFile = (filename: string, JsonData: any): void => {
4 | try {
5 | const blob = new Blob([JSON.stringify(JsonData, null, 2)], {
6 | type: 'application/json',
7 | });
8 | const url = URL.createObjectURL(blob);
9 | const a = document.createElement('a');
10 | a.href = url;
11 | a.download = filename;
12 | document.body.appendChild(a);
13 | a.click();
14 | document.body.removeChild(a);
15 | URL.revokeObjectURL(url);
16 | alert('File saving!');
17 | } catch (error) {
18 | console.error('Error saving JSON:', error);
19 | alert('Failed to save file.');
20 | }
21 | };
22 |
23 | export const loadJsonFromFile = (): Promise => {
24 | return new Promise((resolve, reject) => {
25 | const fileInput = document.createElement('input');
26 | fileInput.type = 'file';
27 | fileInput.accept = '.json';
28 | fileInput.style.display = 'none';
29 | document.body.appendChild(fileInput);
30 |
31 | fileInput.addEventListener('change', async (event) => {
32 | try {
33 | const file = (event.target as HTMLInputElement).files?.[0];
34 | if (!file) {
35 | reject(new Error('No file selected.'));
36 | return;
37 | }
38 | const reader = new FileReader();
39 | reader.onload = async (e) => {
40 | try {
41 | const contents = e.target?.result;
42 | if (typeof contents === 'string') {
43 | const parsedData = JSON.parse(contents);
44 | resolve(parsedData);
45 | } else {
46 | reject(new Error('File contents are not a string.'));
47 | }
48 |
49 | } catch (error) {
50 | reject(new Error('Error parsing JSON.' + error));
51 | }
52 | };
53 | reader.onerror = () => reject(new Error('Error reading file.'));
54 | reader.readAsText(file);
55 | } catch (error) {
56 | console.error("Error during file handling:", error);
57 | reject(new Error('Error loading JSON:' + error));
58 | } finally {
59 | document.body.removeChild(fileInput);
60 | }
61 | });
62 | fileInput.click();
63 | });
64 | };
--------------------------------------------------------------------------------
/src/graph/NodeData.ts:
--------------------------------------------------------------------------------
1 | // NodeData.ts
2 |
3 | export interface ReactFlowNodeEXT {
4 | type: string;
5 | name?: string | undefined;
6 | description?: string | undefined;
7 | tool?: string | undefined;
8 | prevs?: string[];
9 | nexts?: string[];
10 | true_next?: string | null | undefined;
11 | false_next?: string | null | undefined;
12 | info?: string | null;
13 | }
14 |
15 | export interface ReactNodeProps {
16 | id: string;
17 | width: number;
18 | height: number;
19 | position: { x: number, y: number }
20 | data: ReactFlowNodeEXT;
21 | onNodeDataChange?: (id: string, newData: ReactFlowNodeEXT) => void;
22 | }
23 |
24 | export interface JsonNodeData {
25 | uniq_id: string;
26 | name: string;
27 | description: string;
28 | nexts: string[];
29 | type?: string;
30 | tool: string;
31 | true_next: string | null;
32 | false_next: string | null;
33 | ext: {
34 | pos_x?: number;
35 | pos_y?: number;
36 | width?: number;
37 | height?: number;
38 | info?: string | null;
39 | };
40 | }
41 |
42 |
43 | export const JsonToReactNode = (jsonData: JsonNodeData, position?: { x: number, y: number }): ReactNodeProps => {
44 | const { uniq_id, ext, ...rest } = jsonData;
45 |
46 | const reactNodeData: ReactFlowNodeEXT = {
47 | type: rest.type || "STEP",
48 | name: rest.name,
49 | description: rest.description,
50 | tool: rest.tool,
51 | nexts: rest.nexts || [],
52 | true_next: rest.true_next,
53 | false_next: rest.false_next,
54 | info: ext?.info ?? null,
55 | prevs: [],
56 | };
57 |
58 | return {
59 | id: uniq_id,
60 | width: ext?.width ?? 200,
61 | height: ext?.height ?? 200,
62 | position: position ||
63 | {
64 | x: ext?.pos_x || 0,
65 | y: ext?.pos_y || 0
66 | },
67 | data: reactNodeData,
68 | };
69 | };
70 |
71 | export const ReactToJsonNode = (reactNode: ReactNodeProps): JsonNodeData => {
72 | const { id, data, width, height, position } = reactNode;
73 | const { type, name, description, tool, nexts, true_next, false_next, info } = data;
74 |
75 | const ext: JsonNodeData['ext'] = {
76 | pos_x: position.x,
77 | pos_y: position.y,
78 | width,
79 | height,
80 | info: info === undefined ? null : info // Set info to null if undefined
81 | };
82 |
83 | return {
84 | uniq_id: id,
85 | type,
86 | name: name || "",
87 | description: description || "",
88 | tool: tool || "",
89 | nexts: nexts || [],
90 | true_next: true_next == undefined ? null : true_next,
91 | false_next: false_next == undefined ? null : false_next,
92 | ext
93 | };
94 | };
--------------------------------------------------------------------------------
/src/graph/GraphContext.test.tsx:
--------------------------------------------------------------------------------
1 | // GraphContext.test.tsx
2 | import React from 'react';
3 | import { render, screen, act } from '@testing-library/react';
4 | import { useGraph, GraphProvider } from './GraphContext';
5 | import { describe, it, expect } from 'vitest';
6 |
7 | const TestComponent: React.FC = () => {
8 | const graphContext = useGraph();
9 |
10 | return (
11 |
12 |
13 |
14 |
SubGraph Count: {graphContext.subGraphs.length}
15 |
Has Root Graph: {graphContext.subGraphs.some(graph => graph.graphName === 'root') ? 'true' : 'false'}
16 |
Has Test Graph: {graphContext.subGraphs.some(graph => graph.graphName === 'test') ? 'true' : 'false'}
17 |
18 | );
19 | };
20 |
21 | describe('GraphContext', () => {
22 | it('should manage subgraphs correctly', async () => {
23 | render(
24 |
25 |
26 |
27 | );
28 |
29 | const testComponent = screen.getByTestId("test-component");
30 | expect(testComponent).toBeInTheDocument();
31 |
32 | const addRootButton = screen.getByTestId("add-root-button");
33 | const addSubGraphButton = screen.getByTestId("add-subgraph-button");
34 |
35 | // Add root and test subgraph
36 | await act(async () => {
37 | addRootButton.click();
38 | });
39 | await act(async () => {
40 | addSubGraphButton.click();
41 | });
42 |
43 | let subGraphCountElement = screen.getByTestId("subgraph-count");
44 | expect(subGraphCountElement).toHaveTextContent("SubGraph Count: 2");
45 |
46 | let hasRootElement = screen.getByTestId("has-root-graph");
47 | expect(hasRootElement).toHaveTextContent("Has Root Graph: true");
48 |
49 | let hasTestElement = screen.getByTestId("has-test-graph");
50 | expect(hasTestElement).toHaveTextContent("Has Test Graph: true");
51 |
52 | // Try adding root again, should not change the count
53 | await act(async () => {
54 | addRootButton.click();
55 | });
56 | subGraphCountElement = screen.getByTestId("subgraph-count");
57 | expect(subGraphCountElement).toHaveTextContent("SubGraph Count: 2");
58 |
59 | hasRootElement = screen.getByTestId("has-root-graph");
60 | expect(hasRootElement).toHaveTextContent("Has Root Graph: true");
61 | hasTestElement = screen.getByTestId("has-test-graph");
62 | expect(hasTestElement).toHaveTextContent("Has Test Graph: true");
63 | });
64 | });
--------------------------------------------------------------------------------
/src/GraphMenu/MenuLayout.tsx:
--------------------------------------------------------------------------------
1 | // GraphMenu/MenuLayout.tsx
2 | import React, { useState, useRef } from "react";
3 | import { Outlet } from 'react-router-dom';
4 | import MenuToggleButton from './MenuToggleButton';
5 | import RunWindow from './RunWindow';
6 | import ConfigWindow from './ConfigWindow'; // Import ConfigWindow
7 |
8 |
9 | const MenuLayout: React.FC = () => {
10 | const [menuOpen, setMenuOpen] = useState(false);
11 | const [isRunWindowOpen, setIsRunWindowOpen] = useState(false);
12 | const [isConfigWindowOpen, setIsConfigWindowOpen] = useState(false); // Config window state
13 | const menuRef = useRef(null);
14 |
15 | const toggleMenu = () => {
16 | setMenuOpen(!menuOpen);
17 | };
18 | const closeMenu = () => {
19 | setMenuOpen(false);
20 | };
21 | const openRunWindow = () => {
22 | setIsRunWindowOpen(true);
23 | };
24 | const closeRunWindow = () => {
25 | setIsRunWindowOpen(false);
26 | };
27 |
28 | const openConfigWindow = () => { // Open config window
29 | setIsConfigWindowOpen(true);
30 | };
31 |
32 | const closeConfigWindow = () => { // Close config window
33 | setIsConfigWindowOpen(false);
34 | };
35 |
36 | return (
37 |
38 |
41 |
42 |
43 |
46 |
47 | {/* Pass openConfigWindow */}
48 |
49 |
50 |
51 |
60 | {isRunWindowOpen &&
}
61 | {isConfigWindowOpen &&
} {/* Render ConfigWindow */}
62 |
63 | );
64 | };
65 |
66 | export default MenuLayout;
--------------------------------------------------------------------------------
/src/GraphMenu/FileTransmit.ts:
--------------------------------------------------------------------------------
1 | // GraphMenu/FileTransmit.ts
2 |
3 | import ConfigManager from '../utils/ConfigManager';
4 |
5 | export const handleUpload = async (files: FileList | null) => {
6 | const { username } = ConfigManager.getSettings();
7 |
8 | const SERVER_URL = import.meta.env.VITE_BACKEND_URL;
9 |
10 | if (!files || files.length === 0) {
11 | alert("No files selected for upload.");
12 | return;
13 | }
14 |
15 | if (!username) {
16 | alert("Username is not set. Please configure your settings.");
17 | return;
18 | }
19 |
20 |
21 | const formData = new FormData();
22 | for (const file of files) {
23 | formData.append('files', file);
24 | }
25 |
26 | try {
27 | const response = await fetch(`${SERVER_URL}/upload/${encodeURIComponent(username)}`, {
28 | method: 'POST',
29 | body: formData,
30 | });
31 |
32 | if (response.ok) {
33 | alert('Files successfully uploaded');
34 | } else {
35 | const errorData = await response.json();
36 | alert('Upload failed: ' + errorData.error);
37 | }
38 | } catch (error: any) {
39 | alert('Upload failed: ' + error.message);
40 | }
41 | };
42 |
43 |
44 | export const handleDownload = async () => {
45 | const { username } = ConfigManager.getSettings();
46 |
47 | const SERVER_URL = import.meta.env.VITE_BACKEND_URL;
48 |
49 |
50 | if (!username) {
51 | alert("Username is not set. Please configure your settings.");
52 | return;
53 | }
54 | try {
55 | const response = await fetch(`${SERVER_URL}/download/${encodeURIComponent(username)}`);
56 |
57 | if (response.ok) {
58 | const blob = await response.blob();
59 | const url = URL.createObjectURL(blob);
60 | const a = document.createElement('a');
61 | a.href = url;
62 | a.download = `${username}_workspace.zip`;
63 | a.click();
64 | URL.revokeObjectURL(url);
65 | } else {
66 | const errorData = await response.json();
67 | alert('Download failed: ' + errorData.error);
68 | }
69 | } catch (error: any) {
70 | alert('Download failed: ' + error.message);
71 | }
72 | };
73 |
74 |
75 | export const handleCleanCache = async () => {
76 |
77 | const SERVER_URL = import.meta.env.VITE_BACKEND_URL;
78 |
79 |
80 | const { username } = ConfigManager.getSettings();
81 | if (!username) {
82 | alert("Username is not set. Please configure your settings.");
83 | return;
84 | }
85 | try {
86 | const response = await fetch(`${SERVER_URL}/clean-cache/${encodeURIComponent(username)}`, {
87 | method: 'POST'
88 | });
89 |
90 | if (response.ok) {
91 | alert('Cache successfully cleaned');
92 | } else {
93 | const errorData = await response.json();
94 | alert('Clean cache failed: ' + errorData.error);
95 | }
96 | } catch (error: any) {
97 | alert('Clean cache failed: ' + error.message);
98 | }
99 | };
--------------------------------------------------------------------------------
/src/GraphMenu/ConfigWindow.tsx:
--------------------------------------------------------------------------------
1 | // GraphMenu/ConfigWindow.tsx
2 |
3 | import { useState } from 'react';
4 | import ConfigManager from '../utils/ConfigManager';
5 |
6 | interface ConfigWindowProps {
7 | onClose: () => void;
8 | }
9 |
10 | function ConfigWindow({ onClose }: ConfigWindowProps) {
11 | const settings = ConfigManager.getSettings();
12 |
13 | const [username] = useState(settings.username);
14 | const [llmModel, setLlmModel] = useState(settings.llmModel);
15 | const [apiKey, setAPIKey] = useState(settings.apiKey);
16 |
17 | const handleSave = () => {
18 | ConfigManager.setSettings(llmModel, apiKey);
19 | onClose();
20 | };
21 |
22 | return (
23 |
24 |
25 |
Settings
26 |
27 |
36 |
37 |
38 |
47 |
48 |
49 |
58 |
59 |
60 |
66 |
72 |
73 |
74 |
75 | );
76 | }
77 |
78 | export default ConfigWindow;
--------------------------------------------------------------------------------
/src/GraphMenu/MenuToggleButton.tsx:
--------------------------------------------------------------------------------
1 | // GraphMenu/MenuToggleButton.tsx
2 |
3 | import React, { useRef } from 'react';
4 | import ConfigManager from '../utils/ConfigManager';
5 | import { handleUpload, handleDownload, handleCleanCache } from './FileTransmit';
6 |
7 |
8 | interface MenuToggleButtonProps {
9 | openRunWindow: () => void;
10 | openConfigWindow: () => void;
11 | }
12 |
13 | const MenuToggleButton: React.FC = ({ openRunWindow, openConfigWindow }) => {
14 | const { username } = ConfigManager.getSettings();
15 | const fileInputRef = useRef(null);
16 |
17 | const handleRunClick = () => {
18 | console.log('run');
19 | openRunWindow();
20 | // Handle run logic here (open the RunWindow)
21 | };
22 |
23 | const handleConfigClick = () => {
24 | openConfigWindow();
25 | }
26 |
27 | const handleDocumentationClick = () => {
28 | window.open("https://langgraph-gui.github.io/", "_blank");
29 | };
30 |
31 |
32 | const UsernameValid = username === 'unknown';
33 |
34 |
35 | return (
36 |
37 |
40 | {`User: ${username}`}
41 |
42 |
43 |
{
49 | await handleUpload(e.target.files);
50 | if (fileInputRef.current) {
51 | fileInputRef.current.value = '';
52 | }
53 |
54 | }}
55 | />
56 |
59 |
67 |
72 |
77 |
80 |
83 |
84 | );
85 | };
86 |
87 | export default MenuToggleButton;
--------------------------------------------------------------------------------
/src/graph/JsonUtil.tsx:
--------------------------------------------------------------------------------
1 | // Graph/JsonUtil.tsx
2 |
3 | import { ReactToJsonNode, ReactNodeProps, ReactFlowNodeEXT, JsonToReactNode, JsonNodeData } from './NodeData';
4 | import { SubGraph } from './GraphContext';
5 | import { Node, Edge } from '@xyflow/react';
6 |
7 | // Type guard to check if an object is a ReactFlowNodeEXT
8 | function isReactFlowNodeEXT(data: any): data is ReactFlowNodeEXT {
9 | return (
10 | typeof data === 'object' &&
11 | data !== null &&
12 | typeof (data as ReactFlowNodeEXT).type === 'string'
13 | );
14 | }
15 |
16 | export const subGraphToJson = (subGraph: SubGraph) => {
17 | const jsonNodes = subGraph.nodes.map(node => {
18 | let nodeData: ReactFlowNodeEXT;
19 | if (isReactFlowNodeEXT(node.data)) {
20 | nodeData = node.data;
21 | } else {
22 | // Handle the case where node.data is not a ReactFlowNodeEXT
23 | console.error("Invalid node data:", node.data);
24 | nodeData = { type: "STEP" }; // Providing default values to avoid potential errors
25 | }
26 | const reactNodeProps: ReactNodeProps = {
27 | id: node.id,
28 | width: node.width || 200,
29 | height: node.height || 200,
30 | position: node.position,
31 | data: nodeData,
32 | };
33 | return ReactToJsonNode(reactNodeProps);
34 | });
35 |
36 | return {
37 | name: subGraph.graphName,
38 | nodes: jsonNodes,
39 | serial_number: subGraph.serial_number
40 | }
41 | };
42 |
43 | export const allSubGraphsToJson = (subGraphs: SubGraph[]) => {
44 | return subGraphs.map(subGraphToJson);
45 | };
46 |
47 |
48 | export interface JsonSubGraph {
49 | name: string;
50 | nodes: JsonNodeData[];
51 | serial_number: number;
52 | }
53 |
54 |
55 | export const jsonToSubGraph = (json: JsonSubGraph): SubGraph => {
56 | const nodes: Node[] = json.nodes.map(nodeJson => {
57 | const reactNodeProps = JsonToReactNode(nodeJson, { x: nodeJson.ext?.pos_x || 0, y: nodeJson.ext?.pos_y || 0 });
58 | const { data, ...rest } = reactNodeProps;
59 | return {
60 | type: 'custom',
61 | ...rest,
62 | data: data as unknown as Record,
63 | };
64 | });
65 |
66 | const edges: Edge[] = [];
67 |
68 | nodes.forEach(node => {
69 | const nodeData = node.data as any;
70 |
71 |
72 | if (nodeData.nexts && Array.isArray(nodeData.nexts)) {
73 | nodeData.nexts.forEach((nextId:string) => {
74 | const newEdge: Edge = {
75 | id: `${node.id}-${nextId}`,
76 | source: node.id,
77 | target: nextId,
78 | type: 'custom',
79 | data: {
80 | sourceNode: node.id,
81 | targetNode: nextId
82 | }
83 | };
84 | edges.push(newEdge);
85 | });
86 | }
87 |
88 | if (nodeData.true_next) {
89 | const newEdge: Edge = {
90 | id: `${node.id}-${nodeData.true_next}-true`,
91 | source: node.id,
92 | target: nodeData.true_next,
93 | sourceHandle: 'true',
94 | type: 'custom',
95 | data: {
96 | sourceNode: node.id,
97 | targetNode: nodeData.true_next
98 | }
99 | };
100 | edges.push(newEdge);
101 | }
102 | if (nodeData.false_next) {
103 | const newEdge: Edge = {
104 | id: `${node.id}-${nodeData.false_next}-false`,
105 | source: node.id,
106 | target: nodeData.false_next,
107 | sourceHandle: 'false',
108 | type: 'custom',
109 | data: {
110 | sourceNode: node.id,
111 | targetNode: nodeData.false_next
112 | }
113 | };
114 | edges.push(newEdge);
115 | }
116 | });
117 |
118 |
119 | return {
120 | graphName: json.name,
121 | nodes,
122 | edges,
123 | serial_number: json.serial_number,
124 | };
125 | };
126 |
127 | export const jsonToSubGraphs = (jsonArray: JsonSubGraph[]): SubGraph[] => {
128 | return jsonArray.map(jsonToSubGraph);
129 | };
--------------------------------------------------------------------------------
/src/graph/GraphContext.tsx:
--------------------------------------------------------------------------------
1 | // Graph/GraphContext.tsx
2 |
3 | import React, { createContext, useState, useContext, useCallback, ReactNode } from 'react';
4 | import { Node, Edge, NodeChange, applyNodeChanges, EdgeChange, applyEdgeChanges } from '@xyflow/react';
5 |
6 | export interface SubGraph {
7 | graphName: string;
8 | nodes: Node[];
9 | edges: Edge[];
10 | serial_number: number;
11 | }
12 |
13 | export interface GraphContextType {
14 | subGraphs: SubGraph[];
15 | currentGraphName: string;
16 | setCurrentGraphName: (graphName: string) => void;
17 | getCurrentGraph: () => SubGraph;
18 | addSubGraph: (graphName: string) => void;
19 | updateSubGraph: (graphName: string, updatedGraph: SubGraph) => void;
20 | removeSubGraph: (graphName: string) => void;
21 | updateNodeData: (graphName: string, nodeId: string, newData: any) => void;
22 | handleNodesChange: (graphName: string, changes: NodeChange[]) => void;
23 | handleEdgesChange: (graphName: string, changes: EdgeChange[]) => void;
24 | }
25 |
26 | const initialGraphData = {
27 | graphName: "root",
28 | nodes: [],
29 | edges: [],
30 | serial_number: 1,
31 | };
32 |
33 | const GraphContext = createContext(undefined);
34 |
35 | export const GraphProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
36 | const [subGraphs, setSubGraphs] = useState([]);
37 | const [currentGraphName, setCurrentGraphNameState] = useState("root");
38 |
39 | const getCurrentGraph = useCallback(():SubGraph => {
40 | const currentGraph = subGraphs.find(graph => graph.graphName === currentGraphName);
41 | return currentGraph || {
42 | graphName: "root",
43 | nodes: [],
44 | edges: [],
45 | serial_number: 1,
46 | };
47 | }, [subGraphs, currentGraphName]);
48 |
49 | const addSubGraph = (graphName: string) => {
50 | setSubGraphs(prevGraphs => {
51 | const graphIndex = prevGraphs.findIndex(graph => graph.graphName === graphName);
52 | if(graphIndex === -1){
53 | const newSubgraphs = [...prevGraphs, {
54 | graphName,
55 | nodes: [],
56 | edges: [],
57 | serial_number: 1,
58 | }]
59 | if(!currentGraphName) setCurrentGraphNameState(graphName);
60 | return newSubgraphs;
61 | } else {
62 | return prevGraphs; // Do nothing, just return the previous state
63 | }
64 | });
65 | };
66 |
67 | const updateSubGraph = (graphName: string, updatedGraph: SubGraph) => {
68 |
69 | setSubGraphs(prevGraphs => {
70 | const graphIndex = prevGraphs.findIndex(graph => graph.graphName === graphName);
71 |
72 | if (graphIndex === -1) {
73 | return [...prevGraphs, updatedGraph]
74 | } else {
75 |
76 | const updateGraph = prevGraphs.map((graph, index) => index === graphIndex ? updatedGraph : graph)
77 | if(currentGraphName === graphName) {
78 |
79 | setCurrentGraphNameState(updatedGraph.graphName);
80 | }
81 | return updateGraph;
82 | }
83 | });
84 | };
85 |
86 | const removeSubGraph = (graphName: string) => {
87 | setSubGraphs(prevGraphs => prevGraphs.filter(graph => graph.graphName !== graphName));
88 | if (currentGraphName === graphName) {
89 | setCurrentGraphNameState("root")
90 | }
91 | };
92 |
93 |
94 | const setCurrentGraphName = (graphName: string) => {
95 | setCurrentGraphNameState(graphName);
96 | };
97 |
98 |
99 | const updateNodeData = (graphName: string, nodeId: string, newData: any) => {
100 | setSubGraphs(prevGraphs => {
101 | return prevGraphs.map(graph => {
102 | if(graph.graphName === graphName){
103 | return {
104 | ...graph,
105 | nodes: graph.nodes.map(node =>{
106 | if(node.id === nodeId){
107 | return {
108 | ...node,
109 | data: {...node.data, ...newData}
110 | }
111 | }
112 | return node;
113 | })
114 | }
115 | }
116 | return graph;
117 | })
118 | })
119 | }
120 |
121 | const handleNodesChange = useCallback((graphName: string, changes: NodeChange[]) => {
122 | setSubGraphs((prevGraphs) => {
123 | return prevGraphs.map(graph => {
124 | if(graph.graphName === graphName){
125 | const updatedNodes = applyNodeChanges(changes, graph.nodes);
126 | return { ...graph, nodes: updatedNodes };
127 | }
128 | return graph;
129 | })
130 | })
131 | }, []);
132 |
133 | const handleEdgesChange = useCallback((graphName: string, changes: EdgeChange[]) => {
134 | setSubGraphs((prevGraphs) => {
135 | return prevGraphs.map(graph => {
136 | if(graph.graphName === graphName){
137 | const updatedEdges = applyEdgeChanges(changes, graph.edges);
138 | return { ...graph, edges: updatedEdges };
139 | }
140 | return graph;
141 | })
142 | })
143 | }, []);
144 |
145 | //Initialize root graph if not exist
146 | React.useEffect(()=>{
147 | const rootGraphExist = subGraphs.find(graph => graph.graphName === "root")
148 | if(!rootGraphExist){
149 | setSubGraphs([{...initialGraphData}]);
150 | }
151 | }, [subGraphs])
152 |
153 | const value = {
154 | subGraphs,
155 | currentGraphName,
156 | setCurrentGraphName,
157 | getCurrentGraph,
158 | addSubGraph,
159 | updateSubGraph,
160 | removeSubGraph,
161 | updateNodeData,
162 | handleNodesChange,
163 | handleEdgesChange,
164 | };
165 |
166 | return (
167 |
168 | {children}
169 |
170 | );
171 | };
172 |
173 | export const useGraph = () => {
174 | const context = useContext(GraphContext);
175 | if (!context) {
176 | throw new Error('useGraph must be used within a GraphProvider');
177 | }
178 | return context;
179 | };
--------------------------------------------------------------------------------
/src/graph/NodeData.fromjson.test.tsx:
--------------------------------------------------------------------------------
1 | // NodeData.fromjson.test.tsx
2 | import { describe, it, expect } from 'vitest';
3 | import { JsonToReactNode, JsonNodeData } from './NodeData';
4 |
5 | describe('JsonToReactNode', () => {
6 | it('should convert JSON node data to React node props correctly', () => {
7 | const jsonNodeData: JsonNodeData = {
8 | uniq_id: '123',
9 | name: 'Test Node',
10 | description: 'This is a test node',
11 | tool: 'testTool',
12 | nexts: ['456'],
13 | true_next: '789',
14 | false_next: null,
15 | type: 'STEP',
16 | ext: {
17 | pos_x: 100,
18 | pos_y: 200,
19 | width: 250,
20 | height: 150,
21 | info: 'Additional info'
22 | }
23 | };
24 |
25 | const expectedReactNodeProps = {
26 | id: '123',
27 | width: 250,
28 | height: 150,
29 | position: { x: 100, y: 200 },
30 | data: {
31 | type: 'STEP',
32 | name: 'Test Node',
33 | description: 'This is a test node',
34 | tool: 'testTool',
35 | nexts: ['456'],
36 | true_next: '789',
37 | false_next: null,
38 | info: 'Additional info',
39 | prevs: [],
40 | }
41 | };
42 |
43 | const reactNodeProps = JsonToReactNode(jsonNodeData);
44 | expect(reactNodeProps).toEqual(expectedReactNodeProps);
45 | });
46 |
47 | it('should handle missing optional fields in JSON data', () => {
48 | const jsonNodeData: JsonNodeData = {
49 | uniq_id: '456',
50 | name: 'Another Node',
51 | description: '',
52 | tool: 'anotherTool',
53 | nexts: [],
54 | true_next: null,
55 | false_next: null,
56 | ext: {}
57 | };
58 |
59 | const expectedReactNodeProps = {
60 | id: '456',
61 | width: 200,
62 | height: 200,
63 | position: { x: 0, y: 0 },
64 | data: {
65 | type: 'STEP',
66 | name: 'Another Node',
67 | description: '',
68 | tool: 'anotherTool',
69 | nexts: [],
70 | true_next: null,
71 | false_next: null,
72 | info: null,
73 | prevs: [],
74 | }
75 | };
76 |
77 | const reactNodeProps = JsonToReactNode(jsonNodeData);
78 | expect(reactNodeProps).toEqual(expectedReactNodeProps);
79 | });
80 |
81 | it('should use a default type if not provided', () => {
82 | const jsonNodeData: JsonNodeData = {
83 | uniq_id: '789',
84 | name: 'Type Missing Node',
85 | description: 'This node has no type',
86 | tool: 'defaultTool',
87 | nexts: [],
88 | true_next: null,
89 | false_next: null,
90 | ext: {
91 | pos_x: 50,
92 | pos_y: 75,
93 | width: 100,
94 | height: 100,
95 | info: null
96 | }
97 | };
98 |
99 | const expectedReactNodeProps = {
100 | id: '789',
101 | width: 100,
102 | height: 100,
103 | position: { x: 50, y: 75 },
104 | data: {
105 | type: 'STEP',
106 | name: 'Type Missing Node',
107 | description: 'This node has no type',
108 | tool: 'defaultTool',
109 | nexts: [],
110 | true_next: null,
111 | false_next: null,
112 | info: null,
113 | prevs: [],
114 | }
115 | };
116 |
117 | const reactNodeProps = JsonToReactNode(jsonNodeData);
118 | expect(reactNodeProps).toEqual(expectedReactNodeProps);
119 | });
120 |
121 | it('should handle info as null correctly', () => {
122 | const jsonNodeData: JsonNodeData = {
123 | uniq_id: '999',
124 | name: 'Info Null Node',
125 | description: 'This node has null info',
126 | tool: 'testTool',
127 | nexts: [],
128 | true_next: null,
129 | false_next: null,
130 | ext: {
131 | pos_x: 100,
132 | pos_y: 200,
133 | width: 200,
134 | height: 100,
135 | info: null,
136 | },
137 | };
138 |
139 | const expectedReactNodeProps = {
140 | id: '999',
141 | width: 200,
142 | height: 100,
143 | position: { x: 100, y: 200 },
144 | data: {
145 | type: 'STEP',
146 | name: 'Info Null Node',
147 | description: 'This node has null info',
148 | tool: 'testTool',
149 | nexts: [],
150 | true_next: null,
151 | false_next: null,
152 | info: null,
153 | prevs: [],
154 | },
155 | };
156 |
157 | const reactNodeProps = JsonToReactNode(jsonNodeData);
158 | expect(reactNodeProps).toEqual(expectedReactNodeProps);
159 | });
160 | it('should handle undefined info correctly', () => {
161 | const jsonNodeData: JsonNodeData = {
162 | uniq_id: '999',
163 | name: 'Info Undefined Node',
164 | description: 'This node has undefined info',
165 | tool: 'testTool',
166 | nexts: [],
167 | true_next: null,
168 | false_next: null,
169 | ext: {
170 | pos_x: 100,
171 | pos_y: 200,
172 | width: 200,
173 | height: 100,
174 | },
175 | };
176 |
177 | const expectedReactNodeProps = {
178 | id: '999',
179 | width: 200,
180 | height: 100,
181 | position: { x: 100, y: 200 },
182 | data: {
183 | type: 'STEP',
184 | name: 'Info Undefined Node',
185 | description: 'This node has undefined info',
186 | tool: 'testTool',
187 | nexts: [],
188 | true_next: null,
189 | false_next: null,
190 | info: null,
191 | prevs: [],
192 | },
193 | };
194 |
195 | const reactNodeProps = JsonToReactNode(jsonNodeData);
196 | expect(reactNodeProps).toEqual(expectedReactNodeProps);
197 | });
198 | });
--------------------------------------------------------------------------------
/src/graph/GraphApp.tsx:
--------------------------------------------------------------------------------
1 | // Graph/GraphApp.tsx
2 |
3 | import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react';
4 | import { ReactFlow, MiniMap, Controls, Background, useReactFlow, ReactFlowProps, NodeChange, EdgeChange, } from '@xyflow/react';
5 | import '@xyflow/react/dist/style.css';
6 | import { useGraph } from './GraphContext';
7 | import GraphPanel from './GraphPanel';
8 | import './GraphApp.css';
9 | import CustomNode from './CustomNode';
10 | import CustomEdge from './CustomEdge';
11 | import { useGraphActions } from './GraphActions';
12 | import { Edge as ReactFlowEdge } from '@xyflow/react';
13 |
14 |
15 | const GraphApp: React.FC = () => {
16 | const { currentGraphName, updateNodeData, handleNodesChange, handleEdgesChange, getCurrentGraph} = useGraph();
17 | const [contextMenu, setContextMenu] = useState<{mouseX: number, mouseY: number, nodeId: string | null, edgeId:string | null, type: 'panel' | 'node' | 'edge'} | null>(null);
18 | const [canvasHeight, setCanvasHeight] = useState(window.innerHeight);
19 | const menuBarRef = useRef(null); //ref for menu bar
20 | const { screenToFlowPosition } = useReactFlow();
21 |
22 | const { handleAddNode, handleDeleteNode, handleDeleteEdge, handlePanelContextMenu, handleAddEdge } = useGraphActions();
23 |
24 |
25 | // Always get the current graph, use initial graph data when current graph is not loaded
26 | const currentGraph = useMemo(()=> getCurrentGraph(), [getCurrentGraph]);
27 |
28 |
29 | const handleCloseContextMenu = useCallback(() => {
30 | setContextMenu(null);
31 | }, []);
32 |
33 |
34 | const handleNodeDataChange = useCallback((nodeId: string, newData: any) => {
35 | updateNodeData(currentGraphName, nodeId, newData)
36 | }, [updateNodeData, currentGraphName]);
37 |
38 | const handleEdgeClick = useCallback((event: React.MouseEvent, edge: ReactFlowEdge) => {
39 | event.preventDefault();
40 | event.stopPropagation();
41 | console.log("handleEdgeClick", edge)
42 | }, [])
43 |
44 |
45 | const reactFlowProps = useMemo(() => ({
46 | onContextMenu: (event: React.MouseEvent)=> handlePanelContextMenu(event, setContextMenu),
47 | onClick: handleCloseContextMenu,
48 | onNodesChange: (changes: NodeChange[]) => handleNodesChange(currentGraphName, changes),
49 | onEdgesChange: (changes: EdgeChange[]) => handleEdgesChange(currentGraphName, changes),
50 | onEdgeClick: handleEdgeClick,
51 | onConnect: handleAddEdge,
52 | edgeTypes: {
53 | custom: (props) => {
54 | const {sourceNode, targetNode} = props.data || {}
55 | return
56 | },
57 | },
58 | }),[handlePanelContextMenu,handleCloseContextMenu, handleNodesChange, handleEdgesChange, handleEdgeClick, handleAddEdge, currentGraphName, setContextMenu])
59 |
60 | useEffect(() => {
61 | const handleResize = () => {
62 | if (menuBarRef.current) {
63 | const menuBarHeight = menuBarRef.current.offsetHeight;
64 | setCanvasHeight(window.innerHeight - menuBarHeight - 10);
65 | } else {
66 | setCanvasHeight(window.innerHeight-10);
67 | }
68 | };
69 |
70 | window.addEventListener('resize', handleResize);
71 | handleResize();
72 |
73 | return () => window.removeEventListener('resize', handleResize);
74 | }, []);
75 |
76 | const nodeTypes = useMemo(() => ({
77 | custom: (props: any) => ,
78 | }), [handleNodeDataChange]);
79 |
80 |
81 |
82 | return (
83 |
84 | {/*Assuming you have a div with ref for menuBar*/}
85 |
86 |
87 |
88 |
89 |
90 |
98 |
99 |
100 |
101 |
102 | {contextMenu && contextMenu.type === 'panel' && (
103 |
110 |
111 |
112 |
113 | )}
114 | {contextMenu && contextMenu.type === 'node' &&(
115 |
122 | {/* */}
123 |
124 |
125 |
126 | )}
127 | {contextMenu && contextMenu.type === 'edge' &&(
128 |
135 |
136 |
137 |
138 | )}
139 |
140 |
141 | );
142 | };
143 |
144 | export default GraphApp;
--------------------------------------------------------------------------------
/src/graph/CustomNode.tsx:
--------------------------------------------------------------------------------
1 | // Graph/CustomNode.tsx
2 |
3 | import React, { useCallback, useRef, useState, useEffect } from 'react';
4 | import { Handle, Position, NodeResizeControl } from '@xyflow/react';
5 | import ResizeIcon from './ResizeIcon';
6 | import { ReactNodeProps, ReactFlowNodeEXT } from './NodeData';
7 |
8 | const handleStyle = {
9 | borderRadius: '50%',
10 | background: '#555',
11 | };
12 |
13 | const CustomNode: React.FC = ({ id, width, height, data, onNodeDataChange }) => {
14 | const [localData, setLocalData] = useState(data);
15 | const dataRef = useRef(data);
16 |
17 | useEffect(() => {
18 | if (data !== dataRef.current) {
19 | setLocalData(data)
20 | dataRef.current = data
21 | }
22 | }, [data])
23 |
24 | const handleChange = useCallback((evt: React.ChangeEvent) => {
25 | const { name, value } = evt.target;
26 | setLocalData(prev => ({ ...prev, [name]: value }))
27 | }, []);
28 |
29 | const handleBlur = useCallback(() => {
30 | if (localData !== data) {
31 | onNodeDataChange?.(id, localData);
32 | }
33 | }, [id, localData, data, onNodeDataChange]);
34 |
35 |
36 | const generateFieldId = (fieldName: string) => `${id}-${fieldName}`;
37 |
38 | return (
39 |
43 |
49 |
56 |
63 |
70 |
71 |
72 |
75 |
91 |
92 | {localData.type !== 'START' && (
93 | <>
94 | {['STEP', 'CONDITION', 'INFO', 'SUBGRAPH'].includes(localData.type) && (
95 |
96 |
99 |
108 |
109 | )}
110 | {localData.type === 'STEP' && (
111 |
112 |
115 |
124 |
125 | )}
126 | {['STEP', 'TOOL', 'CONDITION', 'INFO'].includes(localData.type) && (
127 |
128 |
131 |
140 |
141 | )}
142 | >
143 | )}
144 |
145 |
150 |
151 |
152 |
153 | );
154 | };
155 |
156 | export default React.memo(CustomNode);
--------------------------------------------------------------------------------
/src/GraphMenu/RunWindow.tsx:
--------------------------------------------------------------------------------
1 | // GraphMenu/RunWindow.tsx
2 |
3 | import { useState, useEffect, useRef } from 'react';
4 | import { useGraph } from '../Graph/GraphContext';
5 | import { allSubGraphsToJson } from '../Graph/JsonUtil';
6 | import ConfigManager from '../utils/ConfigManager';
7 |
8 | interface RunWindowProps {
9 | onClose: () => void;
10 | }
11 |
12 |
13 | function RunWindow({ onClose }: RunWindowProps) {
14 | const [responseMessage, setResponseMessage] = useState('');
15 | const [isRunning, setIsRunning] = useState(false);
16 | const { username, llmModel, apiKey } = ConfigManager.getSettings();
17 | const { subGraphs } = useGraph();
18 | const isPollingRef = useRef(false);
19 |
20 | const SERVER_URL = import.meta.env.VITE_BACKEND_URL;
21 |
22 | const uploadGraphData = async () => {
23 | try {
24 | const flowData = allSubGraphsToJson(subGraphs);
25 |
26 | if (!username) {
27 | throw new Error("Username not available to upload graph data.");
28 | }
29 |
30 | const jsonString = JSON.stringify(flowData, null, 2);
31 | const blob = new Blob([jsonString], { type: 'application/json' });
32 | const graphFile = new File([blob], 'workflow.json');
33 |
34 |
35 | const formData = new FormData();
36 | formData.append('files', graphFile);
37 |
38 |
39 | const response = await fetch(`${SERVER_URL}/upload/${encodeURIComponent(username)}`, {
40 | method: 'POST',
41 | body: formData,
42 | });
43 |
44 |
45 | if (!response.ok) {
46 | const errorData = await response.json();
47 | throw new Error('Failed to upload graph data: ' + errorData.error);
48 | }
49 |
50 |
51 | console.log('Graph data successfully uploaded to server.\n');
52 | setResponseMessage(prev => prev + '\nGraph data successfully uploaded to server.\n');
53 |
54 |
55 | } catch (error: unknown) {
56 | let errorMessage = "An unknown error occurred";
57 | if (error instanceof Error) {
58 | errorMessage = error.message;
59 | }
60 | console.error('Error uploading graph data:', errorMessage);
61 | setResponseMessage(prev => prev + '\nError uploading graph data: ' + errorMessage);
62 | throw error;
63 | }
64 | };
65 |
66 |
67 |
68 | const handleRun = async () => {
69 | if (isRunning) return;
70 | setIsRunning(true);
71 | setResponseMessage('');
72 |
73 |
74 | try {
75 | await uploadGraphData();
76 | console.log("Attempting to send request to Flask server...");
77 |
78 | if (!username) {
79 | throw new Error("Username not available to run.");
80 | }
81 |
82 | const response = await fetch(`${SERVER_URL}/run/${encodeURIComponent(username)}`, {
83 | method: 'POST',
84 | headers: { 'Content-Type': 'application/json' },
85 | body: JSON.stringify({
86 | username: username,
87 | llm_model: llmModel,
88 | api_key: apiKey,
89 | }),
90 | });
91 |
92 |
93 | if (!response.body) {
94 | throw new Error('ReadableStream not yet supported in this browser.');
95 | }
96 |
97 | const reader = response.body.getReader();
98 | const decoder = new TextDecoder();
99 | let done = false;
100 |
101 |
102 | while (!done) {
103 | const { value, done: streamDone } = await reader.read();
104 | done = streamDone;
105 | if (value) {
106 | const chunk = decoder.decode(value, { stream: !done });
107 | console.log("Received chunk:", chunk);
108 | try{
109 | const parsed = JSON.parse(chunk.replace("data: ", "").trim());
110 | if (parsed.status){
111 | setIsRunning(false)
112 | }
113 | }catch(e){
114 | console.error("Error parsing JSON:", e);
115 | }
116 | setResponseMessage(prev => prev + chunk);
117 | }
118 | }
119 | } catch (error: unknown) {
120 | let errorMessage = "An unknown error occurred";
121 | if (error instanceof Error) {
122 | errorMessage = error.message;
123 | }
124 | console.error('Error:', errorMessage);
125 | setResponseMessage(prev => prev + '\nError: ' + errorMessage);
126 | alert('Error: ' + errorMessage);
127 | setIsRunning(false);
128 | } finally {
129 | if(isPollingRef.current){
130 | setIsRunning(false);
131 | }
132 | }
133 | };
134 |
135 |
136 | useEffect(() => {
137 | isPollingRef.current = true;
138 | const checkStatus = async () => {
139 | try {
140 | if (!username) {
141 | throw new Error("Username not available to check status.");
142 | }
143 |
144 | const response = await fetch(`${SERVER_URL}/status/${encodeURIComponent(username)}`, {
145 | method: 'GET',
146 | });
147 | const status = await response.json();
148 | setIsRunning(status.running);
149 | } catch (error) {
150 | console.error('Error checking status:', error);
151 | }
152 | };
153 | const interval = setInterval(checkStatus, 2000);
154 |
155 |
156 | return () => {
157 | isPollingRef.current = false;
158 | clearInterval(interval);
159 | };
160 | }, [username, SERVER_URL]);
161 |
162 |
163 | const handleLeave = async () => {
164 | onClose();
165 | };
166 |
167 |
168 | return (
169 |
170 |
171 |
Run Script
172 |
173 |
180 |
187 |
188 |
189 | {/* ADDED TEXT-BLACK HERE */}
190 |
{responseMessage}
191 |
192 |
193 |
194 | );
195 | }
196 |
197 |
198 | export default RunWindow;
--------------------------------------------------------------------------------
/src/graph/JsonUtil.tojson.test.tsx:
--------------------------------------------------------------------------------
1 | // JsonUtil.tojson.test.tsx
2 |
3 | import { describe, it, expect, vi } from 'vitest';
4 | import { subGraphToJson, allSubGraphsToJson } from './JsonUtil';
5 | import { SubGraph } from './GraphContext';
6 | import { Node } from '@xyflow/react';
7 |
8 | describe('JsonUtil', () => {
9 | it('should convert a subGraph to JSON correctly', () => {
10 | const mockSubGraph: SubGraph = {
11 | graphName: "testGraph",
12 | nodes: [
13 | {
14 | id: '1',
15 | type: 'custom',
16 | position: { x: 100, y: 100 },
17 | width: 200,
18 | height: 200,
19 | data: { type: "START", name: "Start Node", description: "This is the start" } ,
20 | } as Node,
21 | {
22 | id: '2',
23 | type: 'custom',
24 | position: { x: 200, y: 200 },
25 | width: 250,
26 | height: 150,
27 | data: { type: "STEP", tool: "someTool", nexts:['3'], true_next: '3'} ,
28 | } as Node,
29 | {
30 | id: '3',
31 | type: 'custom',
32 | position: {x: 300, y: 300},
33 | data:{ type: "END"} ,
34 | } as Node,
35 | ],
36 | edges: [],
37 | serial_number: 3,
38 | };
39 |
40 | const expectedJson = {
41 | name: "testGraph",
42 | nodes: [
43 | {
44 | uniq_id: '1',
45 | type: 'START',
46 | name: "Start Node",
47 | description: "This is the start",
48 | tool: '',
49 | nexts: [],
50 | true_next: null,
51 | false_next: null,
52 | ext: { pos_x: 100, pos_y: 100, width: 200, height: 200, info: null },
53 | },
54 | {
55 | uniq_id: '2',
56 | type: 'STEP',
57 | name: "",
58 | description: "",
59 | tool: 'someTool',
60 | nexts: [ '3' ],
61 | true_next: '3',
62 | false_next: null,
63 | ext: { pos_x: 200, pos_y: 200, width: 250, height: 150, info: null },
64 | },
65 | {
66 | uniq_id: '3',
67 | type: 'END',
68 | name: "",
69 | description: "",
70 | tool: "",
71 | nexts: [],
72 | true_next: null,
73 | false_next: null,
74 | ext: { pos_x: 300, pos_y: 300, width: 200, height: 200, info: null }
75 | }
76 | ],
77 | serial_number: 3,
78 | };
79 |
80 | const result = subGraphToJson(mockSubGraph);
81 | console.log("subGraphToJson Output:", JSON.stringify(result, null, 2));
82 | expect(result).toEqual(expectedJson);
83 | });
84 |
85 |
86 | it('should handle missing properties and default type for node.data', () => {
87 | const mockSubGraph: SubGraph = {
88 | graphName: "testGraph",
89 | nodes: [
90 | {
91 | id: '1',
92 | type: 'custom',
93 | position: { x: 100, y: 100 },
94 | data: { } as any, //missing type
95 | } as Node,
96 | {
97 | id: '2',
98 | type: 'custom',
99 | position: { x: 200, y: 200 },
100 | data: {type:"STEP", tool:"test"} ,
101 | } as Node
102 | ],
103 | edges: [],
104 | serial_number: 1,
105 | };
106 |
107 |
108 | const expectedJson = {
109 | name: "testGraph",
110 | nodes:[
111 | {
112 | uniq_id: '1',
113 | type: 'STEP',
114 | name: "",
115 | description: "",
116 | tool: "",
117 | nexts: [],
118 | true_next: null,
119 | false_next: null,
120 | ext: { pos_x: 100, pos_y: 100, width: 200, height: 200, info: null }
121 | },
122 | {
123 | uniq_id: '2',
124 | type: 'STEP',
125 | name: "",
126 | description: "",
127 | tool: "test",
128 | nexts: [],
129 | true_next: null,
130 | false_next: null,
131 | ext: { pos_x: 200, pos_y: 200, width: 200, height: 200, info: null }
132 | }
133 | ],
134 | serial_number: 1
135 | }
136 |
137 | // Mock console.error to check if it's called for invalid node data
138 | const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {});
139 |
140 | const result = subGraphToJson(mockSubGraph);
141 | console.log("subGraphToJson Output (missing properties):", JSON.stringify(result, null, 2));
142 | expect(result).toEqual(expectedJson);
143 | expect(consoleErrorMock).toHaveBeenCalled();
144 | consoleErrorMock.mockRestore();
145 | });
146 |
147 | it('should convert all subgraphs to JSON correctly', () => {
148 | const mockSubGraphs: SubGraph[] = [
149 | {
150 | graphName: "graph1",
151 | nodes: [
152 | {
153 | id: '1',
154 | type: 'custom',
155 | position: { x: 100, y: 100 },
156 | data: { type: "START"} ,
157 | } as Node
158 | ],
159 | edges: [],
160 | serial_number: 1,
161 | },
162 | {
163 | graphName: "graph2",
164 | nodes: [
165 | {
166 | id: '2',
167 | type: 'custom',
168 | position: { x: 200, y: 200 },
169 | data: { type: "STEP", tool:"test"} ,
170 | } as Node
171 | ],
172 | edges: [],
173 | serial_number: 2,
174 | },
175 | ];
176 | const expectedJson = [
177 | {
178 | name: "graph1",
179 | nodes:[
180 | {
181 | uniq_id: '1',
182 | type: 'START',
183 | name: "",
184 | description: "",
185 | tool: "",
186 | nexts: [],
187 | true_next: null,
188 | false_next: null,
189 | ext: { pos_x: 100, pos_y: 100, width: 200, height: 200, info: null }
190 | },
191 | ],
192 | serial_number: 1,
193 | },
194 | {
195 | name: "graph2",
196 | nodes: [
197 | {
198 | uniq_id: '2',
199 | type: 'STEP',
200 | name: "",
201 | description: "",
202 | tool: "test",
203 | nexts: [],
204 | true_next: null,
205 | false_next: null,
206 | ext: { pos_x: 200, pos_y: 200, width: 200, height: 200, info: null }
207 | },
208 | ],
209 | serial_number: 2,
210 | },
211 | ];
212 | const result = allSubGraphsToJson(mockSubGraphs);
213 | console.log("allSubGraphsToJson Output:", JSON.stringify(result, null, 2));
214 | expect(result).toEqual(expectedJson);
215 | });
216 | });
--------------------------------------------------------------------------------
/src/graph/NodeData.tojson.test.tsx:
--------------------------------------------------------------------------------
1 | // NodeData.tojson.test.tsx
2 |
3 | import React, { ReactNode } from 'react';
4 | import { render, screen, act } from '@testing-library/react';
5 | import { useGraph, GraphProvider, GraphContextType, SubGraph } from './GraphContext';
6 | import { describe, it, expect, vi } from 'vitest';
7 | import { Node } from '@xyflow/react';
8 | import { ReactToJsonNode, ReactNodeProps, ReactFlowNodeEXT } from './NodeData';
9 |
10 |
11 | interface TestComponentProps {
12 | onContextChange?: (context: GraphContextType) => void;
13 | }
14 |
15 | const TestComponent: React.FC = ({ onContextChange }) => {
16 | const graphContext = useGraph();
17 |
18 | React.useEffect(() => {
19 | if (onContextChange) {
20 | onContextChange(graphContext);
21 | }
22 | }, [graphContext, onContextChange]);
23 |
24 | const addTestNodes = () => {
25 | const testGraph: SubGraph = {
26 | graphName: "test",
27 | nodes: [
28 | {
29 | id: "1",
30 | type: "custom",
31 | position: { x: 100, y: 100 },
32 | data: { type: "START" } // info is not provided here
33 | } as Node,
34 | {
35 | id: "2",
36 | type: "custom",
37 | position: { x: 200, y: 250 },
38 | data: {
39 | type: "INFO",
40 | description: "test for flow",
41 | } // info is not provided here
42 | } as Node,
43 | {
44 | id: "3",
45 | type: "custom",
46 | position: { x: 300, y: 330 },
47 | data: {
48 | type: "STEP",
49 | description: "try use a tool",
50 | tool: "save_file",
51 | } // info is not provided here
52 | } as Node
53 | ],
54 | edges: [],
55 | serial_number: 4
56 | };
57 | graphContext.updateSubGraph("test", testGraph);
58 | };
59 |
60 | const convertFirstNodeToJson = () => {
61 | if (graphContext.subGraphs.length > 0) {
62 | const testGraph = graphContext.subGraphs.find((graph) => graph.graphName === 'test');
63 | if (testGraph && testGraph.nodes.length > 0) {
64 | const jsonNodes = testGraph.nodes.map(node => {
65 | const nodeData = node.data as { type: string };
66 | const reactNodeProps: ReactNodeProps = {
67 | id: node.id,
68 | width: 200, // Or use a constant
69 | height: 200, // Or use a constant
70 | position: node.position,
71 | data: {
72 | ...node.data,
73 | type: nodeData.type,
74 |
75 | } as ReactFlowNodeEXT,
76 | };
77 | const jsonNode = ReactToJsonNode(reactNodeProps);
78 | return jsonNode;
79 | })
80 | console.log("JSON Nodes:", jsonNodes);
81 | }
82 | }
83 | }
84 |
85 | return (
86 |
87 |
90 |
93 |
96 |
99 |
SubGraph Count: {graphContext.subGraphs.length}
100 |
101 | Has Root Graph: {graphContext.subGraphs.some(graph => graph.graphName === 'root') ? 'true' : 'false'}
102 |
103 |
104 | Has Test Graph: {graphContext.subGraphs.some(graph => graph.graphName === 'test') ? 'true' : 'false'}
105 |
106 |
107 | Test Graph Nodes: {graphContext.subGraphs.find(graph => graph.graphName === 'test')?.nodes.length || 0}
108 |
109 |
110 | );
111 | };
112 |
113 | interface TestWrapperProps {
114 | children: ReactNode;
115 | }
116 |
117 | const TestWrapper: React.FC = ({ children }) => {
118 | return (
119 | {children}
120 | );
121 | };
122 |
123 | describe('GraphContext', () => {
124 | it('should manage subgraphs correctly', async () => {
125 | // Mock console.log to capture its calls
126 | const consoleLogMock = vi.spyOn(console, 'log');
127 |
128 | const handleContextChange = () => {
129 | // graphContextValue = context; remove unused variable
130 | };
131 |
132 | render(
133 |
134 |
135 |
136 | );
137 |
138 | const testComponent = screen.getByTestId("test-component");
139 | expect(testComponent).toBeInTheDocument();
140 |
141 | const addRootButton = screen.getByTestId("add-root-button");
142 | const addSubGraphButton = screen.getByTestId("add-subgraph-button");
143 | const addNodesButton = screen.getByTestId("add-nodes-button");
144 | const convertToJsonButton = screen.getByTestId("convert-to-json-button");
145 |
146 |
147 | // Add root and test subgraph
148 | await act(async () => {
149 | addRootButton.click();
150 | });
151 | await act(async () => {
152 | addSubGraphButton.click();
153 | });
154 |
155 | let subGraphCountElement = screen.getByTestId("subgraph-count");
156 | expect(subGraphCountElement).toHaveTextContent("SubGraph Count: 2");
157 |
158 | let hasRootElement = screen.getByTestId("has-root-graph");
159 | expect(hasRootElement).toHaveTextContent("Has Root Graph: true");
160 |
161 | let hasTestElement = screen.getByTestId("has-test-graph");
162 | expect(hasTestElement).toHaveTextContent("Has Test Graph: true");
163 |
164 | // Add nodes to test subgraph
165 | await act(async () => {
166 | addNodesButton.click();
167 | });
168 |
169 |
170 |
171 | const testGraphNodesElement = screen.getByTestId("test-graph-nodes");
172 | expect(testGraphNodesElement).toHaveTextContent("Test Graph Nodes: 3");
173 |
174 | // Convert first node to JSON
175 | await act(async () => {
176 | convertToJsonButton.click();
177 | });
178 |
179 | // Check if console.log was called with the correct JSON
180 | expect(consoleLogMock).toHaveBeenCalled();
181 | const consoleArgs = consoleLogMock.mock.calls[0];
182 | expect(consoleArgs[0]).toBe("JSON Nodes:");
183 | const jsonOutput = consoleArgs[1];
184 | expect(jsonOutput).toEqual([
185 | {
186 | uniq_id: '1',
187 | type: 'START',
188 | name: "",
189 | description: "",
190 | tool: "",
191 | nexts: [],
192 | true_next: null,
193 | false_next: null,
194 | ext: { pos_x: 100, pos_y: 100, width: 200, height: 200, info: null }
195 | },
196 | {
197 | uniq_id: '2',
198 | type: 'INFO',
199 | name: "",
200 | description: 'test for flow',
201 | tool: "",
202 | nexts: [],
203 | true_next: null,
204 | false_next: null,
205 | ext: { pos_x: 200, pos_y: 250, width: 200, height: 200, info: null }
206 | },
207 | {
208 | uniq_id: '3',
209 | type: 'STEP',
210 | name: "",
211 | description: 'try use a tool',
212 | tool: 'save_file',
213 | nexts: [],
214 | true_next: null,
215 | false_next: null,
216 | ext: { pos_x: 300, pos_y: 330, width: 200, height: 200, info: null }
217 | }
218 | ]);
219 |
220 | // Try adding root again, should not change the count
221 | await act(async () => {
222 | addRootButton.click();
223 | });
224 | subGraphCountElement = screen.getByTestId("subgraph-count");
225 | expect(subGraphCountElement).toHaveTextContent("SubGraph Count: 2");
226 |
227 | hasRootElement = screen.getByTestId("has-root-graph");
228 | expect(hasRootElement).toHaveTextContent("Has Root Graph: true");
229 | hasTestElement = screen.getByTestId("has-test-graph");
230 | expect(hasTestElement).toHaveTextContent("Has Test Graph: true");
231 | // Restore the original console.log
232 | consoleLogMock.mockRestore();
233 | });
234 | });
--------------------------------------------------------------------------------
/src/graph/GraphPanel.tsx:
--------------------------------------------------------------------------------
1 | // Graph/GraphPanel.tsx
2 |
3 | import React, { useState, useRef, useEffect } from 'react';
4 | import { useGraph } from './GraphContext';
5 | import './GraphPanel.css';
6 | import { allSubGraphsToJson, subGraphToJson, jsonToSubGraphs, jsonToSubGraph, JsonSubGraph } from './JsonUtil';
7 | import { saveJsonToFile, loadJsonFromFile } from '../utils/JsonIO';
8 | import { SubGraph } from './GraphContext';
9 |
10 | const GraphPanel: React.FC = () => {
11 | const { subGraphs, currentGraphName, addSubGraph, removeSubGraph, setCurrentGraphName, updateSubGraph, getCurrentGraph } = useGraph(); // Include getCurrentGraph
12 | const [isGraphMenuOpen, setIsGraphMenuOpen] = useState(false);
13 | const [isSubGraphMenuOpen, setIsSubGraphMenuOpen] = useState(false);
14 | const graphMenuRef = useRef(null);
15 | const subGraphMenuRef = useRef(null);
16 |
17 | const handleAddGraph = () => {
18 | const newGraphName = prompt("Enter a new graph name:");
19 | if (newGraphName) {
20 | addSubGraph(newGraphName);
21 | }
22 | closeMenus();
23 | };
24 |
25 | const handleRenameGraph = () => {
26 | const newGraphName = prompt("Enter a new graph name:");
27 | if (newGraphName && currentGraphName !== "root") {
28 | const currentGraph = subGraphs.find(graph => graph.graphName === currentGraphName)
29 | if(currentGraph){
30 | updateSubGraph(currentGraphName, {...currentGraph, graphName: newGraphName})
31 | }
32 | }
33 | closeMenus();
34 | }
35 |
36 | const handleRemoveGraph = () => {
37 | const graphName = prompt("Enter the graph name to delete:");
38 | if (graphName && graphName !== "root") {
39 | removeSubGraph(graphName);
40 | } else if (graphName === "root") {
41 | alert("cannot delete root");
42 | }
43 | closeMenus();
44 | };
45 |
46 | const handleSelectGraph = (graphName: string) => {
47 | setCurrentGraphName(graphName);
48 | closeMenus();
49 | };
50 |
51 | const handleNewGraph = () => {
52 | console.log("New Graph clicked");
53 | closeMenus();
54 | };
55 |
56 | const handleLoadGraph = async () => {
57 | try {
58 | const jsonData = await loadJsonFromFile();
59 | if(jsonData){
60 | const loadedSubGraphs: SubGraph[] = jsonToSubGraphs(jsonData);
61 |
62 | //Clear subgraphs first
63 | subGraphs.forEach(graph => {
64 | if(graph.graphName !== 'root') removeSubGraph(graph.graphName)
65 | })
66 | //Then load new subgraphs
67 | loadedSubGraphs.forEach(subGraph => updateSubGraph(subGraph.graphName,subGraph))
68 |
69 | alert('Graph loaded successfully!');
70 | }
71 |
72 | } catch (error) {
73 | console.error("Error loading graph:", error);
74 | alert('Failed to load graph: ' + error);
75 | }
76 | closeMenus();
77 | };
78 |
79 | const handleSaveGraph = () => {
80 | const jsonData = allSubGraphsToJson(subGraphs);
81 | saveJsonToFile("Save.json", jsonData);
82 | closeMenus();
83 | };
84 | // Placeholder functions for SubGraph menu
85 | const handleLoadSubGraph = async () => {
86 | try {
87 | const jsonData = await loadJsonFromFile();
88 |
89 | if (jsonData) {
90 |
91 | // Make sure jsonData is JsonSubGraph
92 | if(!jsonData.name || !jsonData.nodes || !jsonData.serial_number){
93 | throw new Error("Invalid Json Format: must be JsonSubGraph")
94 | }
95 |
96 | const loadedSubGraph: SubGraph = jsonToSubGraph(jsonData as JsonSubGraph);
97 |
98 | updateSubGraph(loadedSubGraph.graphName, loadedSubGraph);
99 |
100 | alert('Subgraph loaded successfully!');
101 | }
102 | } catch (error) {
103 | console.error("Error loading subgraph:", error);
104 | alert('Failed to load subgraph: ' + error);
105 | }
106 | closeMenus();
107 | };
108 | const handleSaveSubGraph = () => {
109 | const currentGraph = getCurrentGraph();
110 | const jsonData = subGraphToJson(currentGraph);
111 | saveJsonToFile(`${currentGraph.graphName}.json`, jsonData);
112 |
113 | closeMenus();
114 | };
115 |
116 | const toggleGraphMenu = () => {
117 | setIsGraphMenuOpen(!isGraphMenuOpen);
118 | setIsSubGraphMenuOpen(false);
119 | };
120 |
121 | const toggleSubGraphMenu = () => {
122 | setIsSubGraphMenuOpen(!isSubGraphMenuOpen);
123 | setIsGraphMenuOpen(false);
124 | };
125 | const closeMenus = () => {
126 | setIsGraphMenuOpen(false);
127 | setIsSubGraphMenuOpen(false);
128 | }
129 | // Close menus when clicking outside
130 | useEffect(() => {
131 | const handleClickOutside = (event: MouseEvent) => {
132 | if (graphMenuRef.current && !graphMenuRef.current.contains(event.target as Node)
133 | && subGraphMenuRef.current && !subGraphMenuRef.current.contains(event.target as Node)
134 | ) {
135 | closeMenus();
136 | }
137 | };
138 |
139 | document.addEventListener('mousedown', handleClickOutside);
140 |
141 | return () => {
142 | document.removeEventListener('mousedown', handleClickOutside);
143 | };
144 | }, [graphMenuRef,subGraphMenuRef]);
145 |
146 |
147 | return (
148 |
213 | );
214 | };
215 |
216 | export default GraphPanel;
--------------------------------------------------------------------------------
/src/graph/JsonUtil.fromjson.test.tsx:
--------------------------------------------------------------------------------
1 | // JsonUtil.fromjson.test.tsx
2 |
3 | import { describe, it, expect } from 'vitest';
4 | import { jsonToSubGraph, jsonToSubGraphs, JsonSubGraph } from './JsonUtil';
5 | import { SubGraph } from './GraphContext';
6 |
7 | describe('JsonUtil from JSON', () => {
8 | it('should convert a JSON subgraph to a SubGraph object correctly', () => {
9 | const jsonSubGraph: JsonSubGraph = {
10 | name: "testGraph",
11 | nodes: [
12 | {
13 | uniq_id: '1',
14 | type: 'START',
15 | name: "Start Node",
16 | description: "This is the start",
17 | tool: '',
18 | nexts: [],
19 | true_next: null,
20 | false_next: null,
21 | ext: { pos_x: 100, pos_y: 100, width: 200, height: 200, info: null },
22 | },
23 | {
24 | uniq_id: '2',
25 | type: 'STEP',
26 | name: "",
27 | description: "",
28 | tool: 'someTool',
29 | nexts: ['3'],
30 | true_next: '3',
31 | false_next: null,
32 | ext: { pos_x: 200, pos_y: 200, width: 250, height: 150, info: null },
33 | },
34 | {
35 | uniq_id: '3',
36 | type: 'END',
37 | name: "",
38 | description: "",
39 | tool: "",
40 | nexts: [],
41 | true_next: null,
42 | false_next: null,
43 | ext: { pos_x: 300, pos_y: 300, width: 200, height: 200, info: null }
44 | }
45 | ],
46 | serial_number: 3,
47 | };
48 |
49 | const expectedSubGraph: SubGraph = {
50 | graphName: "testGraph",
51 | nodes: [
52 | {
53 | id: '1',
54 | type: 'custom',
55 | position: { x: 100, y: 100 },
56 | width: 200,
57 | height: 200,
58 | data: { type: 'START', name: "Start Node", description: "This is the start", tool: '', nexts: [], true_next: null, false_next: null, info: null , prevs: []}
59 | },
60 | {
61 | id: '2',
62 | type: 'custom',
63 | position: { x: 200, y: 200 },
64 | width: 250,
65 | height: 150,
66 | data: { type: 'STEP', name: '', description: '', tool: 'someTool', nexts: ['3'], true_next: '3', false_next: null, info: null , prevs: []}
67 | },
68 | {
69 | id: '3',
70 | type: 'custom',
71 | position: { x: 300, y: 300 },
72 | width: 200,
73 | height: 200,
74 | data: { type: 'END', name: '', description: '', tool: '', nexts: [], true_next: null, false_next: null, info: null, prevs: [] }
75 | }
76 | ],
77 | edges: [
78 | {
79 | id: '2-3',
80 | source: '2',
81 | target: '3',
82 | type: 'custom',
83 | data: { sourceNode: '2', targetNode: '3' }
84 | },
85 | {
86 | id: '2-3-true',
87 | source: '2',
88 | target: '3',
89 | type: 'custom',
90 | sourceHandle: 'true',
91 | data: { sourceNode: '2', targetNode: '3' }
92 | },
93 | ],
94 | serial_number: 3,
95 | };
96 |
97 |
98 | const result = jsonToSubGraph(jsonSubGraph);
99 | console.log("jsonToSubGraph Output:", JSON.stringify(result, null, 2));
100 | expect(result).toEqual(expectedSubGraph);
101 | });
102 |
103 |
104 | it('should convert a JSON subgraph without positions and sizes', () => {
105 | const jsonSubGraph: JsonSubGraph = {
106 | name: "testGraph",
107 | nodes: [
108 | {
109 | uniq_id: '1',
110 | type: 'START',
111 | name: "Start Node",
112 | description: "This is the start",
113 | tool: '',
114 | nexts: [],
115 | true_next: null,
116 | false_next: null,
117 | ext: { },
118 | },
119 | {
120 | uniq_id: '2',
121 | type: 'STEP',
122 | name: "",
123 | description: "",
124 | tool: 'someTool',
125 | nexts: ['3'],
126 | true_next: '3',
127 | false_next: null,
128 | ext: {},
129 | },
130 | ],
131 | serial_number: 3,
132 | };
133 |
134 | const expectedSubGraph: SubGraph = {
135 | graphName: "testGraph",
136 | nodes: [
137 | {
138 | id: '1',
139 | type: 'custom',
140 | position: { x: 0, y: 0 },
141 | width: 200,
142 | height: 200,
143 | data: { type: 'START', name: "Start Node", description: "This is the start", tool: '', nexts: [], true_next: null, false_next: null, info: null, prevs: []}
144 | },
145 | {
146 | id: '2',
147 | type: 'custom',
148 | position: { x: 0, y: 0 },
149 | width: 200,
150 | height: 200,
151 | data: { type: 'STEP', name: '', description: '', tool: 'someTool', nexts: ['3'], true_next: '3', false_next: null, info: null, prevs: []}
152 | },
153 | ],
154 | edges: [
155 | {
156 | id: '2-3',
157 | source: '2',
158 | target: '3',
159 | type: 'custom',
160 | data: { sourceNode: '2', targetNode: '3' }
161 | },
162 | {
163 | id: '2-3-true',
164 | source: '2',
165 | target: '3',
166 | type: 'custom',
167 | sourceHandle: 'true',
168 | data: { sourceNode: '2', targetNode: '3' }
169 | },
170 | ],
171 | serial_number: 3,
172 | };
173 |
174 |
175 | const result = jsonToSubGraph(jsonSubGraph);
176 | console.log("jsonToSubGraph no position Output:", JSON.stringify(result, null, 2));
177 | expect(result).toEqual(expectedSubGraph);
178 | });
179 |
180 |
181 | it('should convert an array of JSON subgraphs to an array of SubGraph objects correctly', () => {
182 | const jsonSubGraphs: JsonSubGraph[] = [
183 | {
184 | name: "graph1",
185 | nodes: [
186 | {
187 | uniq_id: '1',
188 | type: 'START',
189 | name: "",
190 | description: "",
191 | tool: "",
192 | nexts: [],
193 | true_next: null,
194 | false_next: null,
195 | ext: { pos_x: 100, pos_y: 100, width: 200, height: 200, info: null }
196 | }
197 | ],
198 | serial_number: 1,
199 | },
200 | {
201 | name: "graph2",
202 | nodes: [
203 | {
204 | uniq_id: '2',
205 | type: 'STEP',
206 | name: "",
207 | description: "",
208 | tool: "test",
209 | nexts: [],
210 | true_next: null,
211 | false_next: null,
212 | ext: { pos_x: 200, pos_y: 200, width: 200, height: 200, info: null },
213 | }
214 | ],
215 | serial_number: 2,
216 | },
217 | ];
218 |
219 |
220 | const expectedSubGraphs: SubGraph[] = [
221 | {
222 | graphName: "graph1",
223 | nodes: [
224 | {
225 | id: '1',
226 | type: 'custom',
227 | position: { x: 100, y: 100 },
228 | width: 200,
229 | height: 200,
230 | data: { type: 'START', name: "", description: "", tool: "", nexts: [], true_next: null, false_next: null, info: null, prevs: [] }
231 | }
232 | ],
233 | edges: [],
234 | serial_number: 1,
235 | },
236 | {
237 | graphName: "graph2",
238 | nodes: [
239 | {
240 | id: '2',
241 | type: 'custom',
242 | position: { x: 200, y: 200 },
243 | width: 200,
244 | height: 200,
245 | data: { type: 'STEP', name: "", description: "", tool: "test", nexts: [], true_next: null, false_next: null, info: null, prevs: [] }
246 | }
247 | ],
248 | edges: [],
249 | serial_number: 2,
250 | },
251 | ];
252 |
253 |
254 | const result = jsonToSubGraphs(jsonSubGraphs);
255 | console.log("jsonToSubGraphs Output:", JSON.stringify(result, null, 2));
256 | expect(result).toEqual(expectedSubGraphs);
257 | });
258 | });
--------------------------------------------------------------------------------
/src/graph/GraphActions.tsx:
--------------------------------------------------------------------------------
1 | // Graph/GraphActions.tsx
2 |
3 | import { useCallback } from 'react';
4 | import { useGraph } from './GraphContext';
5 | import { Edge, Connection } from '@xyflow/react';
6 |
7 |
8 | interface ContextMenuProps {
9 | mouseX: number;
10 | mouseY: number;
11 | nodeId: string | null;
12 | edgeId: string | null;
13 | type: 'panel' | 'node' | 'edge';
14 | }
15 |
16 | interface AddNodeProps {
17 | contextMenu: ContextMenuProps | null;
18 | setContextMenu: React.Dispatch>;
19 | screenToFlowPosition: ((pos: { x: number; y: number }) => { x: number; y: number }) | null;
20 | }
21 |
22 | export const useGraphActions = () => {
23 | const { currentGraphName, updateSubGraph, getCurrentGraph } = useGraph();
24 | const currentGraph = useCallback(() => getCurrentGraph(), [getCurrentGraph]);
25 |
26 | const handleAddNode = useCallback(({ contextMenu, setContextMenu, screenToFlowPosition }: AddNodeProps) => {
27 | if (contextMenu && contextMenu.type === 'panel' && screenToFlowPosition) {
28 | const newPosition = screenToFlowPosition({ x: contextMenu.mouseX, y: contextMenu.mouseY });
29 | const newNodeId = String(currentGraph().serial_number);
30 | const newNode = {
31 | id: newNodeId,
32 | type: 'custom',
33 | position: newPosition,
34 | width: 150,
35 | height: 200,
36 | data: {
37 | type: "STEP",
38 | name: `Node ${currentGraph().serial_number}` // Set the default name here
39 | },
40 | };
41 | const updatedNodes = [...currentGraph().nodes, newNode]
42 | updateSubGraph(currentGraphName, {
43 | ...currentGraph(),
44 | nodes: updatedNodes,
45 | serial_number: currentGraph().serial_number + 1,
46 | }
47 | );
48 | setContextMenu(null);
49 | }
50 | }, [currentGraph, updateSubGraph, currentGraphName]);
51 |
52 |
53 | const handleDeleteNode = useCallback((contextMenu: ContextMenuProps | null, setContextMenu: React.Dispatch>) => {
54 | if (contextMenu && contextMenu.nodeId) {
55 | const nodeToDeleteId = contextMenu.nodeId;
56 | let updatedNodes = currentGraph().nodes.filter((node) => node.id !== nodeToDeleteId)
57 | const updatedEdges = currentGraph().edges.filter((edge) => edge.source !== nodeToDeleteId && edge.target !== nodeToDeleteId);
58 |
59 |
60 | // Update prevs and nexts on deletion
61 | updatedNodes = updatedNodes.map((node) => {
62 | const updatedNode = { ...node };
63 |
64 | //remove all next ref
65 | if (Array.isArray(updatedNode.data.nexts) && updatedNode.data.nexts.includes(nodeToDeleteId)) {
66 | (updatedNode.data.nexts as string[]) = updatedNode.data.nexts.filter(next => next !== nodeToDeleteId)
67 | }
68 | if (updatedNode.data.true_next === nodeToDeleteId) {
69 | updatedNode.data.true_next = null
70 | }
71 | if (updatedNode.data.false_next === nodeToDeleteId) {
72 | updatedNode.data.false_next = null;
73 | }
74 |
75 | //remove all prev ref
76 | if (Array.isArray(updatedNode.data.prevs) && updatedNode.data.prevs.includes(nodeToDeleteId)) {
77 | (updatedNode.data.prevs as string[]) = updatedNode.data.prevs.filter(prev => prev !== nodeToDeleteId);
78 | }
79 |
80 | return updatedNode
81 | })
82 |
83 | updateSubGraph(currentGraphName, {
84 | ...currentGraph(),
85 | nodes: updatedNodes,
86 | edges: updatedEdges
87 | });
88 | setContextMenu(null);
89 | }
90 | }, [updateSubGraph, currentGraph, currentGraphName]);
91 |
92 |
93 | const handleDeleteEdge = useCallback((contextMenu: ContextMenuProps | null, setContextMenu: React.Dispatch>) => {
94 | if (contextMenu && contextMenu.edgeId) {
95 | const edgeToDeleteId = contextMenu.edgeId;
96 | const edgeToDelete = currentGraph().edges.find((edge) => edge.id === edgeToDeleteId);
97 |
98 | if (!edgeToDelete) return;
99 |
100 | const updatedEdges = currentGraph().edges.filter((edge) => edge.id !== edgeToDeleteId);
101 | const updatedNodes = currentGraph().nodes.map(node => {
102 | const updatedNode = { ...node };
103 |
104 | if (updatedNode.id === edgeToDelete.source) {
105 | if (edgeToDelete.sourceHandle === 'true') {
106 | updatedNode.data.true_next = null;
107 | } else if (edgeToDelete.sourceHandle === 'false') {
108 | updatedNode.data.false_next = null;
109 | } else {
110 | // general to nexts if it exists
111 | if (Array.isArray(updatedNode.data.nexts) && updatedNode.data.nexts.includes(edgeToDelete.target)){
112 | (updatedNode.data.nexts as string[]) = updatedNode.data.nexts.filter(next => next !== edgeToDelete.target)
113 | }
114 | }
115 |
116 | } else if (updatedNode.id === edgeToDelete.target) {
117 | if (Array.isArray(updatedNode.data.prevs) && updatedNode.data.prevs.includes(edgeToDelete.source)){
118 | (updatedNode.data.prevs as string[]) = updatedNode.data.prevs.filter(prev => prev !== edgeToDelete.source)
119 | }
120 |
121 | }
122 |
123 | return updatedNode
124 | })
125 |
126 | updateSubGraph(currentGraphName, {
127 | ...currentGraph(),
128 | edges: updatedEdges,
129 | nodes: updatedNodes
130 | });
131 | setContextMenu(null);
132 | }
133 | }, [currentGraph, currentGraphName, updateSubGraph])
134 |
135 | const handleAddEdge = useCallback((connection: Connection) => {
136 |
137 | const sourceNode = currentGraph().nodes.find(node => node.id === connection.source);
138 | const targetNode = currentGraph().nodes.find(node => node.id === connection.target);
139 |
140 | if (!sourceNode || !targetNode) return;
141 |
142 | // Check for existing connections on true/false handles
143 | if (connection.sourceHandle === 'true' && sourceNode.data.true_next) {
144 | alert("This node already has a 'true' connection. Please remove existing edge to create new one.");
145 | return; // Reject new connection
146 | }
147 | if (connection.sourceHandle === 'false' && sourceNode.data.false_next) {
148 | alert("This node already has a 'false' connection. Please remove existing edge to create new one.");
149 | return; // Reject new connection
150 | }
151 |
152 |
153 | const newEdge: Edge = {
154 | id: `${connection.source}-${connection.target}-${connection.sourceHandle || ""}`,
155 | source: connection.source,
156 | target: connection.target,
157 | sourceHandle: connection.sourceHandle,
158 | type: "custom",
159 | data:{
160 | sourceNode: connection.source,
161 | targetNode: connection.target
162 | }
163 | }
164 |
165 | const updatedNodes = currentGraph().nodes.map(node =>{
166 | const updatedNode = { ...node }
167 | if (updatedNode.id === connection.source){
168 | if(connection.sourceHandle === 'true'){
169 | updatedNode.data.true_next = connection.target
170 | } else if (connection.sourceHandle === 'false'){
171 | updatedNode.data.false_next = connection.target
172 | } else {
173 | // General connection
174 | if(!updatedNode.data.nexts) {
175 | updatedNode.data.nexts = []
176 | }
177 |
178 | if (! (updatedNode.data.nexts as string[]).includes(connection.target)){
179 | (updatedNode.data.nexts as string[]).push(connection.target);
180 | }
181 | }
182 |
183 | } else if(updatedNode.id === connection.target){
184 | if(!updatedNode.data.prevs) {
185 | updatedNode.data.prevs = []
186 | }
187 | if (! (updatedNode.data.prevs as string[]).includes(connection.source)){
188 | (updatedNode.data.prevs as string[]).push(connection.source)
189 | }
190 |
191 | }
192 |
193 | return updatedNode;
194 | })
195 |
196 |
197 | const updatedEdges = [...currentGraph().edges, newEdge];
198 | updateSubGraph(currentGraphName, {
199 | ...currentGraph(),
200 | edges: updatedEdges,
201 | nodes: updatedNodes
202 | });
203 |
204 | }, [currentGraph, currentGraphName, updateSubGraph])
205 |
206 | const handlePanelContextMenu = useCallback((event: React.MouseEvent, setContextMenu: React.Dispatch>) => {
207 | event.preventDefault();
208 | const target = event.target as HTMLElement;
209 | const nodeElement = target.closest('.react-flow__node') as HTMLElement;
210 | const edgeElement = target.closest('.react-flow__edge') as HTMLElement;
211 |
212 | if (nodeElement) {
213 | const nodeId = nodeElement.getAttribute("data-id")
214 | setContextMenu({
215 | mouseX: event.clientX,
216 | mouseY: event.clientY,
217 | nodeId: nodeId,
218 | edgeId: null,
219 | type: 'node'
220 | });
221 | } else if (edgeElement) {
222 | const edgeId = edgeElement.getAttribute('data-id');
223 | setContextMenu({
224 | mouseX: event.clientX,
225 | mouseY: event.clientY,
226 | nodeId: null,
227 | edgeId: edgeId,
228 | type: 'edge'
229 | })
230 | }
231 | else {
232 | setContextMenu({
233 | mouseX: event.clientX,
234 | mouseY: event.clientY,
235 | nodeId: null,
236 | edgeId: null,
237 | type: 'panel'
238 | });
239 | }
240 |
241 | }, []);
242 |
243 | return { handleAddNode, handleDeleteNode, handleDeleteEdge, handlePanelContextMenu, handleAddEdge }
244 | }
--------------------------------------------------------------------------------