diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f0083ba --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +build + +**/node_modules +**/build +**/dist diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..f842b51 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,28 @@ +module.exports = { + extends: [ + 'eslint:recommended', + 'plugin:markdown/recommended', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended', + 'plugin:jsx-a11y/recommended', + 'plugin:prettier/recommended' + ], + settings: { + react: { + version: 'detect' + } + }, + parser: '@typescript-eslint/parser', + ignorePatterns: ['**/node_modules', '**/dist', '**/build', '**/package-lock.json'], + plugins: ['unused-imports'], + rules: { + '@typescript-eslint/explicit-module-boundary-types': 'off', + 'no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'warn', + 'unused-imports/no-unused-vars': ['warn', { vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' }], + 'no-undef': 'off', + 'no-console': [process.env.CI ? 'error' : 'warn', { allow: ['warn', 'error', 'info'] }], + 'prettier/prettier': 'error' + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..b731850 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '[BUG]' +labels: '' +assignees: '' +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Setup** + +- OS: [e.g. iOS, Windows, Linux] +- Browser [e.g. chrome, safari] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..557a358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,13 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '[FEATURE]' +labels: '' +assignees: '' +--- + +**Describe the feature you'd like** +A clear and concise description of what you would like Flowise to have. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..afc4307 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,36 @@ +name: Node CI + +on: + push: + branches: + - master + + pull_request: + branches: + - '*' + +permissions: + contents: read + +jobs: + build: + strategy: + matrix: + platform: [ubuntu-latest] + node-version: [14.x, 16.x] + runs-on: ${{ matrix.platform }} + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - run: npm i -g yarn + + - run: yarn install --ignore-engines + + - run: yarn lint + + - run: yarn build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5604f48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# editor +.idea +.vscode + +# dependencies +**/node_modules +**/package-lock.json +**/yarn.lock + +## logs +**/*.log + +## build +**/dist +**/build + +## temp +**/tmp +**/temp + +## test +**/coverage + +# misc +.DS_Store + +## env +.env.local +.env.development.local +.env.test.local +.env.production.local +.env + +## turbo +.turbo + +## secrets +**/*.key +**/api.json + +## compressed +**/*.tgz \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..7aba047 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn quick # prettify +yarn lint-staged # eslint lint(also include prettify but prettify support more file extensions than eslint, so run prettify first) \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..ba08339 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +**/node_modules +**/dist +**/build \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..0008576 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,9 @@ +module.exports = { + printWidth: 140, + singleQuote: true, + jsxSingleQuote: true, + trailingComma: 'none', + tabWidth: 4, + semi: false, + endOfLine: 'auto' +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..7865b84 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at hello@flowiseai.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..afe9f5d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,127 @@ + + +# Contributing to Flowise + +We appreciate any form of contributions. + +## ⭐ Star + +Star and share the [Github Repo](https://github.com/FlowiseAI/Flowise). + +## πŸ™‹ Q&A + +Search up for any questions in [Q&A section](https://github.com/FlowiseAI/Flowise/discussions/categories/q-a), if you can't find one, don't hesitate to create one. It might helps others that have similar question. + +## πŸ™Œ Share Chatflow + +Yes! Sharing how you use Flowise is a way of contribution. Export your chatflow as JSON, attach a screenshot and share it in [Show and Tell section](https://github.com/FlowiseAI/Flowise/discussions/categories/show-and-tell). + +## πŸ’‘ Ideas + +Ideas are welcome such as new feature, apps integration, and blockchain networks. Submit in [Ideas section](https://github.com/FlowiseAI/Flowise/discussions/categories/ideas). + +## 🐞 Report Bugs + +Found an issue? [Report it](https://github.com/FlowiseAI/Flowise/issues/new/choose). + +## πŸ‘¨β€πŸ’» Contribute to Code + +Not sure what to contribute? Some ideas: + +- Create new components from Langchain +- Update existing components such as extending functionality, fixing bugs +- Add new chatflow ideas + +### Developers + +Flowise has 3 different modules in a single mono repository. + +- `server`: Node backend to serve API logics +- `ui`: React frontend +- `components`: Langchain components + +#### Prerequisite + +- Install Yarn + ```bash + npm i -g yarn + ``` + +#### Step by step + +1. Fork the official [Flowise Github Repository](https://github.com/FlowiseAI/Flowise). + +2. Clone your forked repository. + +3. Create a new branch, see [guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-and-deleting-branches-within-your-repository). Naming conventions: + + - For feature branch: `feature/` + - For bug fix branch: `bugfix/`. + +4. Switch to the newly created branch. + +5. Go into repository folder + + ```bash + cd Flowise + ``` + +6. Install all dependencies of all modules: + + ```bash + yarn install + ``` + +7. Build all the code: + + ```bash + yarn build + ``` + +8. Start the app on [http://localhost:3000](http://localhost:3000) + + ```bash + yarn start + ``` + +9. For development, run + + ```bash + yarn dev + ``` + + Any changes made in `packages/ui` or `packages/server` will be reflected on [http://localhost:8080](http://localhost:8080) + + For changes made in `packages/components`, run `yarn build` again to pickup the changes. + +10. After making all the changes, run + + ```bash + yarn build + ``` + + and + + ```bash + yarn start + ``` + + to make sure everything works fine in production. + +11. Commit code and submit Pull Request from forked branch pointing to [Flowise master](https://github.com/FlowiseAI/Flowise/tree/master). + +## πŸ“– Contribute to Docs + +In-Progress + +## 🏷️ Pull Request process + +A member of the FlowiseAI team will automatically be notified/assigned when you open a pull request. You can also reach out to us on [Discord](https://discord.gg/GWcGczPk). + +## πŸ“ƒ Contributor License Agreement + +Before we can merge your contribution you have to sign our [Contributor License Agreement (CLA)](https://cla-assistant.io/FlowiseAI/Flowise). The CLA contains the terms and conditions under which the contribution is submitted. You need to do this only once for your first pull request. Keep in mind that without a signed CLA we cannot merge your contribution. + +## πŸ“œ Code of Conduct + +This project and everyone participating in it are governed by the Code of Conduct which can be found in the [file](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to hello@flowiseai.com. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..705ffa2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Build local monorepo image +# docker build --no-cache -t flowise . +# Run image +# docker run -d -p 3000:3000 flowise +FROM node:16 + +WORKDIR /usr/src/packages + +# Copy root package.json and lockfile +COPY package.json ./ +COPY yarn.lock ./ + +# Copy components package.json +COPY packages/components/package.json ./packages/components/package.json + +# Copy ui package.json +COPY packages/ui/package.json ./packages/ui/package.json + +# Copy server package.json +COPY packages/server/package.json ./packages/server/package.json + +RUN yarn install + +# Copy app source +COPY . . + +RUN yarn build + +EXPOSE 3000 + +CMD [ "yarn", "start" ] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..56552bd --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2023 FlowiseAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c12825e --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ + + +# Flowise - LangchainJS UI + + + + +Drag & drop UI to build your customized LLM flow using [LangchainJS](https://github.com/hwchase17/langchainjs) + +## ⚑Quick Start + +1. Install Flowise + ```bash + npm install -g flowise + ``` +2. Start FlowiseAI + + ```bash + npx flowise start + ``` + +3. Open [http://localhost:3000](http://localhost:3000) + +## 🐳 Docker + +1. Go to `docker` folder at the root of the project +2. `docker-compose up -d` +3. This will automatically spins up mongodb and flowise containers +4. Open [http://localhost:3000](http://localhost:3000) +5. You can bring the containers down by `docker-compose stop` + +## πŸ‘¨β€πŸ’» Developers + +Flowise has 3 different modules in a single mono repository. + +- `server`: Node backend to serve API logics +- `ui`: React frontend +- `components`: Langchain components + +### Prerequisite + +- Install Yarn + ```bash + npm i -g yarn + ``` + +### Setup + +1. Clone the repository + + ```bash + git clone https://github.com/FlowiseAI/Flowise.git + ``` + +2. Go into repository folder + + ```bash + cd Flowise + ``` + +3. Install all dependencies of all modules: + + ```bash + yarn install + ``` + +4. Build all the code: + + ```bash + yarn build + ``` + +5. Start the app: + + ```bash + yarn start + ``` + + You can now access the app on [http://localhost:3000](http://localhost:3000) + +6. For development build: + + ```bash + yarn dev + ``` + + Any code changes will reload the app automatically on [http://localhost:8080](http://localhost:8080) + +## πŸ“– Documentation + +Coming soon + +## πŸ’» Cloud Hosted + +Coming soon + +## 🌐 Self Host + +Coming soon + +## πŸ™‹ Support + +Feel free to ask any questions, raise problems, and request new features in [discussion](https://github.com/FlowiseAI/Flowise/discussions) + +## πŸ™Œ Contributing + +See [contributing guide](CONTRIBUTING.md). Reach out to us at [Discord](https://discord.gg/GWcGczPk) if you have any questions or issues. + +## πŸ“„ License + +Source code in this repository is made available under the [MIT License](LICENSE.md). diff --git a/assets/Demo.png b/assets/Demo.png new file mode 100644 index 0000000..5b875c8 Binary files /dev/null and b/assets/Demo.png differ diff --git a/assets/FloWiseAI.png b/assets/FloWiseAI.png new file mode 100644 index 0000000..cb3d563 Binary files /dev/null and b/assets/FloWiseAI.png differ diff --git a/assets/FloWiseAI_black.png b/assets/FloWiseAI_black.png new file mode 100644 index 0000000..bc49f25 Binary files /dev/null and b/assets/FloWiseAI_black.png differ diff --git a/assets/FloWiseAI_dark.png b/assets/FloWiseAI_dark.png new file mode 100644 index 0000000..55ca977 Binary files /dev/null and b/assets/FloWiseAI_dark.png differ diff --git a/assets/FloWiseAI_primary.png b/assets/FloWiseAI_primary.png new file mode 100644 index 0000000..3a9f1f0 Binary files /dev/null and b/assets/FloWiseAI_primary.png differ diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..4a7f96e --- /dev/null +++ b/babel.config.js @@ -0,0 +1,13 @@ +module.exports = { + presets: [ + '@babel/preset-typescript', + [ + '@babel/preset-env', + { + targets: { + node: 'current' + } + } + ] + ] +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..442b98e --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,13 @@ +FROM node:14.20.0-alpine + +USER root + +RUN apk add --no-cache git +RUN apk add --no-cache python3 py3-pip + +# You can install a specific version like: flowise@1.0.0 +RUN npm install -g flowise + +WORKDIR /data + +CMD "flowise" \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..b64ba73 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.1' + +services: + mongo: + image: mongo + ports: + - '27017:27017' + restart: always + environment: + - MONGO_INITDB_DATABASE=flowiseai + + flowise: + image: flowiseai/flowise + restart: always + environment: + - PORT=${PORT} + ports: + - '${PORT}:${PORT}' + links: + - mongo + volumes: + - ~/.flowise:/root/.flowise + command: /bin/sh -c "sleep 3; flowise start" diff --git a/images/flowise.gif b/images/flowise.gif new file mode 100644 index 0000000..07101bc Binary files /dev/null and b/images/flowise.gif differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..794c3b6 --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "flowise", + "version": "1.0.0", + "private": true, + "homepage": "https://flowiseai.com", + "workspaces": [ + "packages/*", + "flowise", + "ui", + "components" + ], + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev --parallel", + "start": "run-script-os", + "start:windows": "cd packages/server/bin && run start", + "start:default": "cd packages/server/bin && ./run start", + "clean": "npm exec -ws -- rimraf dist build", + "format": "prettier --write \"**/*.{ts,tsx,md}\"", + "test": "turbo run test", + "lint": "eslint \"**/*.{js,jsx,ts,tsx,json,md}\"", + "lint-fix": "yarn lint --fix", + "quick": "pretty-quick --staged", + "postinstall": "husky install" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx,json,md}": "eslint --fix" + }, + "devDependencies": { + "turbo": "1.7.4", + "@babel/preset-env": "^7.19.4", + "@babel/preset-typescript": "7.18.6", + "@types/express": "^4.17.13", + "@typescript-eslint/typescript-estree": "^5.39.0", + "eslint": "^8.24.0", + "eslint-config-prettier": "^8.3.0", + "eslint-config-react-app": "^7.0.1", + "eslint-plugin-jsx-a11y": "^6.6.1", + "eslint-plugin-markdown": "^3.0.0", + "eslint-plugin-prettier": "^3.4.0", + "eslint-plugin-react": "^7.26.1", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-unused-imports": "^2.0.0", + "husky": "^8.0.1", + "lint-staged": "^13.0.3", + "prettier": "^2.7.1", + "pretty-quick": "^3.1.3", + "rimraf": "^3.0.2", + "run-script-os": "^1.1.6", + "typescript": "^4.8.4" + }, + "engines": { + "node": ">=18.15.0" + } +} diff --git a/packages/components/README.md b/packages/components/README.md new file mode 100644 index 0000000..5b564be --- /dev/null +++ b/packages/components/README.md @@ -0,0 +1,17 @@ + + +# Flowise Components + +Apps integration for Flowise. Contain Nodes and Credentials. + +![Flowise](https://github.com/FlowiseAI/Flowise/blob/main/images/flowise.gif?raw=true) + +Install: + +```bash +npm i flowise-components +``` + +## License + +Source code in this repository is made available under the [MIT License](https://github.com/FlowiseAI/Flowise/blob/master/LICENSE.md). diff --git a/packages/components/gulpfile.ts b/packages/components/gulpfile.ts new file mode 100644 index 0000000..c4d2d35 --- /dev/null +++ b/packages/components/gulpfile.ts @@ -0,0 +1,9 @@ +import gulp from 'gulp' + +const { src, dest } = gulp + +function copyIcons() { + return src(['nodes/**/*.{jpg,png,svg}']).pipe(dest('dist/nodes')) +} + +exports.default = copyIcons diff --git a/packages/components/nodes/agents/MRLKAgentLLM/MRLKAgentLLM.ts b/packages/components/nodes/agents/MRLKAgentLLM/MRLKAgentLLM.ts new file mode 100644 index 0000000..00ea31d --- /dev/null +++ b/packages/components/nodes/agents/MRLKAgentLLM/MRLKAgentLLM.ts @@ -0,0 +1,58 @@ +import { INode, INodeData, INodeParams } from '../../../src/Interface' + +class MRLKAgentLLM implements INode { + label: string + name: string + description: string + type: string + icon: string + category: string + baseClasses: string[] + inputs: INodeParams[] + + constructor() { + this.label = 'MRLK Agent for LLMs' + this.name = 'mrlkAgentLLM' + this.type = 'AgentExecutor' + this.category = 'Agents' + this.icon = 'agent.svg' + this.description = 'Agent that uses the ReAct Framework to decide what action to take, optimized to be used with LLMs' + this.inputs = [ + { + label: 'Allowed Tools', + name: 'tools', + type: 'Tool', + list: true + }, + { + label: 'LLM Model', + name: 'model', + type: 'BaseLanguageModel' + } + ] + } + + async getBaseClasses(): Promise { + return ['AgentExecutor'] + } + + async init(nodeData: INodeData): Promise { + const { initializeAgentExecutor } = await import('langchain/agents') + + const model = nodeData.inputs?.model + const tools = nodeData.inputs?.tools + + const executor = await initializeAgentExecutor(tools, model, 'zero-shot-react-description', true) + + return executor + } + + async run(nodeData: INodeData, input: string): Promise { + const executor = nodeData.instance + const result = await executor.call({ input }) + + return result?.output + } +} + +module.exports = { nodeClass: MRLKAgentLLM } diff --git a/packages/components/nodes/agents/MRLKAgentLLM/agent.svg b/packages/components/nodes/agents/MRLKAgentLLM/agent.svg new file mode 100644 index 0000000..c87861e --- /dev/null +++ b/packages/components/nodes/agents/MRLKAgentLLM/agent.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/components/nodes/chains/LLMChain/LLMChain.ts b/packages/components/nodes/chains/LLMChain/LLMChain.ts new file mode 100644 index 0000000..1dab8ea --- /dev/null +++ b/packages/components/nodes/chains/LLMChain/LLMChain.ts @@ -0,0 +1,61 @@ +import { INode, INodeData, INodeParams } from '../../../src/Interface' +import { getBaseClasses } from '../../../src/utils' + +class LLMChain_Chains implements INode { + label: string + name: string + type: string + icon: string + category: string + baseClasses: string[] + description: string + inputs: INodeParams[] + + constructor() { + this.label = 'LLM Chain' + this.name = 'llmChain' + this.type = 'LLMChain' + this.icon = 'chain.svg' + this.category = 'Chains' + this.description = 'Chain to run queries against LLMs' + this.inputs = [ + { + label: 'LLM', + name: 'llm', + type: 'BaseLanguageModel' + }, + { + label: 'Prompt', + name: 'prompt', + type: 'BasePromptTemplate' + } + ] + } + + async getBaseClasses(): Promise { + const { LLMChain } = await import('langchain/chains') + return getBaseClasses(LLMChain) + } + + async init(nodeData: INodeData): Promise { + const { LLMChain } = await import('langchain/chains') + + const llm = nodeData.inputs?.llm + const prompt = nodeData.inputs?.prompt + + const chain = new LLMChain({ llm, prompt }) + return chain + } + + async run(nodeData: INodeData, input: string): Promise { + const prompt = nodeData.instance.prompt.inputVariables // ["product"] + if (prompt.length > 1) throw new Error('Prompt can only contains 1 literal string {}. Multiples are found') + + const chain = nodeData.instance + const res = await chain.run(input) + + return res + } +} + +module.exports = { nodeClass: LLMChain_Chains } diff --git a/packages/components/nodes/chains/LLMChain/chain.svg b/packages/components/nodes/chains/LLMChain/chain.svg new file mode 100644 index 0000000..a5b32f9 --- /dev/null +++ b/packages/components/nodes/chains/LLMChain/chain.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/components/nodes/chatmodels/ChatOpenAI/ChatOpenAI.ts b/packages/components/nodes/chatmodels/ChatOpenAI/ChatOpenAI.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/components/nodes/chatmodels/ChatOpenAI/openai.png b/packages/components/nodes/chatmodels/ChatOpenAI/openai.png new file mode 100644 index 0000000..de08a05 Binary files /dev/null and b/packages/components/nodes/chatmodels/ChatOpenAI/openai.png differ diff --git a/packages/components/nodes/llms/OpenAI/OpenAI.ts b/packages/components/nodes/llms/OpenAI/OpenAI.ts new file mode 100644 index 0000000..7bfa15d --- /dev/null +++ b/packages/components/nodes/llms/OpenAI/OpenAI.ts @@ -0,0 +1,83 @@ +import { INode, INodeData, INodeParams } from '../../../src/Interface' +import { getBaseClasses } from '../../../src/utils' + +class OpenAI_LLMs implements INode { + label: string + name: string + type: string + icon: string + category: string + description: string + baseClasses: string[] + inputs: INodeParams[] + + constructor() { + this.label = 'OpenAI' + this.name = 'openAI' + this.type = 'OpenAI' + this.icon = 'openai.png' + this.category = 'LLMs' + this.description = 'Wrapper around OpenAI large language models' + this.inputs = [ + { + label: 'OpenAI Api Key', + name: 'openAIApiKey', + type: 'password' + }, + { + label: 'Model Name', + name: 'modelName', + type: 'options', + options: [ + { + label: 'text-davinci-003', + name: 'text-davinci-003' + }, + { + label: 'text-davinci-002', + name: 'text-davinci-002' + }, + { + label: 'text-curie-001', + name: 'text-curie-001' + }, + { + label: 'text-babbage-001', + name: 'text-babbage-001' + } + ], + default: 'text-davinci-003', + optional: true + }, + { + label: 'Temperature', + name: 'temperature', + type: 'number', + default: 0.7, + optional: true + } + ] + } + + async getBaseClasses(): Promise { + const { OpenAI } = await import('langchain/llms') + return getBaseClasses(OpenAI) + } + + async init(nodeData: INodeData): Promise { + const { OpenAI } = await import('langchain/llms') + + const temperature = nodeData.inputs?.temperature as string + const modelName = nodeData.inputs?.modelName as string + const openAIApiKey = nodeData.inputs?.openAIApiKey as string + + const model = new OpenAI({ + temperature: parseInt(temperature, 10), + modelName, + openAIApiKey + }) + return model + } +} + +module.exports = { nodeClass: OpenAI_LLMs } diff --git a/packages/components/nodes/llms/OpenAI/openai.png b/packages/components/nodes/llms/OpenAI/openai.png new file mode 100644 index 0000000..de08a05 Binary files /dev/null and b/packages/components/nodes/llms/OpenAI/openai.png differ diff --git a/packages/components/nodes/prompts/ChatPromptTemplate/ChatPromptTemplate.ts b/packages/components/nodes/prompts/ChatPromptTemplate/ChatPromptTemplate.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/components/nodes/prompts/HumanMessagePromptTemplate/HumanMessagePromptTemplate.ts b/packages/components/nodes/prompts/HumanMessagePromptTemplate/HumanMessagePromptTemplate.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/components/nodes/prompts/PromptTemplate/PromptTemplate.ts b/packages/components/nodes/prompts/PromptTemplate/PromptTemplate.ts new file mode 100644 index 0000000..11fd26f --- /dev/null +++ b/packages/components/nodes/prompts/PromptTemplate/PromptTemplate.ts @@ -0,0 +1,56 @@ +import { INode, INodeData, INodeParams } from '../../../src/Interface' +import { getBaseClasses, getInputVariables } from '../../../src/utils' + +class PromptTemplate_Prompts implements INode { + label: string + name: string + description: string + type: string + icon: string + category: string + baseClasses: string[] + inputs: INodeParams[] + + constructor() { + this.label = 'Prompt Template' + this.name = 'promptTemplate' + this.type = 'PromptTemplate' + this.icon = 'prompt.svg' + this.category = 'Prompts' + this.description = 'Schema to represent a basic prompt for an LLM. Template can only contains 1 literal string {}' + this.inputs = [ + { + label: 'Template', + name: 'template', + type: 'string', + rows: 5, + default: 'What is a good name for a company that makes {product}?', + placeholder: 'What is a good name for a company that makes {product}?' + } + ] + } + + async getBaseClasses(): Promise { + const { PromptTemplate } = await import('langchain/prompts') + return getBaseClasses(PromptTemplate) + } + + async init(nodeData: INodeData): Promise { + const { PromptTemplate } = await import('langchain/prompts') + + const template = nodeData.inputs?.template as string + const inputVariables = getInputVariables(template) + + try { + const prompt = new PromptTemplate({ + template, + inputVariables: inputVariables + }) + return prompt + } catch (e) { + throw new Error(e) + } + } +} + +module.exports = { nodeClass: PromptTemplate_Prompts } diff --git a/packages/components/nodes/prompts/PromptTemplate/prompt.svg b/packages/components/nodes/prompts/PromptTemplate/prompt.svg new file mode 100644 index 0000000..7e48611 --- /dev/null +++ b/packages/components/nodes/prompts/PromptTemplate/prompt.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/components/nodes/prompts/SysmtemMessagePromptTemplate/SysmtemMessagePromptTemplate.ts b/packages/components/nodes/prompts/SysmtemMessagePromptTemplate/SysmtemMessagePromptTemplate.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/components/nodes/tools/Calculator/Calculator.ts b/packages/components/nodes/tools/Calculator/Calculator.ts new file mode 100644 index 0000000..35a304d --- /dev/null +++ b/packages/components/nodes/tools/Calculator/Calculator.ts @@ -0,0 +1,33 @@ +import { INode } from '../../../src/Interface' +import { getBaseClasses } from '../../../src/utils' + +class Calculator implements INode { + label: string + name: string + description: string + type: string + icon: string + category: string + baseClasses: string[] + + constructor() { + this.label = 'Calculator' + this.name = 'calculator' + this.type = 'Calculator' + this.icon = 'calculator.svg' + this.category = 'Tools' + this.description = 'Perform calculations on response' + } + + async getBaseClasses(): Promise { + const { Calculator } = await import('langchain/tools') + return getBaseClasses(Calculator) + } + + async init(): Promise { + const { Calculator } = await import('langchain/tools') + return new Calculator() + } +} + +module.exports = { nodeClass: Calculator } diff --git a/packages/components/nodes/tools/Calculator/calculator.svg b/packages/components/nodes/tools/Calculator/calculator.svg new file mode 100644 index 0000000..6fa49e1 --- /dev/null +++ b/packages/components/nodes/tools/Calculator/calculator.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/components/nodes/tools/SerpAPI/SerpAPI.ts b/packages/components/nodes/tools/SerpAPI/SerpAPI.ts new file mode 100644 index 0000000..ea58b5a --- /dev/null +++ b/packages/components/nodes/tools/SerpAPI/SerpAPI.ts @@ -0,0 +1,42 @@ +import { INode, INodeData, INodeParams } from '../../../src/Interface' +import { getBaseClasses } from '../../../src/utils' + +class SerpAPI implements INode { + label: string + name: string + description: string + type: string + icon: string + category: string + baseClasses: string[] + inputs: INodeParams[] + + constructor() { + this.label = 'Serp API' + this.name = 'serpAPI' + this.type = 'SerpAPI' + this.icon = 'serp.png' + this.category = 'Tools' + this.description = 'Wrapper around SerpAPI - a real-time API to access Google search results' + this.inputs = [ + { + label: 'Serp Api Key', + name: 'apiKey', + type: 'password' + } + ] + } + + async getBaseClasses(): Promise { + const { SerpAPI } = await import('langchain/tools') + return getBaseClasses(SerpAPI) + } + + async init(nodeData: INodeData): Promise { + const { SerpAPI } = await import('langchain/tools') + const apiKey = nodeData.inputs?.apiKey as string + return new SerpAPI(apiKey) + } +} + +module.exports = { nodeClass: SerpAPI } diff --git a/packages/components/nodes/tools/SerpAPI/serp.png b/packages/components/nodes/tools/SerpAPI/serp.png new file mode 100644 index 0000000..338aeae Binary files /dev/null and b/packages/components/nodes/tools/SerpAPI/serp.png differ diff --git a/packages/components/package.json b/packages/components/package.json new file mode 100644 index 0000000..d5e3f65 --- /dev/null +++ b/packages/components/package.json @@ -0,0 +1,34 @@ +{ + "name": "flowise-components", + "version": "1.0.0", + "description": "Flowiseai Components", + "main": "dist/src/index", + "types": "dist/src/index.d.ts", + "scripts": { + "build": "tsc && gulp", + "dev": "tsc --watch" + }, + "keywords": [], + "homepage": "https://flowiseai.com", + "author": { + "name": "Henry Heng", + "email": "henryheng@flowiseai.com" + }, + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "axios": "^0.27.2", + "dotenv": "^16.0.0", + "express": "^4.17.3", + "form-data": "^4.0.0", + "langchain": "^0.0.44", + "moment": "^2.29.3", + "node-fetch": "2", + "ws": "^8.9.0" + }, + "devDependencies": { + "@types/gulp": "4.0.9", + "@types/ws": "^8.5.3", + "gulp": "^4.0.2", + "typescript": "^4.8.4" + } +} diff --git a/packages/components/src/Interface.ts b/packages/components/src/Interface.ts new file mode 100644 index 0000000..6d1d8ea --- /dev/null +++ b/packages/components/src/Interface.ts @@ -0,0 +1,83 @@ +/** + * Types + */ + +export type NodeParamsType = + | 'asyncOptions' + | 'options' + | 'string' + | 'number' + | 'boolean' + | 'password' + | 'json' + | 'code' + | 'date' + | 'file' + | 'folder' + +export type CommonType = string | number | boolean | undefined | null + +/** + * Others + */ + +export interface ICommonObject { + [key: string]: any | CommonType | ICommonObject | CommonType[] | ICommonObject[] +} + +export interface IAttachment { + content: string + contentType: string + size?: number + filename?: string +} + +export interface INodeOptionsValue { + label: string + name: string + description?: string +} + +export interface INodeParams { + label: string + name: string + type: NodeParamsType | string + default?: CommonType | ICommonObject | ICommonObject[] + description?: string + options?: Array + optional?: boolean | INodeDisplay + rows?: number + list?: boolean + placeholder?: string +} + +export interface INodeExecutionData { + [key: string]: CommonType | CommonType[] | ICommonObject | ICommonObject[] +} + +export interface INodeDisplay { + [key: string]: string[] | string +} + +export interface INodeProperties { + label: string + name: string + type: string + icon: string + category: string + baseClasses: string[] + description?: string + filePath?: string +} + +export interface INode extends INodeProperties { + inputs?: INodeParams[] + getBaseClasses?(): Promise + getInstance?(nodeData: INodeData): Promise + run?(nodeData: INodeData, input: string): Promise +} + +export interface INodeData extends INodeProperties { + inputs?: ICommonObject + instance?: any +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts new file mode 100644 index 0000000..537faa8 --- /dev/null +++ b/packages/components/src/index.ts @@ -0,0 +1,2 @@ +export * from './Interface' +export * from './utils' diff --git a/packages/components/src/utils.ts b/packages/components/src/utils.ts new file mode 100644 index 0000000..9e46257 --- /dev/null +++ b/packages/components/src/utils.ts @@ -0,0 +1,144 @@ +import * as fs from 'fs' +import * as path from 'path' + +export const numberOrExpressionRegex = '^(\\d+\\.?\\d*|{{.*}})$' //return true if string consists only numbers OR expression {{}} +export const notEmptyRegex = '(.|\\s)*\\S(.|\\s)*' //return true if string is not empty or blank + +export const getBaseClasses = (targetClass: any) => { + const baseClasses: string[] = [] + + if (targetClass instanceof Function) { + let baseClass = targetClass + + while (baseClass) { + const newBaseClass = Object.getPrototypeOf(baseClass) + if (newBaseClass && newBaseClass !== Object && newBaseClass.name) { + baseClass = newBaseClass + baseClasses.push(baseClass.name) + } else { + break + } + } + } + return baseClasses +} + +/** + * Serialize axios query params + * + * @export + * @param {any} params + * @param {boolean} skipIndex // Set to true if you want same params to be: param=1¶m=2 instead of: param[0]=1¶m[1]=2 + * @returns {string} + */ +export function serializeQueryParams(params: any, skipIndex?: boolean): string { + const parts: any[] = [] + + const encode = (val: string) => { + return encodeURIComponent(val) + .replace(/%3A/gi, ':') + .replace(/%24/g, '$') + .replace(/%2C/gi, ',') + .replace(/%20/g, '+') + .replace(/%5B/gi, '[') + .replace(/%5D/gi, ']') + } + + const convertPart = (key: string, val: any) => { + if (val instanceof Date) val = val.toISOString() + else if (val instanceof Object) val = JSON.stringify(val) + + parts.push(encode(key) + '=' + encode(val)) + } + + Object.entries(params).forEach(([key, val]) => { + if (val === null || typeof val === 'undefined') return + + if (Array.isArray(val)) val.forEach((v, i) => convertPart(`${key}${skipIndex ? '' : `[${i}]`}`, v)) + else convertPart(key, val) + }) + + return parts.join('&') +} + +/** + * Handle error from try catch + * + * @export + * @param {any} error + * @returns {string} + */ +export function handleErrorMessage(error: any): string { + let errorMessage = '' + + if (error.message) { + errorMessage += error.message + '. ' + } + + if (error.response && error.response.data) { + if (error.response.data.error) { + if (typeof error.response.data.error === 'object') errorMessage += JSON.stringify(error.response.data.error) + '. ' + else if (typeof error.response.data.error === 'string') errorMessage += error.response.data.error + '. ' + } else if (error.response.data.msg) errorMessage += error.response.data.msg + '. ' + else if (error.response.data.Message) errorMessage += error.response.data.Message + '. ' + else if (typeof error.response.data === 'string') errorMessage += error.response.data + '. ' + } + + if (!errorMessage) errorMessage = 'Unexpected Error.' + + return errorMessage +} + +/** + * Returns the path of node modules package + * @param {string} packageName + * @returns {string} + */ +export const getNodeModulesPackagePath = (packageName: string): string => { + const checkPaths = [ + path.join(__dirname, '..', 'node_modules', packageName), + path.join(__dirname, '..', '..', 'node_modules', packageName), + path.join(__dirname, '..', '..', '..', 'node_modules', packageName), + path.join(__dirname, '..', '..', '..', '..', 'node_modules', packageName), + path.join(__dirname, '..', '..', '..', '..', '..', 'node_modules', packageName) + ] + for (const checkPath of checkPaths) { + if (fs.existsSync(checkPath)) { + return checkPath + } + } + return '' +} + +/** + * Get input variables + * @param {string} paramValue + * @returns {boolean} + */ +export const getInputVariables = (paramValue: string): string[] => { + let returnVal = paramValue + const variableStack = [] + const inputVariables = [] + let startIdx = 0 + const endIdx = returnVal.length - 1 + + while (startIdx < endIdx) { + const substr = returnVal.substring(startIdx, startIdx + 1) + + // Store the opening double curly bracket + if (substr === '{') { + variableStack.push({ substr, startIdx: startIdx + 1 }) + } + + // Found the complete variable + if (substr === '}' && variableStack.length > 0 && variableStack[variableStack.length - 1].substr === '{') { + const variableStartIdx = variableStack[variableStack.length - 1].startIdx + const variableEndIdx = startIdx + const variableFullPath = returnVal.substring(variableStartIdx, variableEndIdx) + inputVariables.push(variableFullPath) + variableStack.pop() + } + startIdx += 1 + } + return inputVariables +} diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json new file mode 100644 index 0000000..96e7c60 --- /dev/null +++ b/packages/components/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["ES2020"], + "experimentalDecorators": true /* Enable experimental support for TC39 stage 2 draft decorators. */, + "emitDecoratorMetadata": true /* Emit design-type metadata for decorated declarations in source files. */, + "target": "ES2020", // or higher + "outDir": "./dist/", + "resolveJsonModule": true, + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + "strict": true /* Enable all strict type-checking options. */, + "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "sourceMap": true, + "strictPropertyInitialization": false, + "useUnknownInCatchVariables": false, + "declaration": true, + "module": "commonjs", + "moduleResolution": "node16" + }, + "include": ["src", "nodes"] +} diff --git a/packages/server/README.md b/packages/server/README.md new file mode 100644 index 0000000..b7e3031 --- /dev/null +++ b/packages/server/README.md @@ -0,0 +1,45 @@ + + +# Flowise - LangchainJS UI + +![Flowise](https://github.com/FlowiseAI/Flowise/blob/main/images/flowise.gif?raw=true) + +Drag & drop UI to build your customized LLM flow using [LangchainJS](https://github.com/hwchase17/langchainjs) + +## ⚑Quick Start + +1. Install Flowise + ```bash + npm install -g flowise + ``` +2. Start Flowise + + ```bash + npx flowise start + ``` + +3. Open [http://localhost:3000](http://localhost:3000) + +## πŸ“– Documentation + +Coming Soon + +## πŸ’» Cloud Hosted + +Coming Soon + +## 🌐 Self Host + +Coming Soon + +## πŸ™‹ Support + +Feel free to ask any questions, raise problems, and request new features in [discussion](https://github.com/FlowiseAI/Flowise/discussions) + +## πŸ™Œ Contributing + +See [contributing guide](https://github.com/FlowiseAI/Flowise/blob/master/CONTRIBUTING.md). Reach out to us at [Discord](https://discord.gg/GWcGczPk) if you have any questions or issues. + +## πŸ“„ License + +Source code in this repository is made available under the [MIT License](https://github.com/FlowiseAI/Flowise/blob/master/LICENSE.md). diff --git a/packages/server/babel.config.js b/packages/server/babel.config.js new file mode 100644 index 0000000..f966f26 --- /dev/null +++ b/packages/server/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + extends: '../../babel.config.js' +} diff --git a/packages/server/bin/dev b/packages/server/bin/dev new file mode 100644 index 0000000..d5b5326 --- /dev/null +++ b/packages/server/bin/dev @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +const oclif = require('@oclif/core') + +const path = require('path') +const project = path.join(__dirname, '..', 'tsconfig.json') + +// In dev mode -> use ts-node and dev plugins +process.env.NODE_ENV = 'development' + +require('ts-node').register({ project }) + +// In dev mode, always show stack traces +oclif.settings.debug = true + +// Start the CLI +oclif.run().then(oclif.flush).catch(oclif.Errors.handle) diff --git a/packages/server/bin/dev.cmd b/packages/server/bin/dev.cmd new file mode 100644 index 0000000..077b57a --- /dev/null +++ b/packages/server/bin/dev.cmd @@ -0,0 +1,3 @@ +@echo off + +node "%~dp0\dev" %* \ No newline at end of file diff --git a/packages/server/bin/run b/packages/server/bin/run new file mode 100644 index 0000000..a7635de --- /dev/null +++ b/packages/server/bin/run @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +const oclif = require('@oclif/core') + +oclif.run().then(require('@oclif/core/flush')).catch(require('@oclif/core/handle')) diff --git a/packages/server/bin/run.cmd b/packages/server/bin/run.cmd new file mode 100644 index 0000000..cf40b54 --- /dev/null +++ b/packages/server/bin/run.cmd @@ -0,0 +1,3 @@ +@echo off + +node "%~dp0\run" %* \ No newline at end of file diff --git a/packages/server/nodemon.json b/packages/server/nodemon.json new file mode 100644 index 0000000..d896b48 --- /dev/null +++ b/packages/server/nodemon.json @@ -0,0 +1,6 @@ +{ + "ignore": ["**/*.spec.ts", ".git", "node_modules"], + "watch": ["commands", "index.ts", "src"], + "exec": "yarn oclif-dev", + "ext": "ts" +} diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..5eee511 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,70 @@ +{ + "name": "flowise", + "version": "1.0.0", + "description": "Flowiseai Server", + "main": "dist/index", + "types": "dist/index.d.ts", + "bin": { + "flowise": "./bin/run" + }, + "files": [ + "bin", + "dist", + "npm-shrinkwrap.json", + "oclif.manifest.json", + "oauth2.html", + ".env" + ], + "oclif": { + "bin": "flowise", + "commands": "./dist/commands" + }, + "scripts": { + "build": "tsc", + "start": "run-script-os", + "start:windows": "cd bin && run start", + "start:default": "cd bin && ./run start", + "dev": "concurrently \"yarn watch\" \"nodemon\"", + "oclif-dev": "run-script-os", + "oclif-dev:windows": "cd bin && dev start", + "oclif-dev:default": "cd bin && ./dev start", + "postpack": "shx rm -f oclif.manifest.json", + "prepack": "yarn build && oclif manifest && oclif readme", + "typeorm": "typeorm-ts-node-commonjs", + "watch": "tsc --watch", + "version": "oclif readme && git add README.md" + }, + "keywords": [], + "homepage": "https://flowiseai.com", + "author": { + "name": "Henry Heng", + "email": "henryheng@flowiseai.com" + }, + "engines": { + "node": ">=18.15.0" + }, + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@oclif/core": "^1.13.10", + "axios": "^0.27.2", + "cors": "^2.8.5", + "dotenv": "^16.0.0", + "express": "^4.17.3", + "flowise-components": "*", + "flowise-ui": "*", + "moment-timezone": "^0.5.34", + "reflect-metadata": "^0.1.13", + "sqlite3": "^5.1.6", + "typeorm": "^0.3.6" + }, + "devDependencies": { + "@types/cors": "^2.8.12", + "concurrently": "^7.1.0", + "nodemon": "^2.0.15", + "oclif": "^3", + "run-script-os": "^1.1.6", + "shx": "^0.3.3", + "ts-node": "^10.7.0", + "typescript": "^4.8.4" + } +} diff --git a/packages/server/src/DataSource.ts b/packages/server/src/DataSource.ts new file mode 100644 index 0000000..76c8e14 --- /dev/null +++ b/packages/server/src/DataSource.ts @@ -0,0 +1,27 @@ +import 'reflect-metadata' +import path from 'path' +import { DataSource } from 'typeorm' +import { ChatFlow } from './entity/ChatFlow' +import { ChatMessage } from './entity/ChatMessage' +import { getUserHome } from './utils' + +let appDataSource: DataSource + +export const init = async (): Promise => { + const homePath = path.join(getUserHome(), '.flowise') + + appDataSource = new DataSource({ + type: 'sqlite', + database: path.resolve(homePath, 'database.sqlite'), + synchronize: true, + entities: [ChatFlow, ChatMessage], + migrations: [] + }) +} + +export function getDataSource(): DataSource { + if (appDataSource === undefined) { + init() + } + return appDataSource +} diff --git a/packages/server/src/Interface.ts b/packages/server/src/Interface.ts new file mode 100644 index 0000000..3419b78 --- /dev/null +++ b/packages/server/src/Interface.ts @@ -0,0 +1,101 @@ +import { INode, INodeData } from 'flowise-components' + +export type MessageType = 'apiMessage' | 'userMessage' + +/** + * Databases + */ +export interface IChatFlow { + id: string + name: string + flowData: string + deployed: boolean + updatedDate: Date + createdDate: Date +} + +export interface IChatMessage { + id: string + role: MessageType + content: string + chatflowid: string + createdDate: Date +} + +export interface IComponentNodesPool { + [key: string]: INode +} + +export interface IVariableDict { + [key: string]: string +} + +export interface INodeDependencies { + [key: string]: number +} + +export interface INodeDirectedGraph { + [key: string]: string[] +} + +export interface IReactFlowNode { + id: string + position: { + x: number + y: number + } + type: string + data: INodeData + positionAbsolute: { + x: number + y: number + } + z: number + handleBounds: { + source: any + target: any + } + width: number + height: number + selected: boolean + dragging: boolean +} + +export interface IReactFlowEdge { + source: string + sourceHandle: string + target: string + targetHandle: string + type: string + id: string + data: { + label: string + } +} + +export interface IReactFlowObject { + nodes: IReactFlowNode[] + edges: IReactFlowEdge[] + viewport: { + x: number + y: number + zoom: number + } +} + +export interface IExploredNode { + [key: string]: { + remainingLoop: number + lastSeenDepth: number + } +} + +export interface INodeQueue { + nodeId: string + depth: number +} + +export interface IncomingInput { + question: string + history: string[] +} diff --git a/packages/server/src/NodesPool.ts b/packages/server/src/NodesPool.ts new file mode 100644 index 0000000..8b63045 --- /dev/null +++ b/packages/server/src/NodesPool.ts @@ -0,0 +1,66 @@ +import { IComponentNodesPool } from './Interface' + +import path from 'path' +import { Dirent } from 'fs' +import { getNodeModulesPackagePath } from './utils' +import { promises } from 'fs' + +export class NodesPool { + componentNodes: IComponentNodesPool = {} + + /** + * Initialize to get all nodes + */ + async initialize() { + const packagePath = getNodeModulesPackagePath('flowise-components') + const nodesPath = path.join(packagePath, 'dist', 'nodes') + const nodeFiles = await this.getFiles(nodesPath) + return Promise.all( + nodeFiles.map(async (file) => { + if (file.endsWith('.js')) { + const nodeModule = await require(file) + try { + const newNodeInstance = new nodeModule.nodeClass() + newNodeInstance.filePath = file + + const baseClasses = await newNodeInstance.getBaseClasses!.call(newNodeInstance) + newNodeInstance.baseClasses = baseClasses + + this.componentNodes[newNodeInstance.name] = newNodeInstance + + // Replace file icon with absolute path + if ( + newNodeInstance.icon && + (newNodeInstance.icon.endsWith('.svg') || + newNodeInstance.icon.endsWith('.png') || + newNodeInstance.icon.endsWith('.jpg')) + ) { + const filePath = file.replace(/\\/g, '/').split('/') + filePath.pop() + const nodeIconAbsolutePath = `${filePath.join('/')}/${newNodeInstance.icon}` + this.componentNodes[newNodeInstance.name].icon = nodeIconAbsolutePath + } + } catch (e) { + // console.error(e); + } + } + }) + ) + } + + /** + * Recursive function to get node files + * @param {string} dir + * @returns {string[]} + */ + async getFiles(dir: string): Promise { + const dirents = await promises.readdir(dir, { withFileTypes: true }) + const files = await Promise.all( + dirents.map((dirent: Dirent) => { + const res = path.resolve(dir, dirent.name) + return dirent.isDirectory() ? this.getFiles(res) : res + }) + ) + return Array.prototype.concat(...files) + } +} diff --git a/packages/server/src/commands/start.ts b/packages/server/src/commands/start.ts new file mode 100644 index 0000000..334979e --- /dev/null +++ b/packages/server/src/commands/start.ts @@ -0,0 +1,66 @@ +import { Command, Flags } from '@oclif/core' +import path from 'path' +import * as Server from '../index' +import * as DataSource from '../DataSource' +import dotenv from 'dotenv' + +dotenv.config({ path: path.join(__dirname, '..', '..', '.env') }) + +enum EXIT_CODE { + SUCCESS = 0, + FAILED = 1 +} +let processExitCode = EXIT_CODE.SUCCESS + +export default class Start extends Command { + static flags = { + mongourl: Flags.string() + } + + static args = [] + + async stopProcess() { + console.info('Shutting down Flowise...') + try { + // Shut down the app after timeout if it ever stuck removing pools + setTimeout(() => { + console.info('Flowise was forced to shut down after 30 secs') + process.exit(processExitCode) + }, 30000) + + // Removing pools + const serverApp = Server.getInstance() + if (serverApp) await serverApp.stopApp() + } catch (error) { + console.error('There was an error shutting down Flowise...', error) + } + process.exit(processExitCode) + } + + async run(): Promise { + process.on('SIGTERM', this.stopProcess) + process.on('SIGINT', this.stopProcess) + + // Prevent throw new Error from crashing the app + // TODO: Get rid of this and send proper error message to ui + process.on('uncaughtException', (err) => { + console.error('uncaughtException: ', err) + }) + + const { flags } = await this.parse(Start) + if (flags.mongourl) process.env.MONGO_URL = flags.mongourl + + await (async () => { + try { + this.log('Starting Flowise...') + await DataSource.init() + await Server.start() + } catch (error) { + console.error('There was an error starting Flowise...', error) + processExitCode = EXIT_CODE.FAILED + // @ts-ignore + process.emit('SIGINT') + } + })() + } +} diff --git a/packages/server/src/entity/ChatFlow.ts b/packages/server/src/entity/ChatFlow.ts new file mode 100644 index 0000000..212dfab --- /dev/null +++ b/packages/server/src/entity/ChatFlow.ts @@ -0,0 +1,24 @@ +/* eslint-disable */ +import { Entity, Column, CreateDateColumn, UpdateDateColumn, PrimaryGeneratedColumn } from 'typeorm' +import { IChatFlow } from '../Interface' + +@Entity() +export class ChatFlow implements IChatFlow { + @PrimaryGeneratedColumn('uuid') + id: string + + @Column() + name: string + + @Column() + flowData: string + + @Column() + deployed: boolean + + @CreateDateColumn() + createdDate: Date + + @UpdateDateColumn() + updatedDate: Date +} diff --git a/packages/server/src/entity/ChatMessage.ts b/packages/server/src/entity/ChatMessage.ts new file mode 100644 index 0000000..3380c86 --- /dev/null +++ b/packages/server/src/entity/ChatMessage.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +import { Entity, Column, CreateDateColumn, PrimaryGeneratedColumn, Index } from 'typeorm' +import { IChatMessage, MessageType } from '../Interface' + +@Entity() +export class ChatMessage implements IChatMessage { + @PrimaryGeneratedColumn('uuid') + id: string + + @Column() + role: MessageType + + @Index() + @Column() + chatflowid: string + + @Column() + content: string + + @CreateDateColumn() + createdDate: Date +} diff --git a/packages/server/src/example.ts b/packages/server/src/example.ts new file mode 100644 index 0000000..a2624ff --- /dev/null +++ b/packages/server/src/example.ts @@ -0,0 +1,146 @@ +export const workflow1 = { + nodes: [ + { + width: 200, + height: 66, + id: 'promptTemplate_0', + position: { + x: 295.0571878493141, + y: 108.66221078850214 + }, + type: 'customNode', + data: { + label: 'Prompt Template', + name: 'promptTemplate', + type: 'PromptTemplate', + inputAnchors: [], + outputAnchors: [ + { + id: 'promptTemplate_0-output-0' + } + ], + selected: false, + inputs: { + template: 'What is a good name for a company that makes {product}?', + inputVariables: '["product"]' + } + }, + selected: false, + positionAbsolute: { + x: 295.0571878493141, + y: 108.66221078850214 + }, + dragging: false + }, + { + width: 200, + height: 66, + id: 'openAI_0', + position: { + x: 774, + y: 97.75 + }, + type: 'customNode', + data: { + label: 'OpenAI', + name: 'openAI', + type: 'OpenAI', + inputAnchors: [], + outputAnchors: [ + { + id: 'openAI_0-output-0' + } + ], + selected: false, + inputs: { + modelName: 'text-davinci-003', + temperature: '0.7', + openAIApiKey: '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' + }, + calls: { + prompt: 'Hi, how are you?' + } + }, + selected: false, + positionAbsolute: { + x: 774, + y: 97.75 + }, + dragging: false + }, + { + width: 200, + height: 66, + id: 'llmChain_0', + position: { + x: 1034.233162523021, + y: 97.59868104260748 + }, + type: 'customNode', + data: { + label: 'LLM Chain', + name: 'llmChain', + type: 'LLMChain', + inputAnchors: [ + { + id: 'llmChain_0-input-0' + } + ], + outputAnchors: [ + { + id: 'llmChain_0-output-0' + } + ], + selected: false, + inputs: { + llm: '{{openAI_0.data.instance}}', + prompt: '{{promptTemplate_0.data.instance}}' + }, + calls: { + variable: '{"product":"colorful socks"}' + } + }, + selected: false, + positionAbsolute: { + x: 1034.233162523021, + y: 97.59868104260748 + }, + dragging: false + } + ], + edges: [ + { + source: 'nodeJS_0', + sourceHandle: 'nodeJS_0-output-0', + target: 'nodeJS_1', + targetHandle: 'nodeJS_1-input-0', + type: 'buttonedge', + id: 'nodeJS_0-nodeJS_0-output-0-nodeJS_1-nodeJS_1-input-0', + data: { + label: '' + } + }, + { + source: 'webhook_0', + sourceHandle: 'webhook_0-output-0', + target: 'wait_0', + targetHandle: 'wait_0-input-0', + type: 'buttonedge', + id: 'webhook_0-webhook_0-output-0-wait_0-wait_0-input-0', + data: { + label: '' + } + }, + { + source: 'wait_0', + sourceHandle: 'wait_0-output-0', + target: 'nodeJS_0', + targetHandle: 'nodeJS_0-input-0', + type: 'buttonedge', + id: 'wait_0-wait_0-output-0-nodeJS_0-nodeJS_0-input-0', + data: { + label: '' + } + } + ] +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000..0794d70 --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,261 @@ +import express, { Request, Response } from 'express' +import path from 'path' +import cors from 'cors' +import http from 'http' + +import { IChatFlow, IComponentNodesPool, IncomingInput, IReactFlowNode, IReactFlowObject } from './Interface' +import { getNodeModulesPackagePath, getStartingNode, buildLangchain, getEndingNode, constructGraphs } from './utils' +import { cloneDeep } from 'lodash' +import { getDataSource } from './DataSource' +import { NodesPool } from './NodesPool' +import { ChatFlow } from './entity/ChatFlow' +import { ChatMessage } from './entity/ChatMessage' + +export class App { + app: express.Application + componentNodes: IComponentNodesPool = {} + AppDataSource = getDataSource() + + constructor() { + this.app = express() + } + + async initDatabase() { + // Initialize database + this.AppDataSource.initialize() + .then(async () => { + console.info('πŸ“¦[server]: Data Source has been initialized!') + + // Initialize node instances + const nodesPool = new NodesPool() + await nodesPool.initialize() + this.componentNodes = nodesPool.componentNodes + }) + .catch((err) => { + console.error('❌[server]: Error during Data Source initialization:', err) + }) + } + + async config() { + // Limit is needed to allow sending/receiving base64 encoded string + this.app.use(express.json({ limit: '50mb' })) + this.app.use(express.urlencoded({ limit: '50mb', extended: true })) + + // Allow access from ui when yarn run dev + if (process.env.NODE_ENV !== 'production') { + this.app.use(cors({ credentials: true, origin: 'http://localhost:8080' })) + } + + // ---------------------------------------- + // Nodes + // ---------------------------------------- + + // Get all component nodes + this.app.get('/api/v1/nodes', (req: Request, res: Response) => { + const returnData = [] + for (const nodeName in this.componentNodes) { + const clonedNode = cloneDeep(this.componentNodes[nodeName]) + returnData.push(clonedNode) + } + return res.json(returnData) + }) + + // Get specific component node via name + this.app.get('/api/v1/nodes/:name', (req: Request, res: Response) => { + if (Object.prototype.hasOwnProperty.call(this.componentNodes, req.params.name)) { + return res.json(this.componentNodes[req.params.name]) + } else { + throw new Error(`Node ${req.params.name} not found`) + } + }) + + // Returns specific component node icon via name + this.app.get('/api/v1/node-icon/:name', (req: Request, res: Response) => { + if (Object.prototype.hasOwnProperty.call(this.componentNodes, req.params.name)) { + const nodeInstance = this.componentNodes[req.params.name] + if (nodeInstance.icon === undefined) { + throw new Error(`Node ${req.params.name} icon not found`) + } + + if (nodeInstance.icon.endsWith('.svg') || nodeInstance.icon.endsWith('.png') || nodeInstance.icon.endsWith('.jpg')) { + const filepath = nodeInstance.icon + res.sendFile(filepath) + } else { + throw new Error(`Node ${req.params.name} icon is missing icon`) + } + } else { + throw new Error(`Node ${req.params.name} not found`) + } + }) + + // ---------------------------------------- + // Chatflows + // ---------------------------------------- + + // Get all chatflows + this.app.get('/api/v1/chatflows', async (req: Request, res: Response) => { + const chatflows: IChatFlow[] = await this.AppDataSource.getRepository(ChatFlow).find() + return res.json(chatflows) + }) + + // Get specific chatflow via id + this.app.get('/api/v1/chatflows/:id', async (req: Request, res: Response) => { + const chatflow = await this.AppDataSource.getRepository(ChatFlow).findOneBy({ + id: req.params.id + }) + if (chatflow) return res.json(chatflow) + return res.status(404).send(`Chatflow ${req.params.id} not found`) + }) + + // Save chatflow + this.app.post('/api/v1/chatflows', async (req: Request, res: Response) => { + const body = req.body + const newChatFlow = new ChatFlow() + Object.assign(newChatFlow, body) + + const chatflow = this.AppDataSource.getRepository(ChatFlow).create(newChatFlow) + const results = await this.AppDataSource.getRepository(ChatFlow).save(chatflow) + + return res.json(results) + }) + + // Update chatflow + this.app.put('/api/v1/chatflows/:id', async (req: Request, res: Response) => { + const chatflow = await this.AppDataSource.getRepository(ChatFlow).findOneBy({ + id: req.params.id + }) + + if (!chatflow) { + res.status(404).send(`Chatflow ${req.params.id} not found`) + return + } + + const body = req.body + const updateChatFlow = new ChatFlow() + Object.assign(updateChatFlow, body) + + this.AppDataSource.getRepository(ChatFlow).merge(chatflow, updateChatFlow) + const result = await this.AppDataSource.getRepository(ChatFlow).save(chatflow) + + return res.json(result) + }) + + // Delete chatflow via id + this.app.delete('/api/v1/chatflows/:id', async (req: Request, res: Response) => { + const results = await this.AppDataSource.getRepository(ChatFlow).delete({ id: req.params.id }) + return res.json(results) + }) + + // ---------------------------------------- + // ChatMessage + // ---------------------------------------- + + // Get all chatmessages from chatflowid + this.app.get('/api/v1/chatmessage/:id', async (req: Request, res: Response) => { + const chatmessages = await this.AppDataSource.getRepository(ChatMessage).findBy({ + chatflowid: req.params.id + }) + return res.json(chatmessages) + }) + + // Add chatmessages for chatflowid + this.app.post('/api/v1/chatmessage/:id', async (req: Request, res: Response) => { + const body = req.body + const newChatMessage = new ChatMessage() + Object.assign(newChatMessage, body) + + const chatmessage = this.AppDataSource.getRepository(ChatMessage).create(newChatMessage) + const results = await this.AppDataSource.getRepository(ChatMessage).save(chatmessage) + + return res.json(results) + }) + + // Delete all chatmessages from chatflowid + this.app.delete('/api/v1/chatmessage/:id', async (req: Request, res: Response) => { + const results = await this.AppDataSource.getRepository(ChatMessage).delete({ chatflowid: req.params.id }) + return res.json(results) + }) + + // ---------------------------------------- + // Prediction + // ---------------------------------------- + + // Send input message and get prediction result + this.app.post('/api/v1/prediction/:id', async (req: Request, res: Response) => { + try { + const incomingInput: IncomingInput = req.body + + const chatflow = await this.AppDataSource.getRepository(ChatFlow).findOneBy({ + id: req.params.id + }) + if (!chatflow) return res.status(404).send(`Chatflow ${req.params.id} not found`) + + const flowData = chatflow.flowData + const parsedFlowData: IReactFlowObject = JSON.parse(flowData) + const { graph, nodeDependencies } = constructGraphs(parsedFlowData.nodes, parsedFlowData.edges) + + const startingNodeIds = getStartingNode(nodeDependencies) + const endingNodeId = getEndingNode(nodeDependencies, graph) + if (!endingNodeId) return res.status(500).send(`Ending node must be either Chain or Agent`) + + const reactFlowNodes = await buildLangchain(startingNodeIds, parsedFlowData.nodes, graph, this.componentNodes) + const nodeToExecute = reactFlowNodes.find((node: IReactFlowNode) => node.id === endingNodeId) + if (!nodeToExecute) return res.status(404).send(`Node ${endingNodeId} not found`) + + const nodeInstanceFilePath = this.componentNodes[nodeToExecute.data.name].filePath as string + const nodeModule = await import(nodeInstanceFilePath) + const nodeInstance = new nodeModule.nodeClass() + + const result = await nodeInstance.run(nodeToExecute.data, incomingInput.question) + + return res.json(result) + } catch (e: any) { + return res.status(500).send(e.message) + } + }) + + // ---------------------------------------- + // Serve UI static + // ---------------------------------------- + + const packagePath = getNodeModulesPackagePath('flowise-ui') + const uiBuildPath = path.join(packagePath, 'build') + const uiHtmlPath = path.join(packagePath, 'build', 'index.html') + + this.app.use('/', express.static(uiBuildPath)) + + // All other requests not handled will return React app + this.app.use((req, res) => { + res.sendFile(uiHtmlPath) + }) + } + + async stopApp() { + try { + const removePromises: any[] = [] + await Promise.all(removePromises) + } catch (e) { + console.error(`❌[server]: Flowise Server shut down error: ${e}`) + } + } +} + +let serverApp: App | undefined + +export async function start(): Promise { + serverApp = new App() + + const port = parseInt(process.env.PORT || '', 10) || 3000 + const server = http.createServer(serverApp.app) + + await serverApp.initDatabase() + await serverApp.config() + + server.listen(port, () => { + console.info(`⚑️[server]: Flowise Server is listening at ${port}`) + }) +} + +export function getInstance(): App | undefined { + return serverApp +} diff --git a/packages/server/src/utils/index.ts b/packages/server/src/utils/index.ts new file mode 100644 index 0000000..316218b --- /dev/null +++ b/packages/server/src/utils/index.ts @@ -0,0 +1,264 @@ +import path from 'path' +import fs from 'fs' +import { + IComponentNodesPool, + IExploredNode, + INodeDependencies, + INodeDirectedGraph, + INodeQueue, + IReactFlowEdge, + IReactFlowNode +} from '../Interface' +import { cloneDeep, get } from 'lodash' +import { ICommonObject, INodeData } from 'flowise-components' + +/** + * Returns the home folder path of the user if + * none can be found it falls back to the current + * working directory + * + */ +export const getUserHome = (): string => { + let variableName = 'HOME' + if (process.platform === 'win32') { + variableName = 'USERPROFILE' + } + + if (process.env[variableName] === undefined) { + // If for some reason the variable does not exist + // fall back to current folder + return process.cwd() + } + return process.env[variableName] as string +} + +/** + * Returns the path of node modules package + * @param {string} packageName + * @returns {string} + */ +export const getNodeModulesPackagePath = (packageName: string): string => { + const checkPaths = [ + path.join(__dirname, '..', 'node_modules', packageName), + path.join(__dirname, '..', '..', 'node_modules', packageName), + path.join(__dirname, '..', '..', '..', 'node_modules', packageName), + path.join(__dirname, '..', '..', '..', '..', 'node_modules', packageName), + path.join(__dirname, '..', '..', '..', '..', '..', 'node_modules', packageName) + ] + for (const checkPath of checkPaths) { + if (fs.existsSync(checkPath)) { + return checkPath + } + } + return '' +} + +/** + * Construct directed graph and node dependencies score + * @param {IReactFlowNode[]} reactFlowNodes + * @param {IReactFlowEdge[]} reactFlowEdges + */ +export const constructGraphs = (reactFlowNodes: IReactFlowNode[], reactFlowEdges: IReactFlowEdge[]) => { + const nodeDependencies = {} as INodeDependencies + const graph = {} as INodeDirectedGraph + + for (let i = 0; i < reactFlowNodes.length; i += 1) { + const nodeId = reactFlowNodes[i].id + nodeDependencies[nodeId] = 0 + graph[nodeId] = [] + } + + for (let i = 0; i < reactFlowEdges.length; i += 1) { + const source = reactFlowEdges[i].source + const target = reactFlowEdges[i].target + + if (Object.prototype.hasOwnProperty.call(graph, source)) { + graph[source].push(target) + } else { + graph[source] = [target] + } + nodeDependencies[target] += 1 + } + + return { graph, nodeDependencies } +} + +/** + * Get starting node and check if flow is valid + * @param {INodeDependencies} nodeDependencies + */ +export const getStartingNode = (nodeDependencies: INodeDependencies) => { + // Find starting node + const startingNodeIds = [] as string[] + Object.keys(nodeDependencies).forEach((nodeId) => { + if (nodeDependencies[nodeId] === 0) { + startingNodeIds.push(nodeId) + } + }) + return startingNodeIds +} + +export const getEndingNode = (nodeDependencies: INodeDependencies, graph: INodeDirectedGraph) => { + // Find starting node + let endingNodeId = '' + Object.keys(graph).forEach((nodeId) => { + if (!graph[nodeId].length && nodeDependencies[nodeId] > 0) { + endingNodeId = nodeId + } + }) + return endingNodeId +} + +/** + * Build langchain from start to end + * @param {string} startingNodeId + * @param {IReactFlowNode[]} reactFlowNodes + * @param {IReactFlowEdge[]} reactFlowEdges + * @param {INodeDirectedGraph} graph + * @param {IComponentNodesPool} componentNodes + * @param {string} clientId + * @param {any} io + */ +export const buildLangchain = async ( + startingNodeIds: string[], + reactFlowNodes: IReactFlowNode[], + graph: INodeDirectedGraph, + componentNodes: IComponentNodesPool +) => { + const flowNodes = cloneDeep(reactFlowNodes) + + // Create a Queue and add our initial node in it + const nodeQueue = [] as INodeQueue[] + const exploredNode = {} as IExploredNode + + // In the case of infinite loop, only max 3 loops will be executed + const maxLoop = 3 + + for (let i = 0; i < startingNodeIds.length; i += 1) { + nodeQueue.push({ nodeId: startingNodeIds[i], depth: 0 }) + exploredNode[startingNodeIds[i]] = { remainingLoop: maxLoop, lastSeenDepth: 0 } + } + + while (nodeQueue.length) { + const { nodeId, depth } = nodeQueue.shift() as INodeQueue + + const reactFlowNode = flowNodes.find((nd) => nd.id === nodeId) + const nodeIndex = flowNodes.findIndex((nd) => nd.id === nodeId) + if (!reactFlowNode || reactFlowNode === undefined || nodeIndex < 0) continue + + try { + const nodeInstanceFilePath = componentNodes[reactFlowNode.data.name].filePath as string + const nodeModule = await import(nodeInstanceFilePath) + const newNodeInstance = new nodeModule.nodeClass() + + const reactFlowNodeData: INodeData = resolveVariables(reactFlowNode.data, flowNodes) + + flowNodes[nodeIndex].data.instance = await newNodeInstance.init(reactFlowNodeData) + } catch (e: any) { + console.error(e) + throw new Error(e) + } + + const neighbourNodeIds = graph[nodeId] + const nextDepth = depth + 1 + + for (let i = 0; i < neighbourNodeIds.length; i += 1) { + const neighNodeId = neighbourNodeIds[i] + + // If nodeId has been seen, cycle detected + if (Object.prototype.hasOwnProperty.call(exploredNode, neighNodeId)) { + const { remainingLoop, lastSeenDepth } = exploredNode[neighNodeId] + + if (lastSeenDepth === nextDepth) continue + + if (remainingLoop === 0) { + break + } + const remainingLoopMinusOne = remainingLoop - 1 + exploredNode[neighNodeId] = { remainingLoop: remainingLoopMinusOne, lastSeenDepth: nextDepth } + nodeQueue.push({ nodeId: neighNodeId, depth: nextDepth }) + } else { + exploredNode[neighNodeId] = { remainingLoop: maxLoop, lastSeenDepth: nextDepth } + nodeQueue.push({ nodeId: neighNodeId, depth: nextDepth }) + } + } + } + return flowNodes +} + +/** + * Get variable value from outputResponses.output + * @param {string} paramValue + * @param {IReactFlowNode[]} reactFlowNodes + * @param {string} key + * @param {number} loopIndex + * @returns {string} + */ +export const getVariableValue = (paramValue: string, reactFlowNodes: IReactFlowNode[]) => { + let returnVal = paramValue + const variableStack = [] + let startIdx = 0 + const endIdx = returnVal.length - 1 + + while (startIdx < endIdx) { + const substr = returnVal.substring(startIdx, startIdx + 2) + + // Store the opening double curly bracket + if (substr === '{{') { + variableStack.push({ substr, startIdx: startIdx + 2 }) + } + + // Found the complete variable + if (substr === '}}' && variableStack.length > 0 && variableStack[variableStack.length - 1].substr === '{{') { + const variableStartIdx = variableStack[variableStack.length - 1].startIdx + const variableEndIdx = startIdx + const variableFullPath = returnVal.substring(variableStartIdx, variableEndIdx) + + // Split by first occurence of '.' to get just nodeId + const [variableNodeId, _] = variableFullPath.split('.') + const executedNode = reactFlowNodes.find((nd) => nd.id === variableNodeId) + if (executedNode) { + const variableInstance = get(executedNode.data, 'instance') + returnVal = variableInstance + } + variableStack.pop() + } + startIdx += 1 + } + return returnVal +} + +/** + * Loop through each inputs and resolve variable if neccessary + * @param {INodeData} reactFlowNodeData + * @param {IReactFlowNode[]} reactFlowNodes + * @returns {INodeData} + */ +export const resolveVariables = (reactFlowNodeData: INodeData, reactFlowNodes: IReactFlowNode[]): INodeData => { + const flowNodeData = cloneDeep(reactFlowNodeData) + const types = 'inputs' + + const getParamValues = (paramsObj: ICommonObject) => { + for (const key in paramsObj) { + const paramValue: string = paramsObj[key] + + if (Array.isArray(paramValue)) { + const resolvedInstances = [] + for (const param of paramValue) { + const resolvedInstance = getVariableValue(param, reactFlowNodes) + resolvedInstances.push(resolvedInstance) + } + paramsObj[key] = resolvedInstances + } else { + const resolvedInstance = getVariableValue(paramValue, reactFlowNodes) + paramsObj[key] = resolvedInstance + } + } + } + + const paramsObj = (flowNodeData as any)[types] + + getParamValues(paramsObj) + + return flowNodeData +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..693ee1b --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "lib": ["es2017"], + "target": "es2017" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + "experimentalDecorators": true /* Enable experimental support for TC39 stage 2 draft decorators. */, + "emitDecoratorMetadata": true /* Emit design-type metadata for decorated declarations in source files. */, + "module": "commonjs" /* Specify what module code is generated. */, + "outDir": "dist", + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + "strict": true /* Enable all strict type-checking options. */, + "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "sourceMap": true, + "strictPropertyInitialization": false, + "declaration": true + }, + "include": ["src"] +} diff --git a/packages/ui/.npmignore b/packages/ui/.npmignore new file mode 100644 index 0000000..2511886 --- /dev/null +++ b/packages/ui/.npmignore @@ -0,0 +1,13 @@ +/tests +/src +/public +!build + +yarn-debug.log* +yarn-error.log* + +.eslintrc +.prettierignore +.prettierrc +jsconfig.json + diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 0000000..fff7f9e --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,17 @@ + + +# Flowise UI + +React frontend ui for Flowise. + +![Flowise](https://github.com/FlowiseAI/Flowise/blob/main/images/flowise.gif?raw=true) + +Install: + +```bash +npm i flowise-ui +``` + +## License + +Source code in this repository is made available under the [MIT License](https://github.com/FlowiseAI/Flowise/blob/master/LICENSE.md). diff --git a/packages/ui/jsconfig.json b/packages/ui/jsconfig.json new file mode 100644 index 0000000..d2071c6 --- /dev/null +++ b/packages/ui/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "commonjs", + "baseUrl": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..b0a9cd0 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,76 @@ +{ + "name": "flowise-ui", + "version": "1.0.1", + "license": "SEE LICENSE IN LICENSE.md", + "homepage": "https://flowiseai.com", + "author": { + "name": "HenryHeng", + "email": "henryheng@flowiseai.com" + }, + "dependencies": { + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.10.6", + "@emotion/styled": "^11.10.6", + "@mui/icons-material": "^5.0.3", + "@mui/material": "^5.11.12", + "@tabler/icons": "^1.39.1", + "clsx": "^1.1.1", + "formik": "^2.2.6", + "framer-motion": "^4.1.13", + "history": "^5.0.0", + "html-react-parser": "^3.0.4", + "lodash": "^4.17.21", + "moment": "^2.29.3", + "notistack": "^2.0.4", + "prismjs": "^1.28.0", + "prop-types": "^15.7.2", + "react": "^18.2.0", + "react-datepicker": "^4.8.0", + "react-device-detect": "^1.17.0", + "react-dom": "^18.2.0", + "react-json-view": "^1.21.3", + "react-markdown": "^8.0.6", + "react-perfect-scrollbar": "^1.5.8", + "react-redux": "^8.0.5", + "react-router": "~6.3.0", + "react-router-dom": "~6.3.0", + "react-simple-code-editor": "^0.11.2", + "reactflow": "^11.5.6", + "redux": "^4.0.5", + "yup": "^0.32.9" + }, + "scripts": { + "start": "react-scripts start", + "dev": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "babel": { + "presets": [ + "@babel/preset-react" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@babel/eslint-parser": "^7.15.8", + "@testing-library/jest-dom": "^5.11.10", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^12.8.3", + "pretty-quick": "^3.1.3", + "react-scripts": "^5.0.1", + "sass": "^1.42.1", + "typescript": "^4.8.4" + } +} diff --git a/packages/ui/public/favicon-16x16.png b/packages/ui/public/favicon-16x16.png new file mode 100644 index 0000000..c056f6a Binary files /dev/null and b/packages/ui/public/favicon-16x16.png differ diff --git a/packages/ui/public/favicon-32x32.png b/packages/ui/public/favicon-32x32.png new file mode 100644 index 0000000..857abed Binary files /dev/null and b/packages/ui/public/favicon-32x32.png differ diff --git a/packages/ui/public/favicon.ico b/packages/ui/public/favicon.ico new file mode 100644 index 0000000..ee99d26 Binary files /dev/null and b/packages/ui/public/favicon.ico differ diff --git a/packages/ui/public/index.html b/packages/ui/public/index.html new file mode 100644 index 0000000..270cc80 --- /dev/null +++ b/packages/ui/public/index.html @@ -0,0 +1,62 @@ + + + + Flowise - LangchainJS UI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + diff --git a/packages/ui/src/App.js b/packages/ui/src/App.js new file mode 100644 index 0000000..37b914b --- /dev/null +++ b/packages/ui/src/App.js @@ -0,0 +1,32 @@ +import { useSelector } from 'react-redux' + +import { ThemeProvider } from '@mui/material/styles' +import { CssBaseline, StyledEngineProvider } from '@mui/material' + +// routing +import Routes from 'routes' + +// defaultTheme +import themes from 'themes' + +// project imports +import NavigationScroll from 'layout/NavigationScroll' + +// ==============================|| APP ||============================== // + +const App = () => { + const customization = useSelector((state) => state.customization) + + return ( + + + + + + + + + ) +} + +export default App diff --git a/packages/ui/src/api/chatflows.js b/packages/ui/src/api/chatflows.js new file mode 100644 index 0000000..eae010e --- /dev/null +++ b/packages/ui/src/api/chatflows.js @@ -0,0 +1,19 @@ +import client from './client' + +const getAllChatflows = () => client.get('/chatflows') + +const getSpecificChatflow = (id) => client.get(`/chatflows/${id}`) + +const createNewChatflow = (body) => client.post(`/chatflows`, body) + +const updateChatflow = (id, body) => client.put(`/chatflows/${id}`, body) + +const deleteChatflow = (id) => client.delete(`/chatflows/${id}`) + +export default { + getAllChatflows, + getSpecificChatflow, + createNewChatflow, + updateChatflow, + deleteChatflow +} diff --git a/packages/ui/src/api/chatmessage.js b/packages/ui/src/api/chatmessage.js new file mode 100644 index 0000000..d93068e --- /dev/null +++ b/packages/ui/src/api/chatmessage.js @@ -0,0 +1,13 @@ +import client from './client' + +const getChatmessageFromChatflow = (id) => client.get(`/chatmessage/${id}`) + +const createNewChatmessage = (id, body) => client.post(`/chatmessage/${id}`, body) + +const deleteChatmessage = (id) => client.delete(`/chatmessage/${id}`) + +export default { + getChatmessageFromChatflow, + createNewChatmessage, + deleteChatmessage +} diff --git a/packages/ui/src/api/client.js b/packages/ui/src/api/client.js new file mode 100644 index 0000000..1211d67 --- /dev/null +++ b/packages/ui/src/api/client.js @@ -0,0 +1,11 @@ +import axios from 'axios' +import { baseURL } from 'store/constant' + +const apiClient = axios.create({ + baseURL: `${baseURL}/api/v1`, + headers: { + 'Content-type': 'application/json' + } +}) + +export default apiClient diff --git a/packages/ui/src/api/nodes.js b/packages/ui/src/api/nodes.js new file mode 100644 index 0000000..7eb4c35 --- /dev/null +++ b/packages/ui/src/api/nodes.js @@ -0,0 +1,10 @@ +import client from './client' + +const getAllNodes = () => client.get('/nodes') + +const getSpecificNode = (name) => client.get(`/nodes/${name}`) + +export default { + getAllNodes, + getSpecificNode +} diff --git a/packages/ui/src/api/prediction.js b/packages/ui/src/api/prediction.js new file mode 100644 index 0000000..6c1e208 --- /dev/null +++ b/packages/ui/src/api/prediction.js @@ -0,0 +1,7 @@ +import client from './client' + +const sendMessageAndGetPrediction = (id, input) => client.post(`/prediction/${id}`, input) + +export default { + sendMessageAndGetPrediction +} diff --git a/packages/ui/src/assets/images/api_empty.svg b/packages/ui/src/assets/images/api_empty.svg new file mode 100644 index 0000000..45aac72 --- /dev/null +++ b/packages/ui/src/assets/images/api_empty.svg @@ -0,0 +1 @@ +two_factor_authentication \ No newline at end of file diff --git a/packages/ui/src/assets/images/flowise_logo.png b/packages/ui/src/assets/images/flowise_logo.png new file mode 100644 index 0000000..b0a07e5 Binary files /dev/null and b/packages/ui/src/assets/images/flowise_logo.png differ diff --git a/packages/ui/src/assets/images/flowise_logo_dark.png b/packages/ui/src/assets/images/flowise_logo_dark.png new file mode 100644 index 0000000..e53cfc0 Binary files /dev/null and b/packages/ui/src/assets/images/flowise_logo_dark.png differ diff --git a/packages/ui/src/assets/images/google-login-white.png b/packages/ui/src/assets/images/google-login-white.png new file mode 100644 index 0000000..aabc474 Binary files /dev/null and b/packages/ui/src/assets/images/google-login-white.png differ diff --git a/packages/ui/src/assets/images/workflow_empty.svg b/packages/ui/src/assets/images/workflow_empty.svg new file mode 100644 index 0000000..01677fd --- /dev/null +++ b/packages/ui/src/assets/images/workflow_empty.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/scss/_themes-vars.module.scss b/packages/ui/src/assets/scss/_themes-vars.module.scss new file mode 100644 index 0000000..374c36c --- /dev/null +++ b/packages/ui/src/assets/scss/_themes-vars.module.scss @@ -0,0 +1,157 @@ +// paper & background +$paper: #ffffff; + +// primary +$primaryLight: #e3f2fd; +$primaryMain: #2196f3; +$primaryDark: #1e88e5; +$primary200: #90caf9; +$primary800: #1565c0; + +// secondary +$secondaryLight: #ede7f6; +$secondaryMain: #673ab7; +$secondaryDark: #5e35b1; +$secondary200: #b39ddb; +$secondary800: #4527a0; + +// success Colors +$successLight: #cdf5d8; +$success200: #69f0ae; +$successMain: #00e676; +$successDark: #00c853; + +// error +$errorLight: #f3d2d2; +$errorMain: #f44336; +$errorDark: #c62828; + +// orange +$orangeLight: #fbe9e7; +$orangeMain: #ffab91; +$orangeDark: #d84315; + +// warning +$warningLight: #fff8e1; +$warningMain: #ffe57f; +$warningDark: #ffc107; + +// grey +$grey50: #fafafa; +$grey100: #f5f5f5; +$grey200: #eeeeee; +$grey300: #e0e0e0; +$grey500: #9e9e9e; +$grey600: #757575; +$grey700: #616161; +$grey900: #212121; + +// ==============================|| DARK THEME VARIANTS ||============================== // + +// paper & background +$darkBackground: #191b1f; +$darkPaper: #191b1f; + +// dark 800 & 900 +$darkLevel1: #252525; // level 1 +$darkLevel2: #242424; // level 2 + +// primary dark +$darkPrimaryLight: #23262c; +$darkPrimaryMain: #23262c; +$darkPrimaryDark: #191b1f; +$darkPrimary200: #c9d4e9; +$darkPrimary800: #32353b; + +// secondary dark +$darkSecondaryLight: #454c59; +$darkSecondaryMain: #7c4dff; +$darkSecondaryDark: #ffffff; +$darkSecondary200: #32353b; +$darkSecondary800: #6200ea; + +// text variants +$darkTextTitle: #d7dcec; +$darkTextPrimary: #bdc8f0; +$darkTextSecondary: #8492c4; + +// ==============================|| JAVASCRIPT ||============================== // + +:export { + // paper & background + paper: $paper; + + // primary + primaryLight: $primaryLight; + primary200: $primary200; + primaryMain: $primaryMain; + primaryDark: $primaryDark; + primary800: $primary800; + + // secondary + secondaryLight: $secondaryLight; + secondary200: $secondary200; + secondaryMain: $secondaryMain; + secondaryDark: $secondaryDark; + secondary800: $secondary800; + + // success + successLight: $successLight; + success200: $success200; + successMain: $successMain; + successDark: $successDark; + + // error + errorLight: $errorLight; + errorMain: $errorMain; + errorDark: $errorDark; + + // orange + orangeLight: $orangeLight; + orangeMain: $orangeMain; + orangeDark: $orangeDark; + + // warning + warningLight: $warningLight; + warningMain: $warningMain; + warningDark: $warningDark; + + // grey + grey50: $grey50; + grey100: $grey100; + grey200: $grey200; + grey300: $grey300; + grey500: $grey500; + grey600: $grey600; + grey700: $grey700; + grey900: $grey900; + + // ==============================|| DARK THEME VARIANTS ||============================== // + + // paper & background + darkPaper: $darkPaper; + darkBackground: $darkBackground; + + // dark 800 & 900 + darkLevel1: $darkLevel1; + darkLevel2: $darkLevel2; + + // text variants + darkTextTitle: $darkTextTitle; + darkTextPrimary: $darkTextPrimary; + darkTextSecondary: $darkTextSecondary; + + // primary dark + darkPrimaryLight: $darkPrimaryLight; + darkPrimaryMain: $darkPrimaryMain; + darkPrimaryDark: $darkPrimaryDark; + darkPrimary200: $darkPrimary200; + darkPrimary800: $darkPrimary800; + + // secondary dark + darkSecondaryLight: $darkSecondaryLight; + darkSecondaryMain: $darkSecondaryMain; + darkSecondaryDark: $darkSecondaryDark; + darkSecondary200: $darkSecondary200; + darkSecondary800: $darkSecondary800; +} diff --git a/packages/ui/src/assets/scss/style.scss b/packages/ui/src/assets/scss/style.scss new file mode 100644 index 0000000..a3a184d --- /dev/null +++ b/packages/ui/src/assets/scss/style.scss @@ -0,0 +1,122 @@ +// color variants +@import 'themes-vars.module.scss'; + +// third-party +@import '~react-perfect-scrollbar/dist/css/styles.css'; + +// ==============================|| LIGHT BOX ||============================== // +.fullscreen .react-images__blanket { + z-index: 1200; +} + +// ==============================|| PERFECT SCROLLBAR ||============================== // + +.scrollbar-container { + .ps__rail-y { + &:hover > .ps__thumb-y, + &:focus > .ps__thumb-y, + &.ps--clicking .ps__thumb-y { + background-color: $grey500; + width: 5px; + } + } + .ps__thumb-y { + background-color: $grey500; + border-radius: 6px; + width: 5px; + right: 0; + } +} + +.scrollbar-container.ps, +.scrollbar-container > .ps { + &.ps--active-y > .ps__rail-y { + width: 5px; + background-color: transparent !important; + z-index: 999; + &:hover, + &.ps--clicking { + width: 5px; + background-color: transparent; + } + } + &.ps--scrolling-y > .ps__rail-y, + &.ps--scrolling-x > .ps__rail-x { + opacity: 0.4; + background-color: transparent; + } +} + +// ==============================|| ANIMATION KEYFRAMES ||============================== // + +@keyframes wings { + 50% { + transform: translateY(-40px); + } + 100% { + transform: translateY(0px); + } +} + +@keyframes blink { + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes bounce { + 0%, + 20%, + 53%, + to { + animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); + transform: translateZ(0); + } + 40%, + 43% { + animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06); + transform: translate3d(0, -5px, 0); + } + 70% { + animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06); + transform: translate3d(0, -7px, 0); + } + 80% { + transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); + transform: translateZ(0); + } + 90% { + transform: translate3d(0, -2px, 0); + } +} + +@keyframes slideY { + 0%, + 50%, + 100% { + transform: translateY(0px); + } + 25% { + transform: translateY(-10px); + } + 75% { + transform: translateY(10px); + } +} + +@keyframes slideX { + 0%, + 50%, + 100% { + transform: translateX(0px); + } + 25% { + transform: translateX(-10px); + } + 75% { + transform: translateX(10px); + } +} diff --git a/packages/ui/src/config.js b/packages/ui/src/config.js new file mode 100644 index 0000000..34e5f15 --- /dev/null +++ b/packages/ui/src/config.js @@ -0,0 +1,9 @@ +const config = { + // basename: only at build time to set, and Don't add '/' at end off BASENAME for breadcrumbs, also Don't put only '/' use blank('') instead, + basename: '', + defaultPath: '/chatflows', + fontFamily: `'Roboto', sans-serif`, + borderRadius: 12 +} + +export default config diff --git a/packages/ui/src/hooks/useApi.js b/packages/ui/src/hooks/useApi.js new file mode 100644 index 0000000..932f0a6 --- /dev/null +++ b/packages/ui/src/hooks/useApi.js @@ -0,0 +1,26 @@ +import { useState } from 'react' + +export default (apiFunc) => { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const request = async (...args) => { + setLoading(true) + try { + const result = await apiFunc(...args) + setData(result.data) + } catch (err) { + setError(err || 'Unexpected Error!') + } finally { + setLoading(false) + } + } + + return { + data, + error, + loading, + request + } +} diff --git a/packages/ui/src/hooks/useConfirm.js b/packages/ui/src/hooks/useConfirm.js new file mode 100644 index 0000000..8e00d21 --- /dev/null +++ b/packages/ui/src/hooks/useConfirm.js @@ -0,0 +1,37 @@ +import { useContext } from 'react' +import ConfirmContext from 'store/context/ConfirmContext' +import { HIDE_CONFIRM, SHOW_CONFIRM } from 'store/actions' + +let resolveCallback +const useConfirm = () => { + const [confirmState, dispatch] = useContext(ConfirmContext) + + const closeConfirm = () => { + dispatch({ + type: HIDE_CONFIRM + }) + } + + const onConfirm = () => { + closeConfirm() + resolveCallback(true) + } + + const onCancel = () => { + closeConfirm() + resolveCallback(false) + } + const confirm = (confirmPayload) => { + dispatch({ + type: SHOW_CONFIRM, + payload: confirmPayload + }) + return new Promise((res) => { + resolveCallback = res + }) + } + + return { confirm, onConfirm, onCancel, confirmState } +} + +export default useConfirm diff --git a/packages/ui/src/hooks/useScriptRef.js b/packages/ui/src/hooks/useScriptRef.js new file mode 100644 index 0000000..451c2c0 --- /dev/null +++ b/packages/ui/src/hooks/useScriptRef.js @@ -0,0 +1,18 @@ +import { useEffect, useRef } from 'react' + +// ==============================|| ELEMENT REFERENCE HOOKS ||============================== // + +const useScriptRef = () => { + const scripted = useRef(true) + + useEffect( + () => () => { + scripted.current = false + }, + [] + ) + + return scripted +} + +export default useScriptRef diff --git a/packages/ui/src/index.js b/packages/ui/src/index.js new file mode 100644 index 0000000..1269f06 --- /dev/null +++ b/packages/ui/src/index.js @@ -0,0 +1,33 @@ +import React from 'react' +import App from './App' +import { store } from 'store' +import { createRoot } from 'react-dom/client' + +// style + assets +import 'assets/scss/style.scss' + +// third party +import { BrowserRouter } from 'react-router-dom' +import { Provider } from 'react-redux' +import { SnackbarProvider } from 'notistack' +import ConfirmContextProvider from 'store/context/ConfirmContextProvider' +import { ReactFlowContext } from 'store/context/ReactFlowContext' + +const container = document.getElementById('root') +const root = createRoot(container) + +root.render( + + + + + + + + + + + + + +) diff --git a/packages/ui/src/layout/MainLayout/Header/index.js b/packages/ui/src/layout/MainLayout/Header/index.js new file mode 100644 index 0000000..30b2670 --- /dev/null +++ b/packages/ui/src/layout/MainLayout/Header/index.js @@ -0,0 +1,127 @@ +import PropTypes from 'prop-types' +import { useSelector, useDispatch } from 'react-redux' +import { useState } from 'react' + +// material-ui +import { useTheme } from '@mui/material/styles' +import { Avatar, Box, ButtonBase, Switch } from '@mui/material' +import { styled } from '@mui/material/styles' + +// project imports +import LogoSection from '../LogoSection' + +// assets +import { IconMenu2 } from '@tabler/icons' + +// store +import { SET_DARKMODE } from 'store/actions' + +// ==============================|| MAIN NAVBAR / HEADER ||============================== // + +const MaterialUISwitch = styled(Switch)(({ theme }) => ({ + width: 62, + height: 34, + padding: 7, + '& .MuiSwitch-switchBase': { + margin: 1, + padding: 0, + transform: 'translateX(6px)', + '&.Mui-checked': { + color: '#fff', + transform: 'translateX(22px)', + '& .MuiSwitch-thumb:before': { + backgroundImage: `url('data:image/svg+xml;utf8,')` + }, + '& + .MuiSwitch-track': { + opacity: 1, + backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be' + } + } + }, + '& .MuiSwitch-thumb': { + backgroundColor: theme.palette.mode === 'dark' ? '#003892' : '#001e3c', + width: 32, + height: 32, + '&:before': { + content: "''", + position: 'absolute', + width: '100%', + height: '100%', + left: 0, + top: 0, + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center', + backgroundImage: `url('data:image/svg+xml;utf8,')` + } + }, + '& .MuiSwitch-track': { + opacity: 1, + backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be', + borderRadius: 20 / 2 + } +})) + +const Header = ({ handleLeftDrawerToggle }) => { + const theme = useTheme() + const customization = useSelector((state) => state.customization) + + const [isDark, setIsDark] = useState(customization.isDarkMode) + const dispatch = useDispatch() + + const changeDarkMode = () => { + dispatch({ type: SET_DARKMODE, isDarkMode: !isDark }) + setIsDark((isDark) => !isDark) + localStorage.setItem('isDarkMode', !isDark) + } + + return ( + <> + {/* logo & toggler button */} + + + + + + + + + + + + + + ) +} + +Header.propTypes = { + handleLeftDrawerToggle: PropTypes.func +} + +export default Header diff --git a/packages/ui/src/layout/MainLayout/LogoSection/index.js b/packages/ui/src/layout/MainLayout/LogoSection/index.js new file mode 100644 index 0000000..cf43639 --- /dev/null +++ b/packages/ui/src/layout/MainLayout/LogoSection/index.js @@ -0,0 +1,18 @@ +import { Link } from 'react-router-dom' + +// material-ui +import { ButtonBase } from '@mui/material' + +// project imports +import config from 'config' +import Logo from 'ui-component/extended/Logo' + +// ==============================|| MAIN LOGO ||============================== // + +const LogoSection = () => ( + + + +) + +export default LogoSection diff --git a/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavCollapse/index.js b/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavCollapse/index.js new file mode 100644 index 0000000..4fb11ed --- /dev/null +++ b/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavCollapse/index.js @@ -0,0 +1,124 @@ +import PropTypes from 'prop-types' +import { useState } from 'react' +import { useSelector } from 'react-redux' + +// material-ui +import { useTheme } from '@mui/material/styles' +import { Collapse, List, ListItemButton, ListItemIcon, ListItemText, Typography } from '@mui/material' + +// project imports +import NavItem from '../NavItem' + +// assets +import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord' +import { IconChevronDown, IconChevronUp } from '@tabler/icons' + +// ==============================|| SIDEBAR MENU LIST COLLAPSE ITEMS ||============================== // + +const NavCollapse = ({ menu, level }) => { + const theme = useTheme() + const customization = useSelector((state) => state.customization) + + const [open, setOpen] = useState(false) + const [selected, setSelected] = useState(null) + + const handleClick = () => { + setOpen(!open) + setSelected(!selected ? menu.id : null) + } + + // menu collapse & item + const menus = menu.children?.map((item) => { + switch (item.type) { + case 'collapse': + return + case 'item': + return + default: + return ( + + Menu Items Error + + ) + } + }) + + const Icon = menu.icon + const menuIcon = menu.icon ? ( + + ) : ( + 0 ? 'inherit' : 'medium'} + /> + ) + + return ( + <> + 1 ? 'transparent !important' : 'inherit', + py: level > 1 ? 1 : 1.25, + pl: `${level * 24}px` + }} + selected={selected === menu.id} + onClick={handleClick} + > + {menuIcon} + + {menu.title} + + } + secondary={ + menu.caption && ( + + {menu.caption} + + ) + } + /> + {open ? ( + + ) : ( + + )} + + + + {menus} + + + + ) +} + +NavCollapse.propTypes = { + menu: PropTypes.object, + level: PropTypes.number +} + +export default NavCollapse diff --git a/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavGroup/index.js b/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavGroup/index.js new file mode 100644 index 0000000..1f33210 --- /dev/null +++ b/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavGroup/index.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types' + +// material-ui +import { useTheme } from '@mui/material/styles' +import { Divider, List, Typography } from '@mui/material' + +// project imports +import NavItem from '../NavItem' +import NavCollapse from '../NavCollapse' + +// ==============================|| SIDEBAR MENU LIST GROUP ||============================== // + +const NavGroup = ({ item }) => { + const theme = useTheme() + + // menu list collapse & items + const items = item.children?.map((menu) => { + switch (menu.type) { + case 'collapse': + return + case 'item': + return + default: + return ( + + Menu Items Error + + ) + } + }) + + return ( + <> + + {item.title} + {item.caption && ( + + {item.caption} + + )} + + ) + } + > + {items} + + + {/* group divider */} + + + ) +} + +NavGroup.propTypes = { + item: PropTypes.object +} + +export default NavGroup diff --git a/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavItem/index.js b/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavItem/index.js new file mode 100644 index 0000000..ebf2536 --- /dev/null +++ b/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavItem/index.js @@ -0,0 +1,150 @@ +import PropTypes from 'prop-types' +import { forwardRef, useEffect } from 'react' +import { Link } from 'react-router-dom' +import { useDispatch, useSelector } from 'react-redux' + +// material-ui +import { useTheme } from '@mui/material/styles' +import { Avatar, Chip, ListItemButton, ListItemIcon, ListItemText, Typography, useMediaQuery } from '@mui/material' + +// project imports +import { MENU_OPEN, SET_MENU } from 'store/actions' +import config from 'config' + +// assets +import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord' + +// ==============================|| SIDEBAR MENU LIST ITEMS ||============================== // + +const NavItem = ({ item, level, navType, onClick, onUploadFile }) => { + const theme = useTheme() + const dispatch = useDispatch() + const customization = useSelector((state) => state.customization) + const matchesSM = useMediaQuery(theme.breakpoints.down('lg')) + + const Icon = item.icon + const itemIcon = item?.icon ? ( + + ) : ( + id === item?.id) > -1 ? 8 : 6, + height: customization.isOpen.findIndex((id) => id === item?.id) > -1 ? 8 : 6 + }} + fontSize={level > 0 ? 'inherit' : 'medium'} + /> + ) + + let itemTarget = '_self' + if (item.target) { + itemTarget = '_blank' + } + + let listItemProps = { + component: forwardRef(function ListItemPropsComponent(props, ref) { + return + }) + } + if (item?.external) { + listItemProps = { component: 'a', href: item.url, target: itemTarget } + } + if (item?.id === 'loadChatflow') { + listItemProps.component = 'label' + } + + const handleFileUpload = (e) => { + if (!e.target.files) return + + const file = e.target.files[0] + + const reader = new FileReader() + reader.onload = (evt) => { + if (!evt?.target?.result) { + return + } + const { result } = evt.target + onUploadFile(result) + } + reader.readAsText(file) + } + + const itemHandler = (id) => { + if (navType === 'SETTINGS' && id !== 'loadChatflow') { + onClick(id) + } else { + dispatch({ type: MENU_OPEN, id }) + if (matchesSM) dispatch({ type: SET_MENU, opened: false }) + } + } + + // active menu item on page load + useEffect(() => { + if (navType === 'MENU') { + const currentIndex = document.location.pathname + .toString() + .split('/') + .findIndex((id) => id === item.id) + if (currentIndex > -1) { + dispatch({ type: MENU_OPEN, id: item.id }) + } + if (!document.location.pathname.toString().split('/')[1]) { + itemHandler('chatflows') + } + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [navType]) + + return ( + 1 ? 'transparent !important' : 'inherit', + py: level > 1 ? 1 : 1.25, + pl: `${level * 24}px` + }} + selected={customization.isOpen.findIndex((id) => id === item.id) > -1} + onClick={() => itemHandler(item.id)} + > + {item.id === 'loadChatflow' && handleFileUpload(e)} />} + {itemIcon} + id === item.id) > -1 ? 'h5' : 'body1'} color='inherit'> + {item.title} + + } + secondary={ + item.caption && ( + + {item.caption} + + ) + } + /> + {item.chip && ( + {item.chip.avatar}} + /> + )} + + ) +} + +NavItem.propTypes = { + item: PropTypes.object, + level: PropTypes.number, + navType: PropTypes.string, + onClick: PropTypes.func, + onUploadFile: PropTypes.func +} + +export default NavItem diff --git a/packages/ui/src/layout/MainLayout/Sidebar/MenuList/index.js b/packages/ui/src/layout/MainLayout/Sidebar/MenuList/index.js new file mode 100644 index 0000000..dbcf3de --- /dev/null +++ b/packages/ui/src/layout/MainLayout/Sidebar/MenuList/index.js @@ -0,0 +1,27 @@ +// material-ui +import { Typography } from '@mui/material' + +// project imports +import NavGroup from './NavGroup' +import menuItem from 'menu-items' + +// ==============================|| SIDEBAR MENU LIST ||============================== // + +const MenuList = () => { + const navItems = menuItem.items.map((item) => { + switch (item.type) { + case 'group': + return + default: + return ( + + Menu Items Error + + ) + } + }) + + return <>{navItems} +} + +export default MenuList diff --git a/packages/ui/src/layout/MainLayout/Sidebar/index.js b/packages/ui/src/layout/MainLayout/Sidebar/index.js new file mode 100644 index 0000000..81c0100 --- /dev/null +++ b/packages/ui/src/layout/MainLayout/Sidebar/index.js @@ -0,0 +1,85 @@ +import PropTypes from 'prop-types' + +// material-ui +import { useTheme } from '@mui/material/styles' +import { Box, Drawer, useMediaQuery } from '@mui/material' + +// third-party +import PerfectScrollbar from 'react-perfect-scrollbar' +import { BrowserView, MobileView } from 'react-device-detect' + +// project imports +import MenuList from './MenuList' +import LogoSection from '../LogoSection' +import { drawerWidth } from 'store/constant' + +// ==============================|| SIDEBAR DRAWER ||============================== // + +const Sidebar = ({ drawerOpen, drawerToggle, window }) => { + const theme = useTheme() + const matchUpMd = useMediaQuery(theme.breakpoints.up('md')) + + const drawer = ( + <> + + + + + + + + + + + + + + + + + ) + + const container = window !== undefined ? () => window.document.body : undefined + + return ( + + + {drawer} + + + ) +} + +Sidebar.propTypes = { + drawerOpen: PropTypes.bool, + drawerToggle: PropTypes.func, + window: PropTypes.object +} + +export default Sidebar diff --git a/packages/ui/src/layout/MainLayout/index.js b/packages/ui/src/layout/MainLayout/index.js new file mode 100644 index 0000000..8c87447 --- /dev/null +++ b/packages/ui/src/layout/MainLayout/index.js @@ -0,0 +1,107 @@ +import { useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { Outlet } from 'react-router-dom' + +// material-ui +import { styled, useTheme } from '@mui/material/styles' +import { AppBar, Box, CssBaseline, Toolbar, useMediaQuery } from '@mui/material' + +// project imports +import Header from './Header' +import Sidebar from './Sidebar' +import { drawerWidth } from 'store/constant' +import { SET_MENU } from 'store/actions' + +// styles +const Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' })(({ theme, open }) => ({ + ...theme.typography.mainContent, + ...(!open && { + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + transition: theme.transitions.create('margin', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen + }), + [theme.breakpoints.up('md')]: { + marginLeft: -(drawerWidth - 20), + width: `calc(100% - ${drawerWidth}px)` + }, + [theme.breakpoints.down('md')]: { + marginLeft: '20px', + width: `calc(100% - ${drawerWidth}px)`, + padding: '16px' + }, + [theme.breakpoints.down('sm')]: { + marginLeft: '10px', + width: `calc(100% - ${drawerWidth}px)`, + padding: '16px', + marginRight: '10px' + } + }), + ...(open && { + transition: theme.transitions.create('margin', { + easing: theme.transitions.easing.easeOut, + duration: theme.transitions.duration.enteringScreen + }), + marginLeft: 0, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + width: `calc(100% - ${drawerWidth}px)`, + [theme.breakpoints.down('md')]: { + marginLeft: '20px' + }, + [theme.breakpoints.down('sm')]: { + marginLeft: '10px' + } + }) +})) + +// ==============================|| MAIN LAYOUT ||============================== // + +const MainLayout = () => { + const theme = useTheme() + const matchDownMd = useMediaQuery(theme.breakpoints.down('lg')) + + // Handle left drawer + const leftDrawerOpened = useSelector((state) => state.customization.opened) + const dispatch = useDispatch() + const handleLeftDrawerToggle = () => { + dispatch({ type: SET_MENU, opened: !leftDrawerOpened }) + } + + useEffect(() => { + dispatch({ type: SET_MENU, opened: !matchDownMd }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [matchDownMd]) + + return ( + + + {/* header */} + + +
+ + + + {/* drawer */} + + + {/* main content */} +
+ +
+ + ) +} + +export default MainLayout diff --git a/packages/ui/src/layout/MinimalLayout/index.js b/packages/ui/src/layout/MinimalLayout/index.js new file mode 100644 index 0000000..0982616 --- /dev/null +++ b/packages/ui/src/layout/MinimalLayout/index.js @@ -0,0 +1,11 @@ +import { Outlet } from 'react-router-dom' + +// ==============================|| MINIMAL LAYOUT ||============================== // + +const MinimalLayout = () => ( + <> + + +) + +export default MinimalLayout diff --git a/packages/ui/src/layout/NavMotion.js b/packages/ui/src/layout/NavMotion.js new file mode 100644 index 0000000..c467b89 --- /dev/null +++ b/packages/ui/src/layout/NavMotion.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types' +import { motion } from 'framer-motion' + +// ==============================|| ANIMATION FOR CONTENT ||============================== // + +const NavMotion = ({ children }) => { + const motionVariants = { + initial: { + opacity: 0, + scale: 0.99 + }, + in: { + opacity: 1, + scale: 1 + }, + out: { + opacity: 0, + scale: 1.01 + } + } + + const motionTransition = { + type: 'tween', + ease: 'anticipate', + duration: 0.4 + } + + return ( + + {children} + + ) +} + +NavMotion.propTypes = { + children: PropTypes.node +} + +export default NavMotion diff --git a/packages/ui/src/layout/NavigationScroll.js b/packages/ui/src/layout/NavigationScroll.js new file mode 100644 index 0000000..d2354b9 --- /dev/null +++ b/packages/ui/src/layout/NavigationScroll.js @@ -0,0 +1,26 @@ +import PropTypes from 'prop-types' +import { useEffect } from 'react' +import { useLocation } from 'react-router-dom' + +// ==============================|| NAVIGATION SCROLL TO TOP ||============================== // + +const NavigationScroll = ({ children }) => { + const location = useLocation() + const { pathname } = location + + useEffect(() => { + window.scrollTo({ + top: 0, + left: 0, + behavior: 'smooth' + }) + }, [pathname]) + + return children || null +} + +NavigationScroll.propTypes = { + children: PropTypes.node +} + +export default NavigationScroll diff --git a/packages/ui/src/menu-items/dashboard.js b/packages/ui/src/menu-items/dashboard.js new file mode 100644 index 0000000..ac62713 --- /dev/null +++ b/packages/ui/src/menu-items/dashboard.js @@ -0,0 +1,25 @@ +// assets +import { IconHierarchy, IconKey, IconBook, IconListCheck } from '@tabler/icons' + +// constant +const icons = { IconHierarchy, IconKey, IconBook, IconListCheck } + +// ==============================|| DASHBOARD MENU ITEMS ||============================== // + +const dashboard = { + id: 'dashboard', + title: '', + type: 'group', + children: [ + { + id: 'chatflows', + title: 'Chatflows', + type: 'item', + url: '/chatflows', + icon: icons.IconHierarchy, + breadcrumbs: true + } + ] +} + +export default dashboard diff --git a/packages/ui/src/menu-items/index.js b/packages/ui/src/menu-items/index.js new file mode 100644 index 0000000..bad835a --- /dev/null +++ b/packages/ui/src/menu-items/index.js @@ -0,0 +1,9 @@ +import dashboard from './dashboard' + +// ==============================|| MENU ITEMS ||============================== // + +const menuItems = { + items: [dashboard] +} + +export default menuItems diff --git a/packages/ui/src/menu-items/settings.js b/packages/ui/src/menu-items/settings.js new file mode 100644 index 0000000..25fd7a5 --- /dev/null +++ b/packages/ui/src/menu-items/settings.js @@ -0,0 +1,38 @@ +// assets +import { IconTrash, IconFileUpload, IconFileExport } from '@tabler/icons' + +// constant +const icons = { IconTrash, IconFileUpload, IconFileExport } + +// ==============================|| SETTINGS MENU ITEMS ||============================== // + +const settings = { + id: 'settings', + title: '', + type: 'group', + children: [ + { + id: 'loadChatflow', + title: 'Load Chatflow', + type: 'item', + url: '', + icon: icons.IconFileUpload + }, + { + id: 'exportChatflow', + title: 'Export Chatflow', + type: 'item', + url: '', + icon: icons.IconFileExport + }, + { + id: 'deleteChatflow', + title: 'Delete Chatflow', + type: 'item', + url: '', + icon: icons.IconTrash + } + ] +} + +export default settings diff --git a/packages/ui/src/routes/CanvasRoutes.js b/packages/ui/src/routes/CanvasRoutes.js new file mode 100644 index 0000000..2f15be6 --- /dev/null +++ b/packages/ui/src/routes/CanvasRoutes.js @@ -0,0 +1,27 @@ +import { lazy } from 'react' + +// project imports +import Loadable from 'ui-component/loading/Loadable' +import MinimalLayout from 'layout/MinimalLayout' + +// canvas routing +const Canvas = Loadable(lazy(() => import('views/canvas'))) + +// ==============================|| CANVAS ROUTING ||============================== // + +const CanvasRoutes = { + path: '/', + element: , + children: [ + { + path: '/canvas', + element: + }, + { + path: '/canvas/:id', + element: + } + ] +} + +export default CanvasRoutes diff --git a/packages/ui/src/routes/MainRoutes.js b/packages/ui/src/routes/MainRoutes.js new file mode 100644 index 0000000..61a23c8 --- /dev/null +++ b/packages/ui/src/routes/MainRoutes.js @@ -0,0 +1,27 @@ +import { lazy } from 'react' + +// project imports +import MainLayout from 'layout/MainLayout' +import Loadable from 'ui-component/loading/Loadable' + +// chatflows routing +const Chatflows = Loadable(lazy(() => import('views/chatflows'))) + +// ==============================|| MAIN ROUTING ||============================== // + +const MainRoutes = { + path: '/', + element: , + children: [ + { + path: '/', + element: + }, + { + path: '/chatflows', + element: + } + ] +} + +export default MainRoutes diff --git a/packages/ui/src/routes/index.js b/packages/ui/src/routes/index.js new file mode 100644 index 0000000..15fe4dc --- /dev/null +++ b/packages/ui/src/routes/index.js @@ -0,0 +1,12 @@ +import { useRoutes } from 'react-router-dom' + +// routes +import MainRoutes from './MainRoutes' +import CanvasRoutes from './CanvasRoutes' +import config from 'config' + +// ==============================|| ROUTING RENDER ||============================== // + +export default function ThemeRoutes() { + return useRoutes([MainRoutes, CanvasRoutes], config.basename) +} diff --git a/packages/ui/src/serviceWorker.js b/packages/ui/src/serviceWorker.js new file mode 100644 index 0000000..9a44c66 --- /dev/null +++ b/packages/ui/src/serviceWorker.js @@ -0,0 +1,132 @@ +// This optional code is used to register a service worker. +// register() is not called by default. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on subsequent visits to a page, after all the +// existing tabs open on the page have been closed, since previously cached +// resources are updated in the background. + +// To learn more about the benefits of this model and instructions on how to +// opt-in, read https://bit.ly/CRA-PWA + +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.0/8 are considered localhost for IPv4. + window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) +) + +function registerValidSW(swUrl, config) { + navigator.serviceWorker + .register(swUrl) + .then((registration) => { + registration.onupdatefound = () => { + const installingWorker = registration.installing + if (installingWorker == null) { + return + } + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + // At this point, the updated precached content has been fetched, + // but the previous service worker will still serve the older + // content until all client tabs are closed. + console.info( + 'New content is available and will be used when all tabs for this page are closed. See https://bit.ly/CRA-PWA.' + ) + + // Execute callback + if (config && config.onUpdate) { + config.onUpdate(registration) + } + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + console.info('Content is cached for offline use.') + + // Execute callback + if (config && config.onSuccess) { + config.onSuccess(registration) + } + } + } + } + } + }) + .catch((error) => { + console.error('Error during service worker registration:', error) + }) +} + +function checkValidServiceWorker(swUrl, config) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl, { + headers: { 'Service-Worker': 'script' } + }) + .then((response) => { + // Ensure service worker exists, and that we really are getting a JS file. + const contentType = response.headers.get('content-type') + if (response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1)) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then((registration) => { + registration.unregister().then(() => { + window.location.reload() + }) + }) + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl, config) + } + }) + .catch(() => { + console.info('No internet connection found. App is running in offline mode.') + }) +} + +export function register(config) { + if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href) + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebook/create-react-app/issues/2374 + return + } + + window.addEventListener('load', () => { + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js` + + if (isLocalhost) { + // This is running on localhost. Let's check if a service worker still exists or not. + checkValidServiceWorker(swUrl, config) + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.info( + 'This web app is being served cache-first by a service worker. To learn more, visit https://bit.ly/CRA-PWA' + ) + }) + } else { + // Is not localhost. Just register service worker + registerValidSW(swUrl, config) + } + }) + } +} + +export function unregister() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then((registration) => { + registration.unregister() + }) + .catch((error) => { + console.error(error.message) + }) + } +} diff --git a/packages/ui/src/store/actions.js b/packages/ui/src/store/actions.js new file mode 100644 index 0000000..e0600ff --- /dev/null +++ b/packages/ui/src/store/actions.js @@ -0,0 +1,46 @@ +// action - customization reducer +export const SET_MENU = '@customization/SET_MENU' +export const MENU_TOGGLE = '@customization/MENU_TOGGLE' +export const MENU_OPEN = '@customization/MENU_OPEN' +export const SET_FONT_FAMILY = '@customization/SET_FONT_FAMILY' +export const SET_BORDER_RADIUS = '@customization/SET_BORDER_RADIUS' +export const SET_LAYOUT = '@customization/SET_LAYOUT ' +export const SET_DARKMODE = '@customization/SET_DARKMODE' + +// action - canvas reducer +export const REMOVE_EDGE = '@canvas/REMOVE_EDGE' +export const SET_DIRTY = '@canvas/SET_DIRTY' +export const REMOVE_DIRTY = '@canvas/REMOVE_DIRTY' +export const SET_CHATFLOW = '@canvas/SET_CHATFLOW' + +// action - notifier reducer +export const ENQUEUE_SNACKBAR = 'ENQUEUE_SNACKBAR' +export const CLOSE_SNACKBAR = 'CLOSE_SNACKBAR' +export const REMOVE_SNACKBAR = 'REMOVE_SNACKBAR' + +// action - dialog reducer +export const SHOW_CONFIRM = 'SHOW_CONFIRM' +export const HIDE_CONFIRM = 'HIDE_CONFIRM' + +export const enqueueSnackbar = (notification) => { + const key = notification.options && notification.options.key + + return { + type: ENQUEUE_SNACKBAR, + notification: { + ...notification, + key: key || new Date().getTime() + Math.random() + } + } +} + +export const closeSnackbar = (key) => ({ + type: CLOSE_SNACKBAR, + dismissAll: !key, // dismiss all if no key has been defined + key +}) + +export const removeSnackbar = (key) => ({ + type: REMOVE_SNACKBAR, + key +}) diff --git a/packages/ui/src/store/constant.js b/packages/ui/src/store/constant.js new file mode 100644 index 0000000..972c500 --- /dev/null +++ b/packages/ui/src/store/constant.js @@ -0,0 +1,5 @@ +// constant +export const gridSpacing = 3 +export const drawerWidth = 260 +export const appDrawerWidth = 320 +export const baseURL = process.env.NODE_ENV === 'production' ? window.location.origin : window.location.origin.replace(':8080', ':3000') diff --git a/packages/ui/src/store/context/ConfirmContext.js b/packages/ui/src/store/context/ConfirmContext.js new file mode 100644 index 0000000..0ca68b1 --- /dev/null +++ b/packages/ui/src/store/context/ConfirmContext.js @@ -0,0 +1,5 @@ +import React from 'react' + +const ConfirmContext = React.createContext() + +export default ConfirmContext diff --git a/packages/ui/src/store/context/ConfirmContextProvider.js b/packages/ui/src/store/context/ConfirmContextProvider.js new file mode 100644 index 0000000..c7f40cb --- /dev/null +++ b/packages/ui/src/store/context/ConfirmContextProvider.js @@ -0,0 +1,16 @@ +import { useReducer } from 'react' +import PropTypes from 'prop-types' +import alertReducer, { initialState } from '../reducers/dialogReducer' +import ConfirmContext from './ConfirmContext' + +const ConfirmContextProvider = ({ children }) => { + const [state, dispatch] = useReducer(alertReducer, initialState) + + return {children} +} + +ConfirmContextProvider.propTypes = { + children: PropTypes.any +} + +export default ConfirmContextProvider diff --git a/packages/ui/src/store/context/ReactFlowContext.js b/packages/ui/src/store/context/ReactFlowContext.js new file mode 100644 index 0000000..1620c2e --- /dev/null +++ b/packages/ui/src/store/context/ReactFlowContext.js @@ -0,0 +1,35 @@ +import { createContext, useState } from 'react' +import PropTypes from 'prop-types' + +const initialValue = { + reactFlowInstance: null, + setReactFlowInstance: () => {}, + deleteNode: () => {} +} + +export const flowContext = createContext(initialValue) + +export const ReactFlowContext = ({ children }) => { + const [reactFlowInstance, setReactFlowInstance] = useState(null) + + const deleteNode = (id) => { + reactFlowInstance.setNodes(reactFlowInstance.getNodes().filter((n) => n.id !== id)) + reactFlowInstance.setEdges(reactFlowInstance.getEdges().filter((ns) => ns.source !== id && ns.target !== id)) + } + + return ( + + {children} + + ) +} + +ReactFlowContext.propTypes = { + children: PropTypes.any +} diff --git a/packages/ui/src/store/index.js b/packages/ui/src/store/index.js new file mode 100644 index 0000000..f62c608 --- /dev/null +++ b/packages/ui/src/store/index.js @@ -0,0 +1,9 @@ +import { createStore } from 'redux' +import reducer from './reducer' + +// ==============================|| REDUX - MAIN STORE ||============================== // + +const store = createStore(reducer) +const persister = 'Free' + +export { store, persister } diff --git a/packages/ui/src/store/reducer.js b/packages/ui/src/store/reducer.js new file mode 100644 index 0000000..b464e57 --- /dev/null +++ b/packages/ui/src/store/reducer.js @@ -0,0 +1,18 @@ +import { combineReducers } from 'redux' + +// reducer import +import customizationReducer from './reducers/customizationReducer' +import canvasReducer from './reducers/canvasReducer' +import notifierReducer from './reducers/notifierReducer' +import dialogReducer from './reducers/dialogReducer' + +// ==============================|| COMBINE REDUCER ||============================== // + +const reducer = combineReducers({ + customization: customizationReducer, + canvas: canvasReducer, + notifier: notifierReducer, + dialog: dialogReducer +}) + +export default reducer diff --git a/packages/ui/src/store/reducers/canvasReducer.js b/packages/ui/src/store/reducers/canvasReducer.js new file mode 100644 index 0000000..c16904a --- /dev/null +++ b/packages/ui/src/store/reducers/canvasReducer.js @@ -0,0 +1,39 @@ +// action - state management +import * as actionTypes from '../actions' + +export const initialState = { + removeEdgeId: '', + isDirty: false, + chatflow: null +} + +// ==============================|| CANVAS REDUCER ||============================== // + +const canvasReducer = (state = initialState, action) => { + switch (action.type) { + case actionTypes.REMOVE_EDGE: + return { + ...state, + removeEdgeId: action.edgeId + } + case actionTypes.SET_DIRTY: + return { + ...state, + isDirty: true + } + case actionTypes.REMOVE_DIRTY: + return { + ...state, + isDirty: false + } + case actionTypes.SET_CHATFLOW: + return { + ...state, + chatflow: action.chatflow + } + default: + return state + } +} + +export default canvasReducer diff --git a/packages/ui/src/store/reducers/customizationReducer.js b/packages/ui/src/store/reducers/customizationReducer.js new file mode 100644 index 0000000..475804c --- /dev/null +++ b/packages/ui/src/store/reducers/customizationReducer.js @@ -0,0 +1,57 @@ +// project imports +import config from 'config' + +// action - state management +import * as actionTypes from '../actions' + +export const initialState = { + isOpen: [], // for active default menu + fontFamily: config.fontFamily, + borderRadius: config.borderRadius, + opened: true, + isHorizontal: localStorage.getItem('isHorizontal') === 'true' ? true : false, + isDarkMode: localStorage.getItem('isDarkMode') === 'true' ? true : false +} + +// ==============================|| CUSTOMIZATION REDUCER ||============================== // + +const customizationReducer = (state = initialState, action) => { + let id + switch (action.type) { + case actionTypes.MENU_OPEN: + id = action.id + return { + ...state, + isOpen: [id] + } + case actionTypes.SET_MENU: + return { + ...state, + opened: action.opened + } + case actionTypes.SET_FONT_FAMILY: + return { + ...state, + fontFamily: action.fontFamily + } + case actionTypes.SET_BORDER_RADIUS: + return { + ...state, + borderRadius: action.borderRadius + } + case actionTypes.SET_LAYOUT: + return { + ...state, + isHorizontal: action.isHorizontal + } + case actionTypes.SET_DARKMODE: + return { + ...state, + isDarkMode: action.isDarkMode + } + default: + return state + } +} + +export default customizationReducer diff --git a/packages/ui/src/store/reducers/dialogReducer.js b/packages/ui/src/store/reducers/dialogReducer.js new file mode 100644 index 0000000..ded5762 --- /dev/null +++ b/packages/ui/src/store/reducers/dialogReducer.js @@ -0,0 +1,28 @@ +import { SHOW_CONFIRM, HIDE_CONFIRM } from '../actions' + +export const initialState = { + show: false, + title: '', + description: '', + confirmButtonName: 'OK', + cancelButtonName: 'Cancel' +} + +const alertReducer = (state = initialState, action) => { + switch (action.type) { + case SHOW_CONFIRM: + return { + show: true, + title: action.payload.title, + description: action.payload.description, + confirmButtonName: action.payload.confirmButtonName, + cancelButtonName: action.payload.cancelButtonName + } + case HIDE_CONFIRM: + return initialState + default: + return state + } +} + +export default alertReducer diff --git a/packages/ui/src/store/reducers/notifierReducer.js b/packages/ui/src/store/reducers/notifierReducer.js new file mode 100644 index 0000000..d6b1b2d --- /dev/null +++ b/packages/ui/src/store/reducers/notifierReducer.js @@ -0,0 +1,40 @@ +import { ENQUEUE_SNACKBAR, CLOSE_SNACKBAR, REMOVE_SNACKBAR } from '../actions' + +export const initialState = { + notifications: [] +} + +const notifierReducer = (state = initialState, action) => { + switch (action.type) { + case ENQUEUE_SNACKBAR: + return { + ...state, + notifications: [ + ...state.notifications, + { + key: action.key, + ...action.notification + } + ] + } + + case CLOSE_SNACKBAR: + return { + ...state, + notifications: state.notifications.map((notification) => + action.dismissAll || notification.key === action.key ? { ...notification, dismissed: true } : { ...notification } + ) + } + + case REMOVE_SNACKBAR: + return { + ...state, + notifications: state.notifications.filter((notification) => notification.key !== action.key) + } + + default: + return state + } +} + +export default notifierReducer diff --git a/packages/ui/src/themes/compStyleOverride.js b/packages/ui/src/themes/compStyleOverride.js new file mode 100644 index 0000000..eb6f6de --- /dev/null +++ b/packages/ui/src/themes/compStyleOverride.js @@ -0,0 +1,204 @@ +export default function componentStyleOverrides(theme) { + const bgColor = theme.colors?.grey50 + return { + MuiButton: { + styleOverrides: { + root: { + fontWeight: 500, + borderRadius: '4px' + } + } + }, + MuiSvgIcon: { + styleOverrides: { + root: { + color: theme?.customization?.isDarkMode ? theme.colors?.paper : 'inherit', + background: theme?.customization?.isDarkMode ? theme.colors?.darkPrimaryLight : 'inherit' + } + } + }, + MuiPaper: { + defaultProps: { + elevation: 0 + }, + styleOverrides: { + root: { + backgroundImage: 'none' + }, + rounded: { + borderRadius: `${theme?.customization?.borderRadius}px` + } + } + }, + MuiCardHeader: { + styleOverrides: { + root: { + color: theme.colors?.textDark, + padding: '24px' + }, + title: { + fontSize: '1.125rem' + } + } + }, + MuiCardContent: { + styleOverrides: { + root: { + padding: '24px' + } + } + }, + MuiCardActions: { + styleOverrides: { + root: { + padding: '24px' + } + } + }, + MuiListItemButton: { + styleOverrides: { + root: { + color: theme.darkTextPrimary, + paddingTop: '10px', + paddingBottom: '10px', + '&.Mui-selected': { + color: theme.menuSelected, + backgroundColor: theme.menuSelectedBack, + '&:hover': { + backgroundColor: theme.menuSelectedBack + }, + '& .MuiListItemIcon-root': { + color: theme.menuSelected + } + }, + '&:hover': { + backgroundColor: theme.menuSelectedBack, + color: theme.menuSelected, + '& .MuiListItemIcon-root': { + color: theme.menuSelected + } + } + } + } + }, + MuiListItemIcon: { + styleOverrides: { + root: { + color: theme.darkTextPrimary, + minWidth: '36px' + } + } + }, + MuiListItemText: { + styleOverrides: { + primary: { + color: theme.textDark + } + } + }, + MuiInputBase: { + styleOverrides: { + input: { + color: theme.textDark, + '&::placeholder': { + color: theme.darkTextSecondary, + fontSize: '0.875rem' + } + } + } + }, + MuiOutlinedInput: { + styleOverrides: { + root: { + background: theme?.customization?.isDarkMode ? theme.colors?.darkPrimary800 : bgColor, + borderRadius: `${theme?.customization?.borderRadius}px`, + '& .MuiOutlinedInput-notchedOutline': { + borderColor: theme.colors?.grey400 + }, + '&:hover $notchedOutline': { + borderColor: theme.colors?.primaryLight + }, + '&.MuiInputBase-multiline': { + padding: 1 + } + }, + input: { + fontWeight: 500, + background: theme?.customization?.isDarkMode ? theme.colors?.darkPrimary800 : bgColor, + padding: '15.5px 14px', + borderRadius: `${theme?.customization?.borderRadius}px`, + '&.MuiInputBase-inputSizeSmall': { + padding: '10px 14px', + '&.MuiInputBase-inputAdornedStart': { + paddingLeft: 0 + } + } + }, + inputAdornedStart: { + paddingLeft: 4 + }, + notchedOutline: { + borderRadius: `${theme?.customization?.borderRadius}px` + } + } + }, + MuiSlider: { + styleOverrides: { + root: { + '&.Mui-disabled': { + color: theme.colors?.grey300 + } + }, + mark: { + backgroundColor: theme.paper, + width: '4px' + }, + valueLabel: { + color: theme?.colors?.primaryLight + } + } + }, + MuiDivider: { + styleOverrides: { + root: { + borderColor: theme.divider, + opacity: 1 + } + } + }, + MuiAvatar: { + styleOverrides: { + root: { + color: theme.colors?.primaryDark, + background: theme.colors?.primary200 + } + } + }, + MuiChip: { + styleOverrides: { + root: { + '&.MuiChip-deletable .MuiChip-deleteIcon': { + color: 'inherit' + } + } + } + }, + MuiTooltip: { + styleOverrides: { + tooltip: { + color: theme?.customization?.isDarkMode ? theme.colors?.paper : theme.paper, + background: theme.colors?.grey700 + } + } + }, + MuiAutocomplete: { + styleOverrides: { + option: { + '&:hover': { + background: theme?.customization?.isDarkMode ? '#233345 !important' : '' + } + } + } + } + } +} diff --git a/packages/ui/src/themes/index.js b/packages/ui/src/themes/index.js new file mode 100644 index 0000000..ad15b10 --- /dev/null +++ b/packages/ui/src/themes/index.js @@ -0,0 +1,70 @@ +import { createTheme } from '@mui/material/styles' + +// assets +import colors from 'assets/scss/_themes-vars.module.scss' + +// project imports +import componentStyleOverrides from './compStyleOverride' +import themePalette from './palette' +import themeTypography from './typography' + +/** + * Represent theme style and structure as per Material-UI + * @param {JsonObject} customization customization parameter object + */ + +export const theme = (customization) => { + const color = colors + + const themeOption = customization.isDarkMode + ? { + colors: color, + heading: color.paper, + paper: color.darkPrimaryLight, + backgroundDefault: color.darkPaper, + background: color.darkPrimaryLight, + darkTextPrimary: color.paper, + darkTextSecondary: color.paper, + textDark: color.paper, + menuSelected: color.darkSecondaryDark, + menuSelectedBack: color.darkSecondaryLight, + divider: color.darkPaper, + customization + } + : { + colors: color, + heading: color.grey900, + paper: color.paper, + backgroundDefault: color.paper, + background: color.primaryLight, + darkTextPrimary: color.grey700, + darkTextSecondary: color.grey500, + textDark: color.grey900, + menuSelected: color.secondaryDark, + menuSelectedBack: color.secondaryLight, + divider: color.grey200, + customization + } + + const themeOptions = { + direction: 'ltr', + palette: themePalette(themeOption), + mixins: { + toolbar: { + minHeight: '48px', + padding: '16px', + '@media (min-width: 600px)': { + minHeight: '48px' + } + } + }, + typography: themeTypography(themeOption) + } + + const themes = createTheme(themeOptions) + themes.components = componentStyleOverrides(themeOption) + + return themes +} + +export default theme diff --git a/packages/ui/src/themes/palette.js b/packages/ui/src/themes/palette.js new file mode 100644 index 0000000..97abbe8 --- /dev/null +++ b/packages/ui/src/themes/palette.js @@ -0,0 +1,96 @@ +/** + * Color intention that you want to used in your theme + * @param {JsonObject} theme Theme customization object + */ + +export default function themePalette(theme) { + return { + mode: theme?.customization?.navType, + common: { + black: theme.colors?.darkPaper + }, + primary: { + light: theme.customization.isDarkMode ? theme.colors?.darkPrimaryLight : theme.colors?.primaryLight, + main: theme.colors?.primaryMain, + dark: theme.customization.isDarkMode ? theme.colors?.darkPrimaryDark : theme.colors?.primaryDark, + 200: theme.customization.isDarkMode ? theme.colors?.darkPrimary200 : theme.colors?.primary200, + 800: theme.customization.isDarkMode ? theme.colors?.darkPrimary800 : theme.colors?.primary800 + }, + secondary: { + light: theme.customization.isDarkMode ? theme.colors?.darkSecondaryLight : theme.colors?.secondaryLight, + main: theme.customization.isDarkMode ? theme.colors?.darkSecondaryMain : theme.colors?.secondaryMain, + dark: theme.customization.isDarkMode ? theme.colors?.darkSecondaryDark : theme.colors?.secondaryDark, + 200: theme.colors?.secondary200, + 800: theme.colors?.secondary800 + }, + error: { + light: theme.colors?.errorLight, + main: theme.colors?.errorMain, + dark: theme.colors?.errorDark + }, + orange: { + light: theme.colors?.orangeLight, + main: theme.colors?.orangeMain, + dark: theme.colors?.orangeDark + }, + warning: { + light: theme.colors?.warningLight, + main: theme.colors?.warningMain, + dark: theme.colors?.warningDark + }, + success: { + light: theme.colors?.successLight, + 200: theme.colors?.success200, + main: theme.colors?.successMain, + dark: theme.colors?.successDark + }, + grey: { + 50: theme.colors?.grey50, + 100: theme.colors?.grey100, + 200: theme.colors?.grey200, + 300: theme.colors?.grey300, + 500: theme.darkTextSecondary, + 600: theme.heading, + 700: theme.darkTextPrimary, + 900: theme.textDark + }, + dark: { + light: theme.colors?.darkTextPrimary, + main: theme.colors?.darkLevel1, + dark: theme.colors?.darkLevel2, + 800: theme.colors?.darkBackground, + 900: theme.colors?.darkPaper + }, + text: { + primary: theme.darkTextPrimary, + secondary: theme.darkTextSecondary, + dark: theme.textDark, + hint: theme.colors?.grey100 + }, + background: { + paper: theme.paper, + default: theme.backgroundDefault + }, + card: { + main: theme.customization.isDarkMode ? theme.colors?.darkPrimaryMain : theme.colors?.paper, + light: theme.customization.isDarkMode ? theme.colors?.darkPrimary200 : theme.colors?.paper, + hover: theme.customization.isDarkMode ? theme.colors?.darkPrimary800 : theme.colors?.paper + }, + asyncSelect: { + main: theme.customization.isDarkMode ? theme.colors?.darkPrimary800 : theme.colors?.grey50 + }, + canvasHeader: { + executionLight: theme.colors?.successLight, + executionDark: theme.colors?.successDark, + deployLight: theme.colors?.primaryLight, + deployDark: theme.colors?.primaryDark, + saveLight: theme.colors?.secondaryLight, + saveDark: theme.colors?.secondaryDark, + settingsLight: theme.colors?.grey300, + settingsDark: theme.colors?.grey700 + }, + codeEditor: { + main: theme.customization.isDarkMode ? theme.colors?.darkPrimary800 : theme.colors?.primaryLight + } + } +} diff --git a/packages/ui/src/themes/typography.js b/packages/ui/src/themes/typography.js new file mode 100644 index 0000000..d6a3f63 --- /dev/null +++ b/packages/ui/src/themes/typography.js @@ -0,0 +1,133 @@ +/** + * Typography used in theme + * @param {JsonObject} theme theme customization object + */ + +export default function themeTypography(theme) { + return { + fontFamily: theme?.customization?.fontFamily, + h6: { + fontWeight: 500, + color: theme.heading, + fontSize: '0.75rem' + }, + h5: { + fontSize: '0.875rem', + color: theme.heading, + fontWeight: 500 + }, + h4: { + fontSize: '1rem', + color: theme.heading, + fontWeight: 600 + }, + h3: { + fontSize: '1.25rem', + color: theme.heading, + fontWeight: 600 + }, + h2: { + fontSize: '1.5rem', + color: theme.heading, + fontWeight: 700 + }, + h1: { + fontSize: '2.125rem', + color: theme.heading, + fontWeight: 700 + }, + subtitle1: { + fontSize: '0.875rem', + fontWeight: 500, + color: theme.textDark + }, + subtitle2: { + fontSize: '0.75rem', + fontWeight: 400, + color: theme.darkTextSecondary + }, + caption: { + fontSize: '0.75rem', + color: theme.darkTextSecondary, + fontWeight: 400 + }, + body1: { + fontSize: '0.875rem', + fontWeight: 400, + lineHeight: '1.334em' + }, + body2: { + letterSpacing: '0em', + fontWeight: 400, + lineHeight: '1.5em', + color: theme.darkTextPrimary + }, + button: { + textTransform: 'capitalize' + }, + customInput: { + marginTop: 1, + marginBottom: 1, + '& > label': { + top: 23, + left: 0, + color: theme.grey500, + '&[data-shrink="false"]': { + top: 5 + } + }, + '& > div > input': { + padding: '30.5px 14px 11.5px !important' + }, + '& legend': { + display: 'none' + }, + '& fieldset': { + top: 0 + } + }, + mainContent: { + backgroundColor: theme.background, + width: '100%', + minHeight: 'calc(100vh - 75px)', + flexGrow: 1, + padding: '20px', + marginTop: '75px', + marginRight: '20px', + borderRadius: `${theme?.customization?.borderRadius}px` + }, + menuCaption: { + fontSize: '0.875rem', + fontWeight: 500, + color: theme.heading, + padding: '6px', + textTransform: 'capitalize', + marginTop: '10px' + }, + subMenuCaption: { + fontSize: '0.6875rem', + fontWeight: 500, + color: theme.darkTextSecondary, + textTransform: 'capitalize' + }, + commonAvatar: { + cursor: 'pointer', + borderRadius: '8px' + }, + smallAvatar: { + width: '22px', + height: '22px', + fontSize: '1rem' + }, + mediumAvatar: { + width: '34px', + height: '34px', + fontSize: '1.2rem' + }, + largeAvatar: { + width: '44px', + height: '44px', + fontSize: '1.5rem' + } + } +} diff --git a/packages/ui/src/ui-component/button/AnimateButton.js b/packages/ui/src/ui-component/button/AnimateButton.js new file mode 100644 index 0000000..ce2d3fb --- /dev/null +++ b/packages/ui/src/ui-component/button/AnimateButton.js @@ -0,0 +1,97 @@ +import PropTypes from 'prop-types' +import { forwardRef } from 'react' +// third-party +import { motion, useCycle } from 'framer-motion' + +// ==============================|| ANIMATION BUTTON ||============================== // + +const AnimateButton = forwardRef(function AnimateButton({ children, type, direction, offset, scale }, ref) { + let offset1 + let offset2 + switch (direction) { + case 'up': + case 'left': + offset1 = offset + offset2 = 0 + break + case 'right': + case 'down': + default: + offset1 = 0 + offset2 = offset + break + } + + const [x, cycleX] = useCycle(offset1, offset2) + const [y, cycleY] = useCycle(offset1, offset2) + + switch (type) { + case 'rotate': + return ( + + {children} + + ) + case 'slide': + if (direction === 'up' || direction === 'down') { + return ( + cycleY()} + onHoverStart={() => cycleY()} + > + {children} + + ) + } + return ( + cycleX()} onHoverStart={() => cycleX()}> + {children} + + ) + + case 'scale': + default: + if (typeof scale === 'number') { + scale = { + hover: scale, + tap: scale + } + } + return ( + + {children} + + ) + } +}) + +AnimateButton.propTypes = { + children: PropTypes.node, + offset: PropTypes.number, + type: PropTypes.oneOf(['slide', 'scale', 'rotate']), + direction: PropTypes.oneOf(['up', 'down', 'left', 'right']), + scale: PropTypes.oneOfType([PropTypes.number, PropTypes.object]) +} + +AnimateButton.defaultProps = { + type: 'scale', + offset: 10, + direction: 'right', + scale: { + hover: 1, + tap: 0.9 + } +} + +export default AnimateButton diff --git a/packages/ui/src/ui-component/button/StyledButton.js b/packages/ui/src/ui-component/button/StyledButton.js new file mode 100644 index 0000000..6e0c707 --- /dev/null +++ b/packages/ui/src/ui-component/button/StyledButton.js @@ -0,0 +1,11 @@ +import { styled } from '@mui/material/styles' +import { Button } from '@mui/material' + +export const StyledButton = styled(Button)(({ theme, color = 'primary' }) => ({ + color: 'white', + backgroundColor: theme.palette[color].main, + '&:hover': { + backgroundColor: theme.palette[color].main, + backgroundImage: `linear-gradient(rgb(0 0 0/10%) 0 0)` + } +})) diff --git a/packages/ui/src/ui-component/button/StyledFab.js b/packages/ui/src/ui-component/button/StyledFab.js new file mode 100644 index 0000000..d1f5ac4 --- /dev/null +++ b/packages/ui/src/ui-component/button/StyledFab.js @@ -0,0 +1,11 @@ +import { styled } from '@mui/material/styles' +import { Fab } from '@mui/material' + +export const StyledFab = styled(Fab)(({ theme, color = 'primary' }) => ({ + color: 'white', + backgroundColor: theme.palette[color].main, + '&:hover': { + backgroundColor: theme.palette[color].main, + backgroundImage: `linear-gradient(rgb(0 0 0/10%) 0 0)` + } +})) diff --git a/packages/ui/src/ui-component/cards/ItemCard.js b/packages/ui/src/ui-component/cards/ItemCard.js new file mode 100644 index 0000000..aac1334 --- /dev/null +++ b/packages/ui/src/ui-component/cards/ItemCard.js @@ -0,0 +1,95 @@ +import PropTypes from 'prop-types' + +// material-ui +import { styled, useTheme } from '@mui/material/styles' +import { Box, Grid, Chip, Typography } from '@mui/material' + +// project imports +import MainCard from 'ui-component/cards/MainCard' +import SkeletonChatflowCard from 'ui-component/cards/Skeleton/ChatflowCard' + +const CardWrapper = styled(MainCard)(({ theme }) => ({ + background: theme.palette.card.main, + color: theme.darkTextPrimary, + overflow: 'hidden', + position: 'relative', + boxShadow: '0 2px 14px 0 rgb(32 40 45 / 8%)', + cursor: 'pointer', + '&:hover': { + background: theme.palette.card.hover, + boxShadow: '0 2px 14px 0 rgb(32 40 45 / 20%)' + } +})) + +// ===========================|| CONTRACT CARD ||=========================== // + +const ItemCard = ({ isLoading, data, images, onClick }) => { + const theme = useTheme() + + const chipSX = { + height: 24, + padding: '0 6px' + } + + const activeChatflowSX = { + ...chipSX, + color: 'white', + backgroundColor: theme.palette.success.dark + } + + return ( + <> + {isLoading ? ( + + ) : ( + + + +
+ {data.name} +
+ + {data.deployed && ( + + + + )} + + {images && ( +
+ {images.map((img) => ( +
+ +
+ ))} +
+ )} +
+
+
+ )} + + ) +} + +ItemCard.propTypes = { + isLoading: PropTypes.bool, + data: PropTypes.object, + images: PropTypes.array, + onClick: PropTypes.func +} + +export default ItemCard diff --git a/packages/ui/src/ui-component/cards/MainCard.js b/packages/ui/src/ui-component/cards/MainCard.js new file mode 100644 index 0000000..302b15d --- /dev/null +++ b/packages/ui/src/ui-component/cards/MainCard.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types' +import { forwardRef } from 'react' + +// material-ui +import { useTheme } from '@mui/material/styles' +import { Card, CardContent, CardHeader, Divider, Typography } from '@mui/material' + +// constant +const headerSX = { + '& .MuiCardHeader-action': { mr: 0 } +} + +// ==============================|| CUSTOM MAIN CARD ||============================== // + +const MainCard = forwardRef(function MainCard( + { + border = true, + boxShadow, + children, + content = true, + contentClass = '', + contentSX = {}, + darkTitle, + secondary, + shadow, + sx = {}, + title, + ...others + }, + ref +) { + const theme = useTheme() + + return ( + + {/* card header and action */} + {!darkTitle && title && } + {darkTitle && title && {title}} action={secondary} />} + + {/* content & header divider */} + {title && } + + {/* card content */} + {content && ( + + {children} + + )} + {!content && children} + + ) +}) + +MainCard.propTypes = { + border: PropTypes.bool, + boxShadow: PropTypes.bool, + children: PropTypes.node, + content: PropTypes.bool, + contentClass: PropTypes.string, + contentSX: PropTypes.object, + darkTitle: PropTypes.bool, + secondary: PropTypes.oneOfType([PropTypes.node, PropTypes.string, PropTypes.object]), + shadow: PropTypes.string, + sx: PropTypes.object, + title: PropTypes.oneOfType([PropTypes.node, PropTypes.string, PropTypes.object]) +} + +export default MainCard diff --git a/packages/ui/src/ui-component/cards/Skeleton/ChatflowCard.js b/packages/ui/src/ui-component/cards/Skeleton/ChatflowCard.js new file mode 100644 index 0000000..955fb09 --- /dev/null +++ b/packages/ui/src/ui-component/cards/Skeleton/ChatflowCard.js @@ -0,0 +1,32 @@ +// material-ui +import { Card, CardContent, Grid } from '@mui/material' +import Skeleton from '@mui/material/Skeleton' + +// ==============================|| SKELETON - BRIDGE CARD ||============================== // + +const ChatflowCard = () => ( + + + + + + + + + + + + + + + + + + + + + + +) + +export default ChatflowCard diff --git a/packages/ui/src/ui-component/dialog/ConfirmDialog.js b/packages/ui/src/ui-component/dialog/ConfirmDialog.js new file mode 100644 index 0000000..8176ecd --- /dev/null +++ b/packages/ui/src/ui-component/dialog/ConfirmDialog.js @@ -0,0 +1,39 @@ +import { createPortal } from 'react-dom' +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material' +import useConfirm from 'hooks/useConfirm' +import { StyledButton } from 'ui-component/button/StyledButton' + +const ConfirmDialog = () => { + const { onConfirm, onCancel, confirmState } = useConfirm() + const portalElement = document.getElementById('portal') + + const component = confirmState.show ? ( + + + {confirmState.title} + + + + {confirmState.description} + + + + + + {confirmState.confirmButtonName} + + + + ) : null + + return createPortal(component, portalElement) +} + +export default ConfirmDialog diff --git a/packages/ui/src/ui-component/dialog/SaveChatflowDialog.js b/packages/ui/src/ui-component/dialog/SaveChatflowDialog.js new file mode 100644 index 0000000..24a7f2c --- /dev/null +++ b/packages/ui/src/ui-component/dialog/SaveChatflowDialog.js @@ -0,0 +1,61 @@ +import { createPortal } from 'react-dom' +import { useState, useEffect } from 'react' +import PropTypes from 'prop-types' + +import { Button, Dialog, DialogActions, DialogContent, OutlinedInput, DialogTitle } from '@mui/material' +import { StyledButton } from 'ui-component/button/StyledButton' + +const SaveChatflowDialog = ({ show, dialogProps, onCancel, onConfirm }) => { + const portalElement = document.getElementById('portal') + + const [chatflowName, setChatflowName] = useState('') + const [isReadyToSave, setIsReadyToSave] = useState(false) + + useEffect(() => { + if (chatflowName) setIsReadyToSave(true) + else setIsReadyToSave(false) + }, [chatflowName]) + + const component = show ? ( + + + {dialogProps.title} + + + setChatflowName(e.target.value)} + /> + + + + onConfirm(chatflowName)}> + {dialogProps.confirmButtonName} + + + + ) : null + + return createPortal(component, portalElement) +} + +SaveChatflowDialog.propTypes = { + show: PropTypes.bool, + dialogProps: PropTypes.object, + onCancel: PropTypes.func, + onConfirm: PropTypes.func +} + +export default SaveChatflowDialog diff --git a/packages/ui/src/ui-component/dropdown/Dropdown.js b/packages/ui/src/ui-component/dropdown/Dropdown.js new file mode 100644 index 0000000..aa40ff1 --- /dev/null +++ b/packages/ui/src/ui-component/dropdown/Dropdown.js @@ -0,0 +1,61 @@ +import { useState } from 'react' +import { useSelector } from 'react-redux' + +import { Popper, FormControl, TextField, Box, Typography } from '@mui/material' +import Autocomplete, { autocompleteClasses } from '@mui/material/Autocomplete' +import { styled } from '@mui/material/styles' +import PropTypes from 'prop-types' + +const StyledPopper = styled(Popper)({ + boxShadow: '0px 8px 10px -5px rgb(0 0 0 / 20%), 0px 16px 24px 2px rgb(0 0 0 / 14%), 0px 6px 30px 5px rgb(0 0 0 / 12%)', + borderRadius: '10px', + [`& .${autocompleteClasses.listbox}`]: { + boxSizing: 'border-box', + '& ul': { + padding: 10, + margin: 10 + } + } +}) + +export const Dropdown = ({ name, value, options, onSelect }) => { + const customization = useSelector((state) => state.customization) + const findMatchingOptions = (options = [], value) => options.find((option) => option.name === value) + const getDefaultOptionValue = () => '' + let [internalValue, setInternalValue] = useState(value ?? 'choose an option') + + return ( + + { + const value = selection ? selection.name : '' + setInternalValue(value) + onSelect(value) + }} + PopperComponent={StyledPopper} + renderInput={(params) => } + renderOption={(props, option) => ( + +
+ {option.label} + {option.description && ( + {option.description} + )} +
+
+ )} + /> +
+ ) +} + +Dropdown.propTypes = { + name: PropTypes.string, + value: PropTypes.string, + options: PropTypes.array, + onSelect: PropTypes.func +} diff --git a/packages/ui/src/ui-component/editor/DarkCodeEditor.js b/packages/ui/src/ui-component/editor/DarkCodeEditor.js new file mode 100644 index 0000000..ce960d3 --- /dev/null +++ b/packages/ui/src/ui-component/editor/DarkCodeEditor.js @@ -0,0 +1,40 @@ +import Editor from 'react-simple-code-editor' +import { highlight, languages } from 'prismjs/components/prism-core' +import 'prismjs/components/prism-clike' +import 'prismjs/components/prism-javascript' +import 'prismjs/components/prism-json' +import 'prismjs/components/prism-markup' +import './prism-dark.css' +import PropTypes from 'prop-types' +import { useTheme } from '@mui/material/styles' + +export const DarkCodeEditor = ({ value, placeholder, type, style, onValueChange, onMouseUp, onBlur }) => { + const theme = useTheme() + + return ( + highlight(code, type === 'json' ? languages.json : languages.js)} + padding={10} + onValueChange={onValueChange} + onMouseUp={onMouseUp} + onBlur={onBlur} + style={{ + ...style, + background: theme.palette.codeEditor.main + }} + textareaClassName='editor__textarea' + /> + ) +} + +DarkCodeEditor.propTypes = { + value: PropTypes.string, + placeholder: PropTypes.string, + type: PropTypes.string, + style: PropTypes.object, + onValueChange: PropTypes.func, + onMouseUp: PropTypes.func, + onBlur: PropTypes.func +} diff --git a/packages/ui/src/ui-component/editor/LightCodeEditor.js b/packages/ui/src/ui-component/editor/LightCodeEditor.js new file mode 100644 index 0000000..36d56fb --- /dev/null +++ b/packages/ui/src/ui-component/editor/LightCodeEditor.js @@ -0,0 +1,40 @@ +import Editor from 'react-simple-code-editor' +import { highlight, languages } from 'prismjs/components/prism-core' +import 'prismjs/components/prism-clike' +import 'prismjs/components/prism-javascript' +import 'prismjs/components/prism-json' +import 'prismjs/components/prism-markup' +import './prism-light.css' +import PropTypes from 'prop-types' +import { useTheme } from '@mui/material/styles' + +export const LightCodeEditor = ({ value, placeholder, type, style, onValueChange, onMouseUp, onBlur }) => { + const theme = useTheme() + + return ( + highlight(code, type === 'json' ? languages.json : languages.js)} + padding={10} + onValueChange={onValueChange} + onMouseUp={onMouseUp} + onBlur={onBlur} + style={{ + ...style, + background: theme.palette.card.main + }} + textareaClassName='editor__textarea' + /> + ) +} + +LightCodeEditor.propTypes = { + value: PropTypes.string, + placeholder: PropTypes.string, + type: PropTypes.string, + style: PropTypes.object, + onValueChange: PropTypes.func, + onMouseUp: PropTypes.func, + onBlur: PropTypes.func +} diff --git a/packages/ui/src/ui-component/editor/prism-dark.css b/packages/ui/src/ui-component/editor/prism-dark.css new file mode 100644 index 0000000..c4bfb41 --- /dev/null +++ b/packages/ui/src/ui-component/editor/prism-dark.css @@ -0,0 +1,275 @@ +pre[class*='language-'], +code[class*='language-'] { + color: #d4d4d4; + font-size: 13px; + text-shadow: none; + font-family: Menlo, Monaco, Consolas, 'Andale Mono', 'Ubuntu Mono', 'Courier New', monospace; + direction: ltr; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + line-height: 1.5; + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +pre[class*='language-']::selection, +code[class*='language-']::selection, +pre[class*='language-'] *::selection, +code[class*='language-'] *::selection { + text-shadow: none; + background: #264f78; +} + +@media print { + pre[class*='language-'], + code[class*='language-'] { + text-shadow: none; + } +} + +pre[class*='language-'] { + padding: 1em; + margin: 0.5em 0; + overflow: auto; + background: #1e1e1e; +} + +:not(pre) > code[class*='language-'] { + padding: 0.1em 0.3em; + border-radius: 0.3em; + color: #db4c69; + background: #1e1e1e; +} +/********************************************************* +* Tokens +*/ +.namespace { + opacity: 0.7; +} + +.token.doctype .token.doctype-tag { + color: #569cd6; +} + +.token.doctype .token.name { + color: #9cdcfe; +} + +.token.comment, +.token.prolog { + color: #6a9955; +} + +.token.punctuation, +.language-html .language-css .token.punctuation, +.language-html .language-javascript .token.punctuation { + color: #d4d4d4; +} + +.token.property, +.token.tag, +.token.boolean, +.token.number, +.token.constant, +.token.symbol, +.token.inserted, +.token.unit { + color: #b5cea8; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.deleted { + color: #ce9178; +} + +.language-css .token.string.url { + text-decoration: underline; +} + +.token.operator, +.token.entity { + color: #d4d4d4; +} + +.token.operator.arrow { + color: #569cd6; +} + +.token.atrule { + color: #ce9178; +} + +.token.atrule .token.rule { + color: #c586c0; +} + +.token.atrule .token.url { + color: #9cdcfe; +} + +.token.atrule .token.url .token.function { + color: #dcdcaa; +} + +.token.atrule .token.url .token.punctuation { + color: #d4d4d4; +} + +.token.keyword { + color: #569cd6; +} + +.token.keyword.module, +.token.keyword.control-flow { + color: #c586c0; +} + +.token.function, +.token.function .token.maybe-class-name { + color: #dcdcaa; +} + +.token.regex { + color: #d16969; +} + +.token.important { + color: #569cd6; +} + +.token.italic { + font-style: italic; +} + +.token.constant { + color: #9cdcfe; +} + +.token.class-name, +.token.maybe-class-name { + color: #4ec9b0; +} + +.token.console { + color: #9cdcfe; +} + +.token.parameter { + color: #9cdcfe; +} + +.token.interpolation { + color: #9cdcfe; +} + +.token.punctuation.interpolation-punctuation { + color: #569cd6; +} + +.token.boolean { + color: #569cd6; +} + +.token.property, +.token.variable, +.token.imports .token.maybe-class-name, +.token.exports .token.maybe-class-name { + color: #9cdcfe; +} + +.token.selector { + color: #d7ba7d; +} + +.token.escape { + color: #d7ba7d; +} + +.token.tag { + color: #569cd6; +} + +.token.tag .token.punctuation { + color: #808080; +} + +.token.cdata { + color: #808080; +} + +.token.attr-name { + color: #9cdcfe; +} + +.token.attr-value, +.token.attr-value .token.punctuation { + color: #ce9178; +} + +.token.attr-value .token.punctuation.attr-equals { + color: #d4d4d4; +} + +.token.entity { + color: #569cd6; +} + +.token.namespace { + color: #4ec9b0; +} +/********************************************************* +* Language Specific +*/ + +pre[class*='language-javascript'], +code[class*='language-javascript'], +pre[class*='language-jsx'], +code[class*='language-jsx'], +pre[class*='language-typescript'], +code[class*='language-typescript'], +pre[class*='language-tsx'], +code[class*='language-tsx'] { + color: #9cdcfe; +} + +pre[class*='language-css'], +code[class*='language-css'] { + color: #ce9178; +} + +pre[class*='language-html'], +code[class*='language-html'] { + color: #d4d4d4; +} + +.language-regex .token.anchor { + color: #dcdcaa; +} + +.language-html .token.punctuation { + color: #808080; +} +/********************************************************* +* Line highlighting +*/ +pre[class*='language-'] > code[class*='language-'] { + position: relative; + z-index: 1; +} + +.line-highlight.line-highlight { + background: #f7ebc6; + box-shadow: inset 5px 0 0 #f7d87c; + z-index: 0; +} diff --git a/packages/ui/src/ui-component/editor/prism-light.css b/packages/ui/src/ui-component/editor/prism-light.css new file mode 100644 index 0000000..95d6d6e --- /dev/null +++ b/packages/ui/src/ui-component/editor/prism-light.css @@ -0,0 +1,207 @@ +code[class*='language-'], +pre[class*='language-'] { + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + color: #90a4ae; + background: #fafafa; + font-family: Roboto Mono, monospace; + font-size: 1em; + line-height: 1.5em; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +code[class*='language-']::-moz-selection, +pre[class*='language-']::-moz-selection, +code[class*='language-'] ::-moz-selection, +pre[class*='language-'] ::-moz-selection { + background: #cceae7; + color: #263238; +} + +code[class*='language-']::selection, +pre[class*='language-']::selection, +code[class*='language-'] ::selection, +pre[class*='language-'] ::selection { + background: #cceae7; + color: #263238; +} + +:not(pre) > code[class*='language-'] { + white-space: normal; + border-radius: 0.2em; + padding: 0.1em; +} + +pre[class*='language-'] { + overflow: auto; + position: relative; + margin: 0.5em 0; + padding: 1.25em 1em; +} + +.language-css > code, +.language-sass > code, +.language-scss > code { + color: #f76d47; +} + +[class*='language-'] .namespace { + opacity: 0.7; +} + +.token.atrule { + color: #7c4dff; +} + +.token.attr-name { + color: #39adb5; +} + +.token.attr-value { + color: #f6a434; +} + +.token.attribute { + color: #f6a434; +} + +.token.boolean { + color: #7c4dff; +} + +.token.builtin { + color: #39adb5; +} + +.token.cdata { + color: #39adb5; +} + +.token.char { + color: #39adb5; +} + +.token.class { + color: #39adb5; +} + +.token.class-name { + color: #6182b8; +} + +.token.comment { + color: #aabfc9; +} + +.token.constant { + color: #7c4dff; +} + +.token.deleted { + color: #e53935; +} + +.token.doctype { + color: #aabfc9; +} + +.token.entity { + color: #e53935; +} + +.token.function { + color: #7c4dff; +} + +.token.hexcode { + color: #f76d47; +} + +.token.id { + color: #7c4dff; + font-weight: bold; +} + +.token.important { + color: #7c4dff; + font-weight: bold; +} + +.token.inserted { + color: #39adb5; +} + +.token.keyword { + color: #7c4dff; +} + +.token.number { + color: #f76d47; +} + +.token.operator { + color: #39adb5; +} + +.token.prolog { + color: #aabfc9; +} + +.token.property { + color: #39adb5; +} + +.token.pseudo-class { + color: #f6a434; +} + +.token.pseudo-element { + color: #f6a434; +} + +.token.punctuation { + color: #39adb5; +} + +.token.regex { + color: #6182b8; +} + +.token.selector { + color: #e53935; +} + +.token.string { + color: #f6a434; +} + +.token.symbol { + color: #7c4dff; +} + +.token.tag { + color: #e53935; +} + +.token.unit { + color: #f76d47; +} + +.token.url { + color: #e53935; +} + +.token.variable { + color: #e53935; +} diff --git a/packages/ui/src/ui-component/extended/Avatar.js b/packages/ui/src/ui-component/extended/Avatar.js new file mode 100644 index 0000000..197a4b3 --- /dev/null +++ b/packages/ui/src/ui-component/extended/Avatar.js @@ -0,0 +1,72 @@ +import PropTypes from 'prop-types' + +// material-ui +import { useTheme } from '@mui/material/styles' +import MuiAvatar from '@mui/material/Avatar' + +// ==============================|| AVATAR ||============================== // + +const Avatar = ({ color, outline, size, sx, ...others }) => { + const theme = useTheme() + + const colorSX = color && !outline && { color: theme.palette.background.paper, bgcolor: `${color}.main` } + const outlineSX = outline && { + color: color ? `${color}.main` : `primary.main`, + bgcolor: theme.palette.background.paper, + border: '2px solid', + borderColor: color ? `${color}.main` : `primary.main` + } + let sizeSX = {} + switch (size) { + case 'badge': + sizeSX = { + width: theme.spacing(3.5), + height: theme.spacing(3.5) + } + break + case 'xs': + sizeSX = { + width: theme.spacing(4.25), + height: theme.spacing(4.25) + } + break + case 'sm': + sizeSX = { + width: theme.spacing(5), + height: theme.spacing(5) + } + break + case 'lg': + sizeSX = { + width: theme.spacing(9), + height: theme.spacing(9) + } + break + case 'xl': + sizeSX = { + width: theme.spacing(10.25), + height: theme.spacing(10.25) + } + break + case 'md': + sizeSX = { + width: theme.spacing(7.5), + height: theme.spacing(7.5) + } + break + default: + sizeSX = {} + } + + return +} + +Avatar.propTypes = { + className: PropTypes.string, + color: PropTypes.string, + outline: PropTypes.bool, + size: PropTypes.string, + sx: PropTypes.object +} + +export default Avatar diff --git a/packages/ui/src/ui-component/extended/Breadcrumbs.js b/packages/ui/src/ui-component/extended/Breadcrumbs.js new file mode 100644 index 0000000..1fca5d9 --- /dev/null +++ b/packages/ui/src/ui-component/extended/Breadcrumbs.js @@ -0,0 +1,184 @@ +import PropTypes from 'prop-types' +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' + +// material-ui +import { useTheme } from '@mui/material/styles' +import { Box, Card, Divider, Grid, Typography } from '@mui/material' +import MuiBreadcrumbs from '@mui/material/Breadcrumbs' + +// project imports +import config from 'config' +import { gridSpacing } from 'store/constant' + +// assets +import { IconTallymark1 } from '@tabler/icons' +import AccountTreeTwoToneIcon from '@mui/icons-material/AccountTreeTwoTone' +import HomeIcon from '@mui/icons-material/Home' +import HomeTwoToneIcon from '@mui/icons-material/HomeTwoTone' + +const linkSX = { + display: 'flex', + color: 'grey.900', + textDecoration: 'none', + alignContent: 'center', + alignItems: 'center' +} + +// ==============================|| BREADCRUMBS ||============================== // + +const Breadcrumbs = ({ card, divider, icon, icons, maxItems, navigation, rightAlign, separator, title, titleBottom, ...others }) => { + const theme = useTheme() + + const iconStyle = { + marginRight: theme.spacing(0.75), + marginTop: `-${theme.spacing(0.25)}`, + width: '1rem', + height: '1rem', + color: theme.palette.secondary.main + } + + const [main, setMain] = useState() + const [item, setItem] = useState() + + // set active item state + const getCollapse = (menu) => { + if (menu.children) { + menu.children.filter((collapse) => { + if (collapse.type && collapse.type === 'collapse') { + getCollapse(collapse) + } else if (collapse.type && collapse.type === 'item') { + if (document.location.pathname === config.basename + collapse.url) { + setMain(menu) + setItem(collapse) + } + } + return false + }) + } + } + + useEffect(() => { + navigation?.items?.map((menu) => { + if (menu.type && menu.type === 'group') { + getCollapse(menu) + } + return false + }) + }) + + // item separator + const SeparatorIcon = separator + const separatorIcon = separator ? : + + let mainContent + let itemContent + let breadcrumbContent = + let itemTitle = '' + let CollapseIcon + let ItemIcon + + // collapse item + if (main && main.type === 'collapse') { + CollapseIcon = main.icon ? main.icon : AccountTreeTwoToneIcon + mainContent = ( + + {icons && } + {main.title} + + ) + } + + // items + if (item && item.type === 'item') { + itemTitle = item.title + + ItemIcon = item.icon ? item.icon : AccountTreeTwoToneIcon + itemContent = ( + + {icons && } + {itemTitle} + + ) + + // main + if (item.breadcrumbs !== false) { + breadcrumbContent = ( + + + + {title && !titleBottom && ( + + + {item.title} + + + )} + + + + {icons && } + {icon && } + {!icon && 'Dashboard'} + + {mainContent} + {itemContent} + + + {title && titleBottom && ( + + + {item.title} + + + )} + + + {card === false && divider !== false && } + + ) + } + } + + return breadcrumbContent +} + +Breadcrumbs.propTypes = { + card: PropTypes.bool, + divider: PropTypes.bool, + icon: PropTypes.bool, + icons: PropTypes.bool, + maxItems: PropTypes.number, + navigation: PropTypes.object, + rightAlign: PropTypes.bool, + separator: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + title: PropTypes.bool, + titleBottom: PropTypes.bool +} + +export default Breadcrumbs diff --git a/packages/ui/src/ui-component/extended/Logo.js b/packages/ui/src/ui-component/extended/Logo.js new file mode 100644 index 0000000..e3a323c --- /dev/null +++ b/packages/ui/src/ui-component/extended/Logo.js @@ -0,0 +1,22 @@ +import logo from 'assets/images/flowise_logo.png' +import logoDark from 'assets/images/flowise_logo_dark.png' + +import { useSelector } from 'react-redux' + +// ==============================|| LOGO ||============================== // + +const Logo = () => { + const customization = useSelector((state) => state.customization) + + return ( +
+ Flowise +
+ ) +} + +export default Logo diff --git a/packages/ui/src/ui-component/extended/Transitions.js b/packages/ui/src/ui-component/extended/Transitions.js new file mode 100644 index 0000000..4942dee --- /dev/null +++ b/packages/ui/src/ui-component/extended/Transitions.js @@ -0,0 +1,107 @@ +import PropTypes from 'prop-types' +import { forwardRef } from 'react' + +// material-ui +import { Collapse, Fade, Box, Grow, Slide, Zoom } from '@mui/material' + +// ==============================|| TRANSITIONS ||============================== // + +const Transitions = forwardRef(function Transitions({ children, position, type, direction, ...others }, ref) { + let positionSX = { + transformOrigin: '0 0 0' + } + + switch (position) { + case 'top-right': + positionSX = { + transformOrigin: 'top right' + } + break + case 'top': + positionSX = { + transformOrigin: 'top' + } + break + case 'bottom-left': + positionSX = { + transformOrigin: 'bottom left' + } + break + case 'bottom-right': + positionSX = { + transformOrigin: 'bottom right' + } + break + case 'bottom': + positionSX = { + transformOrigin: 'bottom' + } + break + case 'top-left': + default: + positionSX = { + transformOrigin: '0 0 0' + } + break + } + + return ( + + {type === 'grow' && ( + + {children} + + )} + {type === 'collapse' && ( + + {children} + + )} + {type === 'fade' && ( + + {children} + + )} + {type === 'slide' && ( + + {children} + + )} + {type === 'zoom' && ( + + {children} + + )} + + ) +}) + +Transitions.propTypes = { + children: PropTypes.node, + type: PropTypes.oneOf(['grow', 'fade', 'collapse', 'slide', 'zoom']), + position: PropTypes.oneOf(['top-left', 'top-right', 'top', 'bottom-left', 'bottom-right', 'bottom']), + direction: PropTypes.oneOf(['up', 'down', 'left', 'right']) +} + +Transitions.defaultProps = { + type: 'grow', + position: 'top-left', + direction: 'up' +} + +export default Transitions diff --git a/packages/ui/src/ui-component/input/Input.js b/packages/ui/src/ui-component/input/Input.js new file mode 100644 index 0000000..41d6fae --- /dev/null +++ b/packages/ui/src/ui-component/input/Input.js @@ -0,0 +1,32 @@ +import { useState } from 'react' +import PropTypes from 'prop-types' +import { FormControl, OutlinedInput } from '@mui/material' + +export const Input = ({ inputParam, value, onChange }) => { + const [myValue, setMyValue] = useState(value ?? '') + return ( + + { + setMyValue(e.target.value) + onChange(e.target.value) + }} + /> + + ) +} + +Input.propTypes = { + inputParam: PropTypes.object, + value: PropTypes.string, + onChange: PropTypes.func +} diff --git a/packages/ui/src/ui-component/loading/Loadable.js b/packages/ui/src/ui-component/loading/Loadable.js new file mode 100644 index 0000000..462d6b8 --- /dev/null +++ b/packages/ui/src/ui-component/loading/Loadable.js @@ -0,0 +1,17 @@ +import { Suspense } from 'react' + +// project imports +import Loader from './Loader' + +// ==============================|| LOADABLE - LAZY LOADING ||============================== // + +const Loadable = (Component) => + function WithLoader(props) { + return ( + }> + + + ) + } + +export default Loadable diff --git a/packages/ui/src/ui-component/loading/Loader.js b/packages/ui/src/ui-component/loading/Loader.js new file mode 100644 index 0000000..6c29446 --- /dev/null +++ b/packages/ui/src/ui-component/loading/Loader.js @@ -0,0 +1,21 @@ +// material-ui +import LinearProgress from '@mui/material/LinearProgress' +import { styled } from '@mui/material/styles' + +// styles +const LoaderWrapper = styled('div')({ + position: 'fixed', + top: 0, + left: 0, + zIndex: 1301, + width: '100%' +}) + +// ==============================|| LOADER ||============================== // +const Loader = () => ( + + + +) + +export default Loader diff --git a/packages/ui/src/ui-component/tooltip/TooltipWithParser.js b/packages/ui/src/ui-component/tooltip/TooltipWithParser.js new file mode 100644 index 0000000..e379eb1 --- /dev/null +++ b/packages/ui/src/ui-component/tooltip/TooltipWithParser.js @@ -0,0 +1,25 @@ +import { Info } from '@mui/icons-material' +import { IconButton, Tooltip } from '@mui/material' +import parser from 'html-react-parser' +import PropTypes from 'prop-types' +import { useSelector } from 'react-redux' + +export const TooltipWithParser = ({ title }) => { + const customization = useSelector((state) => state.customization) + + return ( + +
+ + + +
+
+ ) +} + +TooltipWithParser.propTypes = { + title: PropTypes.node +} diff --git a/packages/ui/src/utils/genericHelper.js b/packages/ui/src/utils/genericHelper.js new file mode 100644 index 0000000..5278af6 --- /dev/null +++ b/packages/ui/src/utils/genericHelper.js @@ -0,0 +1,233 @@ +import moment from 'moment' + +export const getUniqueNodeId = (nodeData, nodes) => { + // Get amount of same nodes + let totalSameNodes = 0 + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i] + if (node.data.name === nodeData.name) { + totalSameNodes += 1 + } + } + + // Get unique id + let nodeId = `${nodeData.name}_${totalSameNodes}` + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i] + if (node.id === nodeId) { + totalSameNodes += 1 + nodeId = `${nodeData.name}_${totalSameNodes}` + } + } + return nodeId +} + +export const initializeNodeData = (nodeParams) => { + const initialValues = {} + + for (let i = 0; i < nodeParams.length; i += 1) { + const input = nodeParams[i] + + // Load from nodeParams default values + initialValues[input.name] = input.default || '' + + // Special case for array, always initialize the item if default is not set + if (input.type === 'array' && !input.default) { + const newObj = {} + for (let j = 0; j < input.array.length; j += 1) { + newObj[input.array[j].name] = input.array[j].default || '' + } + initialValues[input.name] = [newObj] + } + } + + return initialValues +} + +export const initNode = (nodeData, newNodeId) => { + const inputAnchors = [] + const incoming = nodeData.inputs ? nodeData.inputs.length : 0 + const outgoing = 1 + + const whitelistTypes = ['asyncOptions', 'options', 'string', 'number', 'boolean', 'password', 'json', 'code', 'date', 'file', 'folder'] + + for (let i = 0; i < incoming; i += 1) { + if (whitelistTypes.includes(nodeData.inputs[i].type)) continue + const newInput = { + ...nodeData.inputs[i], + id: `${newNodeId}-input-${nodeData.inputs[i].name}-${nodeData.inputs[i].type}` + } + inputAnchors.push(newInput) + } + + const outputAnchors = [] + for (let i = 0; i < outgoing; i += 1) { + const newOutput = { + id: `${newNodeId}-output-${nodeData.name}-${nodeData.baseClasses.join('|')}`, + name: nodeData.name, + label: nodeData.type, + type: nodeData.baseClasses.join(' | ') + } + outputAnchors.push(newOutput) + } + + nodeData.id = newNodeId + nodeData.inputAnchors = inputAnchors + nodeData.outputAnchors = outputAnchors + + /* + Initial inputs = [ + { + label: 'field_label', + name: 'field' + } + ] + + // Turn into inputs object with default values + Converted inputs = { 'field': 'defaultvalue' } + + // Move remaining inputs that are not part of inputAnchors to inputParams + inputParams = [ + { + label: 'field_label', + name: 'field' + } + ] + */ + if (nodeData.inputs) { + nodeData.inputParams = nodeData.inputs.filter(({ name }) => !nodeData.inputAnchors.some((exclude) => exclude.name === name)) + nodeData.inputs = initializeNodeData(nodeData.inputs) + } else { + nodeData.inputParams = [] + nodeData.inputs = {} + } + + return nodeData +} + +export const getEdgeLabelName = (source) => { + const sourceSplit = source.split('-') + if (sourceSplit.length && sourceSplit[0].includes('ifElse')) { + const outputAnchorsIndex = sourceSplit[sourceSplit.length - 1] + return outputAnchorsIndex === '0' ? 'true' : 'false' + } + return '' +} + +export const isValidConnection = (connection, reactFlowInstance) => { + const sourceHandle = connection.sourceHandle + const targetHandle = connection.targetHandle + const target = connection.target + + //sourceHandle: "llmChain_0-output-llmChain-BaseChain" + //targetHandle: "mrlkAgentLLM_0-input-model-BaseLanguageModel" + + const sourceTypes = sourceHandle.split('-')[sourceHandle.split('-').length - 1].split('|') + const targetTypes = targetHandle.split('-')[targetHandle.split('-').length - 1].split('|') + + if (targetTypes.some((t) => sourceTypes.includes(t))) { + let targetNode = reactFlowInstance.getNode(target) + + if (!targetNode) { + if (!reactFlowInstance.getEdges().find((e) => e.targetHandle === targetHandle)) { + return true + } + } else { + const targetNodeInputAnchor = targetNode.data.inputAnchors.find((ancr) => ancr.id === targetHandle) + if ( + (targetNodeInputAnchor && + !targetNodeInputAnchor?.list && + !reactFlowInstance.getEdges().find((e) => e.targetHandle === targetHandle)) || + targetNodeInputAnchor?.list + ) { + return true + } + } + } + + return false +} + +export const convertDateStringToDateObject = (dateString) => { + if (dateString === undefined || !dateString) return undefined + + const date = moment(dateString) + if (!date.isValid) return undefined + + // Sat Sep 24 2022 07:30:14 + return new Date(date.year(), date.month(), date.date(), date.hours(), date.minutes()) +} + +export const getFileName = (fileBase64) => { + const splitDataURI = fileBase64.split(',') + const filename = splitDataURI[splitDataURI.length - 1].split(':')[1] + return filename +} + +export const getFolderName = (base64ArrayStr) => { + try { + const base64Array = JSON.parse(base64ArrayStr) + const filenames = [] + for (let i = 0; i < base64Array.length; i += 1) { + const fileBase64 = base64Array[i] + const splitDataURI = fileBase64.split(',') + const filename = splitDataURI[splitDataURI.length - 1].split(':')[1] + filenames.push(filename) + } + return filenames.length ? filenames.join(',') : '' + } catch (e) { + return '' + } +} + +export const generateExportFlowData = (flowData) => { + const nodes = flowData.nodes + const edges = flowData.edges + + for (let i = 0; i < nodes.length; i += 1) { + nodes[i].selected = false + const node = nodes[i] + + const newNodeData = { + id: node.data.id, + label: node.data.label, + name: node.data.name, + type: node.data.type, + baseClasses: node.data.baseClasses, + category: node.data.category, + description: node.data.description, + inputParams: node.data.inputParams, + inputAnchors: node.data.inputAnchors, + inputs: {}, + outputAnchors: node.data.outputAnchors, + selected: false + } + + // Remove password + if (node.data.inputs && Object.keys(node.data.inputs).length) { + const nodeDataInputs = {} + for (const input in node.data.inputs) { + const inputParam = node.data.inputParams.find((inp) => inp.name === input) + if (inputParam && inputParam.type === 'password') continue + nodeDataInputs[input] = node.data.inputs[input] + } + newNodeData.inputs = nodeDataInputs + } + + nodes[i].data = newNodeData + } + const exportJson = { + nodes, + edges + } + return exportJson +} + +export const copyToClipboard = (e) => { + const src = e.src + if (Array.isArray(src) || typeof src === 'object') { + navigator.clipboard.writeText(JSON.stringify(src, null, ' ')) + } else { + navigator.clipboard.writeText(src) + } +} diff --git a/packages/ui/src/utils/useNotifier.js b/packages/ui/src/utils/useNotifier.js new file mode 100644 index 0000000..2ea8bf7 --- /dev/null +++ b/packages/ui/src/utils/useNotifier.js @@ -0,0 +1,56 @@ +import React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useSnackbar } from 'notistack' +import { removeSnackbar } from 'store/actions' + +let displayed = [] + +const useNotifier = () => { + const dispatch = useDispatch() + const notifier = useSelector((state) => state.notifier) + const { notifications } = notifier + + const { enqueueSnackbar, closeSnackbar } = useSnackbar() + + const storeDisplayed = (id) => { + displayed = [...displayed, id] + } + + const removeDisplayed = (id) => { + displayed = [...displayed.filter((key) => id !== key)] + } + + React.useEffect(() => { + notifications.forEach(({ key, message, options = {}, dismissed = false }) => { + if (dismissed) { + // dismiss snackbar using notistack + closeSnackbar(key) + return + } + + // do nothing if snackbar is already displayed + if (displayed.includes(key)) return + + // display snackbar using notistack + enqueueSnackbar(message, { + key, + ...options, + onClose: (event, reason, myKey) => { + if (options.onClose) { + options.onClose(event, reason, myKey) + } + }, + onExited: (event, myKey) => { + // remove this snackbar from redux store + dispatch(removeSnackbar(myKey)) + removeDisplayed(myKey) + } + }) + + // keep track of snackbars that we've displayed + storeDisplayed(key) + }) + }, [notifications, closeSnackbar, enqueueSnackbar, dispatch]) +} + +export default useNotifier diff --git a/packages/ui/src/utils/usePrompt.js b/packages/ui/src/utils/usePrompt.js new file mode 100644 index 0000000..4108556 --- /dev/null +++ b/packages/ui/src/utils/usePrompt.js @@ -0,0 +1,37 @@ +import { useCallback, useContext, useEffect } from 'react' +import { UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom' + +// https://stackoverflow.com/questions/71572678/react-router-v-6-useprompt-typescript + +export function useBlocker(blocker, when = true) { + const { navigator } = useContext(NavigationContext) + + useEffect(() => { + if (!when) return + + const unblock = navigator.block((tx) => { + const autoUnblockingTx = { + ...tx, + retry() { + unblock() + tx.retry() + } + } + + blocker(autoUnblockingTx) + }) + + return unblock + }, [navigator, blocker, when]) +} + +export function usePrompt(message, when = true) { + const blocker = useCallback( + (tx) => { + if (window.confirm(message)) tx.retry() + }, + [message] + ) + + useBlocker(blocker, when) +} diff --git a/packages/ui/src/views/canvas/AddNodes.js b/packages/ui/src/views/canvas/AddNodes.js new file mode 100644 index 0000000..968b2c0 --- /dev/null +++ b/packages/ui/src/views/canvas/AddNodes.js @@ -0,0 +1,296 @@ +import { useState, useRef, useEffect } from 'react' +import { useSelector } from 'react-redux' +import PropTypes from 'prop-types' + +// material-ui +import { useTheme } from '@mui/material/styles' +import { + Accordion, + AccordionSummary, + AccordionDetails, + Box, + ClickAwayListener, + Divider, + InputAdornment, + List, + ListItemButton, + ListItem, + ListItemAvatar, + ListItemText, + OutlinedInput, + Paper, + Popper, + Stack, + Typography +} from '@mui/material' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' + +// third-party +import PerfectScrollbar from 'react-perfect-scrollbar' + +// project imports +import MainCard from 'ui-component/cards/MainCard' +import Transitions from 'ui-component/extended/Transitions' +import { StyledFab } from 'ui-component/button/StyledFab' + +// icons +import { IconPlus, IconSearch, IconMinus } from '@tabler/icons' + +// const +import { baseURL } from 'store/constant' + +// ==============================|| ADD NODES||============================== // + +const AddNodes = ({ nodesData, node }) => { + const theme = useTheme() + const customization = useSelector((state) => state.customization) + + const [searchValue, setSearchValue] = useState('') + const [nodes, setNodes] = useState({}) + const [open, setOpen] = useState(false) + const [categoryExpanded, setCategoryExpanded] = useState({}) + + const anchorRef = useRef(null) + const prevOpen = useRef(open) + const ps = useRef() + + const scrollTop = () => { + const curr = ps.current + if (curr) { + curr.scrollTop = 0 + } + } + + const filterSearch = (value) => { + setSearchValue(value) + setTimeout(() => { + if (value) { + const returnData = nodesData.filter((nd) => nd.name.toLowerCase().includes(value.toLowerCase())) + groupByCategory(returnData, true) + scrollTop() + } else if (value === '') { + groupByCategory(nodesData) + scrollTop() + } + }, 500) + } + + const groupByCategory = (nodes, isFilter) => { + const accordianCategories = {} + const result = nodes.reduce(function (r, a) { + r[a.category] = r[a.category] || [] + r[a.category].push(a) + accordianCategories[a.category] = isFilter ? true : false + return r + }, Object.create(null)) + setNodes(result) + setCategoryExpanded(accordianCategories) + } + + const handleAccordionChange = (category) => (event, isExpanded) => { + const accordianCategories = { ...categoryExpanded } + accordianCategories[category] = isExpanded + setCategoryExpanded(accordianCategories) + } + + const handleClose = (event) => { + if (anchorRef.current && anchorRef.current.contains(event.target)) { + return + } + setOpen(false) + } + + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen) + } + + const onDragStart = (event, node) => { + event.dataTransfer.setData('application/reactflow', JSON.stringify(node)) + event.dataTransfer.effectAllowed = 'move' + } + + useEffect(() => { + if (prevOpen.current === true && open === false) { + anchorRef.current.focus() + } + + prevOpen.current = open + }, [open]) + + useEffect(() => { + if (node) setOpen(false) + }, [node]) + + useEffect(() => { + if (nodesData) groupByCategory(nodesData) + }, [nodesData]) + + return ( + <> + + {open ? : } + + + {({ TransitionProps }) => ( + + + + + + + Add Nodes + + filterSearch(e.target.value)} + placeholder='Search nodes' + startAdornment={ + + + + } + aria-describedby='search-helper-text' + inputProps={{ + 'aria-label': 'weight' + }} + /> + + + { + ps.current = el + }} + style={{ height: '100%', maxHeight: 'calc(100vh - 320px)', overflowX: 'hidden' }} + > + + + {Object.keys(nodes) + .sort() + .map((category) => ( + + } + aria-controls={`nodes-accordian-${category}`} + id={`nodes-accordian-header-${category}`} + > + {category} + + + {nodes[category].map((node, index) => ( +
onDragStart(event, node)} + draggable + > + + + +
+ {node.name} +
+
+ +
+
+ {index === nodes[category].length - 1 ? null : } +
+ ))} +
+
+ ))} +
+
+
+
+
+
+
+ )} +
+ + ) +} + +AddNodes.propTypes = { + nodesData: PropTypes.array, + node: PropTypes.object +} + +export default AddNodes diff --git a/packages/ui/src/views/canvas/ButtonEdge.js b/packages/ui/src/views/canvas/ButtonEdge.js new file mode 100644 index 0000000..827d51e --- /dev/null +++ b/packages/ui/src/views/canvas/ButtonEdge.js @@ -0,0 +1,72 @@ +import { getBezierPath, EdgeText } from 'reactflow' +import PropTypes from 'prop-types' +import { useDispatch } from 'react-redux' +import { REMOVE_EDGE } from 'store/actions' + +import './index.css' + +const foreignObjectSize = 40 + +const ButtonEdge = ({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style = {}, data, markerEnd }) => { + const [edgePath, edgeCenterX, edgeCenterY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition + }) + + const dispatch = useDispatch() + + const onEdgeClick = (evt, id) => { + evt.stopPropagation() + dispatch({ type: REMOVE_EDGE, edgeId: `${id}:${Date.now()}` }) + } + + return ( + <> + + {data && data.label && ( + + )} + +
+ +
+
+ + ) +} + +ButtonEdge.propTypes = { + id: PropTypes.string, + sourceX: PropTypes.number, + sourceY: PropTypes.number, + targetX: PropTypes.number, + targetY: PropTypes.number, + sourcePosition: PropTypes.any, + targetPosition: PropTypes.any, + style: PropTypes.object, + data: PropTypes.object, + markerEnd: PropTypes.any +} + +export default ButtonEdge diff --git a/packages/ui/src/views/canvas/CanvasHeader.js b/packages/ui/src/views/canvas/CanvasHeader.js new file mode 100644 index 0000000..9a5654e --- /dev/null +++ b/packages/ui/src/views/canvas/CanvasHeader.js @@ -0,0 +1,291 @@ +import PropTypes from 'prop-types' +import { useNavigate } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { useEffect, useRef, useState } from 'react' + +// material-ui +import { useTheme } from '@mui/material/styles' +import { Avatar, Box, ButtonBase, Typography, Stack, TextField } from '@mui/material' + +// icons +import { IconSettings, IconChevronLeft, IconDeviceFloppy, IconPencil, IconCheck, IconX } from '@tabler/icons' + +// project imports +import Settings from 'views/settings' +import SaveChatflowDialog from 'ui-component/dialog/SaveChatflowDialog' + +// API +import chatflowsApi from 'api/chatflows' + +// Hooks +import useApi from 'hooks/useApi' + +// utils +import { generateExportFlowData } from 'utils/genericHelper' + +// ==============================|| CANVAS HEADER ||============================== // + +const CanvasHeader = ({ chatflow, handleSaveFlow, handleDeleteFlow, handleLoadFlow }) => { + const theme = useTheme() + const navigate = useNavigate() + const flowNameRef = useRef() + const settingsRef = useRef() + + const [isEditingFlowName, setEditingFlowName] = useState(null) + const [flowName, setFlowName] = useState('') + const [isSettingsOpen, setSettingsOpen] = useState(false) + const [flowDialogOpen, setFlowDialogOpen] = useState(false) + + const updateChatflowApi = useApi(chatflowsApi.updateChatflow) + const canvas = useSelector((state) => state.canvas) + + const onSettingsItemClick = (setting) => { + setSettingsOpen(false) + + if (setting === 'deleteChatflow') { + handleDeleteFlow() + } else if (setting === 'exportChatflow') { + try { + const flowData = JSON.parse(chatflow.flowData) + let dataStr = JSON.stringify(generateExportFlowData(flowData)) + let dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr) + + let exportFileDefaultName = `${chatflow.name} Chatflow.json` + + let linkElement = document.createElement('a') + linkElement.setAttribute('href', dataUri) + linkElement.setAttribute('download', exportFileDefaultName) + linkElement.click() + } catch (e) { + console.error(e) + } + } + } + + const onUploadFile = (file) => { + setSettingsOpen(false) + handleLoadFlow(file) + } + + const submitFlowName = () => { + if (chatflow.id) { + const updateBody = { + name: flowNameRef.current.value + } + updateChatflowApi.request(chatflow.id, updateBody) + } + } + + const onSaveChatflowClick = () => { + if (chatflow.id) handleSaveFlow(chatflow.name) + else setFlowDialogOpen(true) + } + + const onConfirmSaveName = (flowName) => { + setFlowDialogOpen(false) + handleSaveFlow(flowName) + } + + useEffect(() => { + if (updateChatflowApi.data) { + setFlowName(updateChatflowApi.data.name) + } + setEditingFlowName(false) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [updateChatflowApi.data]) + + useEffect(() => { + if (chatflow) { + setFlowName(chatflow.name) + } + }, [chatflow]) + + return ( + <> + + + navigate(-1)} + > + + + + + + {!isEditingFlowName && ( + + + {canvas.isDirty && *} {flowName} + + {chatflow?.id && ( + + setEditingFlowName(true)} + > + + + + )} + + )} + {isEditingFlowName && ( + + + + + + + + + setEditingFlowName(false)} + > + + + + + )} + + + + + + + + + setSettingsOpen(!isSettingsOpen)} + > + + + + + setSettingsOpen(false)} + onSettingsItemClick={onSettingsItemClick} + onUploadFile={onUploadFile} + /> + setFlowDialogOpen(false)} + onConfirm={onConfirmSaveName} + /> + + ) +} + +CanvasHeader.propTypes = { + chatflow: PropTypes.object, + handleSaveFlow: PropTypes.func, + handleDeleteFlow: PropTypes.func, + handleLoadFlow: PropTypes.func +} + +export default CanvasHeader diff --git a/packages/ui/src/views/canvas/CanvasNode.js b/packages/ui/src/views/canvas/CanvasNode.js new file mode 100644 index 0000000..a264c92 --- /dev/null +++ b/packages/ui/src/views/canvas/CanvasNode.js @@ -0,0 +1,136 @@ +import PropTypes from 'prop-types' +import { useContext } from 'react' + +// material-ui +import { styled, useTheme } from '@mui/material/styles' +import { IconButton, Box, Typography, Divider } from '@mui/material' + +// project imports +import MainCard from 'ui-component/cards/MainCard' +import NodeInputHandler from './NodeInputHandler' +import NodeOutputHandler from './NodeOutputHandler' + +// const +import { baseURL } from 'store/constant' +import { IconTrash } from '@tabler/icons' +import { flowContext } from 'store/context/ReactFlowContext' + +const CardWrapper = styled(MainCard)(({ theme }) => ({ + background: theme.palette.card.main, + color: theme.darkTextPrimary, + border: 'solid 1px', + borderColor: theme.palette.primary[200] + 75, + width: '300px', + height: 'auto', + padding: '10px', + boxShadow: '0 2px 14px 0 rgb(32 40 45 / 8%)', + '&:hover': { + borderColor: theme.palette.primary.main + } +})) + +// ===========================|| CANVAS NODE ||=========================== // + +const CanvasNode = ({ data }) => { + const theme = useTheme() + const { deleteNode } = useContext(flowContext) + + return ( + <> + + +
+ +
+ Notification +
+
+ + + {data.label} + + +
+ { + deleteNode(data.id) + }} + sx={{ height: 35, width: 35, mr: 1 }} + > + + +
+ {(data.inputAnchors.length > 0 || data.inputParams.length > 0) && ( + <> + + + + Inputs + + + + + )} + {data.inputAnchors.map((inputAnchor, index) => ( + + ))} + {data.inputParams.map((inputParam, index) => ( + + ))} + + + + + Output + + + + + {data.outputAnchors.map((outputAnchor, index) => ( + + ))} +
+
+ + ) +} + +CanvasNode.propTypes = { + data: PropTypes.object +} + +export default CanvasNode diff --git a/packages/ui/src/views/canvas/NodeInputHandler.js b/packages/ui/src/views/canvas/NodeInputHandler.js new file mode 100644 index 0000000..798ff5d --- /dev/null +++ b/packages/ui/src/views/canvas/NodeInputHandler.js @@ -0,0 +1,104 @@ +import PropTypes from 'prop-types' +import { Handle, Position, useUpdateNodeInternals } from 'reactflow' +import { useEffect, useRef, useState, useContext } from 'react' + +// material-ui +import { useTheme } from '@mui/material/styles' +import { Box, Typography, Tooltip } from '@mui/material' + +import { Dropdown } from 'ui-component/dropdown/Dropdown' +import { Input } from 'ui-component/input/Input' +import { flowContext } from 'store/context/ReactFlowContext' +import { isValidConnection } from 'utils/genericHelper' + +// ===========================|| NodeInputHandler ||=========================== // + +const NodeInputHandler = ({ inputAnchor, inputParam, data }) => { + const theme = useTheme() + const ref = useRef(null) + const updateNodeInternals = useUpdateNodeInternals() + const [position, setPosition] = useState(0) + const { reactFlowInstance } = useContext(flowContext) + + useEffect(() => { + if (ref.current && ref.current.offsetTop && ref.current.clientHeight) { + setPosition(ref.current.offsetTop + ref.current.clientHeight / 2) + updateNodeInternals(data.id) + } + }, [data.id, ref, updateNodeInternals]) + + useEffect(() => { + updateNodeInternals(data.id) + }, [data.id, position, updateNodeInternals]) + + return ( +
+ {inputAnchor && ( + <> + + {'Type: ' + inputAnchor.type} + + } + > + isValidConnection(connection, reactFlowInstance)} + style={{ + height: 10, + width: 10, + backgroundColor: data.selected ? theme.palette.primary.main : theme.palette.text.secondary, + top: position + }} + /> + + + + {inputAnchor.label} + {!inputAnchor.optional &&  *} + + + + )} + + {inputParam && ( + <> + + + {inputParam.label} + {!inputParam.optional &&  *} + + {(inputParam.type === 'string' || inputParam.type === 'password' || inputParam.type === 'number') && ( + (data.inputs[inputParam.name] = newValue)} + value={data.inputs[inputParam.name] ?? inputParam.default ?? ''} + /> + )} + {inputParam.type === 'options' && ( + (data.inputs[inputParam.name] = newValue)} + value={data.inputs[inputParam.name] ?? inputParam.default ?? 'chose an option'} + /> + )} + + + )} +
+ ) +} + +NodeInputHandler.propTypes = { + inputAnchor: PropTypes.object, + inputParam: PropTypes.object, + data: PropTypes.object +} + +export default NodeInputHandler diff --git a/packages/ui/src/views/canvas/NodeOutputHandler.js b/packages/ui/src/views/canvas/NodeOutputHandler.js new file mode 100644 index 0000000..5f4d294 --- /dev/null +++ b/packages/ui/src/views/canvas/NodeOutputHandler.js @@ -0,0 +1,71 @@ +import PropTypes from 'prop-types' +import { Handle, Position, useUpdateNodeInternals } from 'reactflow' +import { useEffect, useRef, useState, useContext } from 'react' + +// material-ui +import { useTheme } from '@mui/material/styles' +import { Box, Typography, Tooltip } from '@mui/material' +import { flowContext } from 'store/context/ReactFlowContext' +import { isValidConnection } from 'utils/genericHelper' + +// ===========================|| NodeOutputHandler ||=========================== // + +const NodeOutputHandler = ({ outputAnchor, data }) => { + const theme = useTheme() + const ref = useRef(null) + const updateNodeInternals = useUpdateNodeInternals() + const [position, setPosition] = useState(0) + const { reactFlowInstance } = useContext(flowContext) + + useEffect(() => { + if (ref.current && ref.current?.offsetTop && ref.current?.clientHeight) { + setTimeout(() => { + setPosition(ref.current?.offsetTop + ref.current?.clientHeight / 2) + updateNodeInternals(data.id) + }, 0) + } + }, [data.id, ref, updateNodeInternals]) + + useEffect(() => { + setTimeout(() => { + updateNodeInternals(data.id) + }, 0) + }, [data.id, position, updateNodeInternals]) + + return ( +
+ + {'Type: ' + outputAnchor.type} + + } + > + isValidConnection(connection, reactFlowInstance)} + style={{ + height: 10, + width: 10, + backgroundColor: data.selected ? theme.palette.primary.main : theme.palette.text.secondary, + top: position + }} + /> + + + {outputAnchor.label} + +
+ ) +} + +NodeOutputHandler.propTypes = { + outputAnchor: PropTypes.object, + data: PropTypes.object +} + +export default NodeOutputHandler diff --git a/packages/ui/src/views/canvas/index.css b/packages/ui/src/views/canvas/index.css new file mode 100644 index 0000000..851dee5 --- /dev/null +++ b/packages/ui/src/views/canvas/index.css @@ -0,0 +1,37 @@ +.edgebutton { + width: 20px; + height: 20px; + background: #eee; + border: 1px solid #fff; + cursor: pointer; + border-radius: 50%; + font-size: 12px; + line-height: 1; +} + +.edgebutton:hover { + background: #5e35b1; + color: #eee; + box-shadow: 0 0 6px 2px rgba(0, 0, 0, 0.08); +} + +.edgebutton-foreignobject div { + background: transparent; + width: 40px; + height: 40px; + display: flex; + justify-content: center; + align-items: center; + min-height: 40px; +} + +.reactflow-parent-wrapper { + display: flex; + flex-grow: 1; + height: 100%; +} + +.reactflow-parent-wrapper .reactflow-wrapper { + flex-grow: 1; + height: 100%; +} diff --git a/packages/ui/src/views/canvas/index.js b/packages/ui/src/views/canvas/index.js new file mode 100644 index 0000000..934ae23 --- /dev/null +++ b/packages/ui/src/views/canvas/index.js @@ -0,0 +1,515 @@ +import { useEffect, useRef, useState, useCallback, useContext } from 'react' +import ReactFlow, { addEdge, Controls, Background, useNodesState, useEdgesState } from 'reactflow' +import 'reactflow/dist/style.css' + +import { useDispatch, useSelector } from 'react-redux' +import { useNavigate } from 'react-router-dom' +import { usePrompt } from '../../utils/usePrompt' +import { + REMOVE_DIRTY, + SET_DIRTY, + SET_CHATFLOW, + enqueueSnackbar as enqueueSnackbarAction, + closeSnackbar as closeSnackbarAction +} from 'store/actions' + +// material-ui +import { Toolbar, Box, AppBar, Button } from '@mui/material' +import { useTheme } from '@mui/material/styles' + +// project imports +import CanvasNode from './CanvasNode' +import ButtonEdge from './ButtonEdge' +import CanvasHeader from './CanvasHeader' +import AddNodes from './AddNodes' +import ConfirmDialog from 'ui-component/dialog/ConfirmDialog' +import { ChatMessage } from 'views/chatmessage/ChatMessage' +import { flowContext } from 'store/context/ReactFlowContext' + +// API +import nodesApi from 'api/nodes' +import chatflowsApi from 'api/chatflows' + +// Hooks +import useApi from 'hooks/useApi' +import useConfirm from 'hooks/useConfirm' + +// icons +import { IconX } from '@tabler/icons' + +// utils +import { getUniqueNodeId, initNode, getEdgeLabelName } from 'utils/genericHelper' +import useNotifier from 'utils/useNotifier' + +const nodeTypes = { customNode: CanvasNode } +const edgeTypes = { buttonedge: ButtonEdge } + +// ==============================|| CANVAS ||============================== // + +const Canvas = () => { + const theme = useTheme() + const navigate = useNavigate() + + const URLpath = document.location.pathname.toString().split('/') + const chatflowId = URLpath[URLpath.length - 1] === 'canvas' ? '' : URLpath[URLpath.length - 1] + + const { confirm } = useConfirm() + + const dispatch = useDispatch() + const canvas = useSelector((state) => state.canvas) + const [canvasDataStore, setCanvasDataStore] = useState(canvas) + const [chatflow, setChatflow] = useState(null) + const { reactFlowInstance, setReactFlowInstance } = useContext(flowContext) + + // ==============================|| Snackbar ||============================== // + + useNotifier() + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) + + // ==============================|| ReactFlow ||============================== // + + const [nodes, setNodes, onNodesChange] = useNodesState() + const [edges, setEdges, onEdgesChange] = useEdgesState() + + const [selectedNode, setSelectedNode] = useState(null) + + const reactFlowWrapper = useRef(null) + + // ==============================|| Chatflow API ||============================== // + + const getNodesApi = useApi(nodesApi.getAllNodes) + const createNewChatflowApi = useApi(chatflowsApi.createNewChatflow) + const testChatflowApi = useApi(chatflowsApi.testChatflow) + const updateChatflowApi = useApi(chatflowsApi.updateChatflow) + const getSpecificChatflowApi = useApi(chatflowsApi.getSpecificChatflow) + + // ==============================|| Events & Actions ||============================== // + + const onConnect = (params) => { + const newEdge = { + ...params, + type: 'buttonedge', + id: `${params.source}-${params.sourceHandle}-${params.target}-${params.targetHandle}`, + data: { label: getEdgeLabelName(params.sourceHandle) } + } + + const targetNodeId = params.targetHandle.split('-')[0] + const sourceNodeId = params.sourceHandle.split('-')[0] + const targetInput = params.targetHandle.split('-')[2] + + setNodes((nds) => + nds.map((node) => { + if (node.id === targetNodeId) { + setTimeout(() => setDirty(), 0) + let value + const inputAnchor = node.data.inputAnchors.find((ancr) => ancr.name === targetInput) + if (inputAnchor && inputAnchor.list) { + const newValues = node.data.inputs[targetInput] || [] + newValues.push(`{{${sourceNodeId}.data.instance}}`) + value = newValues + } else { + value = `{{${sourceNodeId}.data.instance}}` + } + node.data = { + ...node.data, + inputs: { + ...node.data.inputs, + [targetInput]: value + } + } + } + return node + }) + ) + + setEdges((eds) => addEdge(newEdge, eds)) + setDirty() + } + + const handleLoadFlow = (file) => { + try { + const flowData = JSON.parse(file) + const nodes = flowData.nodes || [] + + setNodes(nodes) + setEdges(flowData.edges || []) + setDirty() + } catch (e) { + console.error(e) + } + } + + const handleDeleteFlow = async () => { + const confirmPayload = { + title: `Delete`, + description: `Delete chatflow ${chatflow.name}?`, + confirmButtonName: 'Delete', + cancelButtonName: 'Cancel' + } + const isConfirmed = await confirm(confirmPayload) + + if (isConfirmed) { + try { + await chatflowsApi.deleteChatflow(chatflow.id) + navigate(-1) + } catch (error) { + const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` + enqueueSnackbar({ + message: errorData, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + } + } + } + + const handleSaveFlow = (chatflowName) => { + if (reactFlowInstance) { + setNodes((nds) => + nds.map((node) => { + node.data = { + ...node.data, + selected: false + } + return node + }) + ) + + const rfInstanceObject = reactFlowInstance.toObject() + const flowData = JSON.stringify(rfInstanceObject) + + if (!chatflow.id) { + const newChatflowBody = { + name: chatflowName, + deployed: false, + flowData + } + createNewChatflowApi.request(newChatflowBody) + } else { + const updateBody = { + name: chatflowName, + flowData + } + updateChatflowApi.request(chatflow.id, updateBody) + } + } + } + + // eslint-disable-next-line + const onNodeClick = useCallback((event, clickedNode) => { + setSelectedNode(clickedNode) + setNodes((nds) => + nds.map((node) => { + if (node.id === clickedNode.id) { + node.data = { + ...node.data, + selected: true + } + } else { + node.data = { + ...node.data, + selected: false + } + } + + return node + }) + ) + }) + + const onDragOver = useCallback((event) => { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + }, []) + + const onDrop = useCallback( + (event) => { + event.preventDefault() + const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect() + let nodeData = event.dataTransfer.getData('application/reactflow') + + // check if the dropped element is valid + if (typeof nodeData === 'undefined' || !nodeData) { + return + } + + nodeData = JSON.parse(nodeData) + + const position = reactFlowInstance.project({ + x: event.clientX - reactFlowBounds.left - 100, + y: event.clientY - reactFlowBounds.top - 50 + }) + + const newNodeId = getUniqueNodeId(nodeData, reactFlowInstance.getNodes()) + + const newNode = { + id: newNodeId, + position, + type: 'customNode', + data: initNode(nodeData, newNodeId) + } + + setSelectedNode(newNode) + setNodes((nds) => + nds.concat(newNode).map((node) => { + if (node.id === newNode.id) { + node.data = { + ...node.data, + selected: true + } + } else { + node.data = { + ...node.data, + selected: false + } + } + + return node + }) + ) + setTimeout(() => setDirty(), 0) + }, + + // eslint-disable-next-line + [reactFlowInstance] + ) + + const saveChatflowSuccess = () => { + dispatch({ type: REMOVE_DIRTY }) + enqueueSnackbar({ + message: 'Chatflow saved', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + } + + const errorFailed = (message) => { + enqueueSnackbar({ + message, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + } + + const setDirty = () => { + dispatch({ type: SET_DIRTY }) + } + + // ==============================|| useEffect ||============================== // + + // Get specific chatflow successful + useEffect(() => { + if (getSpecificChatflowApi.data) { + const chatflow = getSpecificChatflowApi.data + const initialFlow = chatflow.flowData ? JSON.parse(chatflow.flowData) : [] + setNodes(initialFlow.nodes || []) + setEdges(initialFlow.edges || []) + dispatch({ type: SET_CHATFLOW, chatflow }) + } else if (getSpecificChatflowApi.error) { + const error = getSpecificChatflowApi.error + const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` + errorFailed(`Failed to retrieve chatflow: ${errorData}`) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getSpecificChatflowApi.data, getSpecificChatflowApi.error]) + + // Create new chatflow successful + useEffect(() => { + if (createNewChatflowApi.data) { + const chatflow = createNewChatflowApi.data + dispatch({ type: SET_CHATFLOW, chatflow }) + saveChatflowSuccess() + window.history.replaceState(null, null, `/canvas/${chatflow.id}`) + } else if (createNewChatflowApi.error) { + const error = createNewChatflowApi.error + const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` + errorFailed(`Failed to save chatflow: ${errorData}`) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [createNewChatflowApi.data, createNewChatflowApi.error]) + + // Update chatflow successful + useEffect(() => { + if (updateChatflowApi.data) { + dispatch({ type: SET_CHATFLOW, chatflow: updateChatflowApi.data }) + saveChatflowSuccess() + } else if (updateChatflowApi.error) { + const error = updateChatflowApi.error + const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` + errorFailed(`Failed to save chatflow: ${errorData}`) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [updateChatflowApi.data, updateChatflowApi.error]) + + // Test chatflow failed + useEffect(() => { + if (testChatflowApi.error) { + enqueueSnackbar({ + message: 'Test chatflow failed', + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [testChatflowApi.error]) + + // Listen to edge button click remove redux event + useEffect(() => { + if (reactFlowInstance) { + const edges = reactFlowInstance.getEdges() + const toRemoveEdgeId = canvasDataStore.removeEdgeId.split(':')[0] + setEdges(edges.filter((edge) => edge.id !== toRemoveEdgeId)) + setDirty() + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [canvasDataStore.removeEdgeId]) + + useEffect(() => setChatflow(canvasDataStore.chatflow), [canvasDataStore.chatflow]) + + // Initialization + useEffect(() => { + if (chatflowId) { + getSpecificChatflowApi.request(chatflowId) + } else { + setNodes([]) + setEdges([]) + dispatch({ + type: SET_CHATFLOW, + chatflow: { + name: 'Untitled chatflow' + } + }) + } + + getNodesApi.request() + + // Clear dirty state before leaving and remove any ongoing test triggers and webhooks + return () => { + setTimeout(() => dispatch({ type: REMOVE_DIRTY }), 0) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + setCanvasDataStore(canvas) + }, [canvas]) + + useEffect(() => { + function handlePaste(e) { + const pasteData = e.clipboardData.getData('text') + //TODO: prevent paste event when input focused, temporary fix: catch chatflow syntax + if (pasteData.includes('{"nodes":[') && pasteData.includes('],"edges":[')) { + handleLoadFlow(pasteData) + } + } + + window.addEventListener('paste', handlePaste) + + return () => { + window.removeEventListener('paste', handlePaste) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + usePrompt('You have unsaved changes! Do you want to navigate away?', canvasDataStore.isDirty) + + return ( + <> + + + + + + + +
+
+ + + + + + +
+
+
+ +
+ + ) +} + +export default Canvas diff --git a/packages/ui/src/views/chatflows/index.js b/packages/ui/src/views/chatflows/index.js new file mode 100644 index 0000000..f9d3757 --- /dev/null +++ b/packages/ui/src/views/chatflows/index.js @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useSelector } from 'react-redux' + +// material-ui +import { Grid, Box, Stack } from '@mui/material' +import { useTheme } from '@mui/material/styles' + +// project imports +import MainCard from 'ui-component/cards/MainCard' +import ItemCard from 'ui-component/cards/ItemCard' +import { gridSpacing } from 'store/constant' +import WorkflowEmptySVG from 'assets/images/workflow_empty.svg' +import { StyledButton } from 'ui-component/button/StyledButton' + +// API +import chatflowsApi from 'api/chatflows' + +// Hooks +import useApi from 'hooks/useApi' + +// const +import { baseURL } from 'store/constant' + +// ==============================|| CHATFLOWS ||============================== // + +const Chatflows = () => { + const navigate = useNavigate() + const theme = useTheme() + const customization = useSelector((state) => state.customization) + + const [isLoading, setLoading] = useState(true) + const [images, setImages] = useState({}) + + const getAllChatflowsApi = useApi(chatflowsApi.getAllChatflows) + + const addNew = () => { + navigate('/canvas') + } + + const goToCanvas = (selectedChatflow) => { + navigate(`/canvas/${selectedChatflow.id}`) + } + + useEffect(() => { + getAllChatflowsApi.request() + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + setLoading(getAllChatflowsApi.loading) + }, [getAllChatflowsApi.loading]) + + useEffect(() => { + if (getAllChatflowsApi.data) { + try { + const chatflows = getAllChatflowsApi.data + const images = {} + for (let i = 0; i < chatflows.length; i += 1) { + const flowDataStr = chatflows[i].flowData + const flowData = JSON.parse(flowDataStr) + const nodes = flowData.nodes || [] + images[chatflows[i].id] = [] + for (let j = 0; j < nodes.length; j += 1) { + const imageSrc = `${baseURL}/api/v1/node-icon/${nodes[j].data.name}` + if (!images[chatflows[i].id].includes(imageSrc)) { + images[chatflows[i].id].push(imageSrc) + } + } + } + setImages(images) + } catch (e) { + console.error(e) + } + } + }, [getAllChatflowsApi.data]) + + return ( + + +

Chatflows

+ + + + + Add New + + + +
+ + {!isLoading && + getAllChatflowsApi.data && + getAllChatflowsApi.data.map((data, index) => ( + + goToCanvas(data)} data={data} images={images[data.id]} /> + + ))} + + {!isLoading && (!getAllChatflowsApi.data || getAllChatflowsApi.data.length === 0) && ( + + + WorkflowEmptySVG + +
No Chatflows Yet
+
+ )} +
+ ) +} + +export default Chatflows diff --git a/packages/ui/src/views/chatmessage/ChatMessage.css b/packages/ui/src/views/chatmessage/ChatMessage.css new file mode 100644 index 0000000..4aa651b --- /dev/null +++ b/packages/ui/src/views/chatmessage/ChatMessage.css @@ -0,0 +1,127 @@ +.cloudform { + position: relative; +} + +.messagelist { + width: 100%; + height: 100%; + overflow-y: scroll; + border-radius: 0.5rem; +} + +.messagelistloading { + display: flex; + width: 100%; + justify-content: center; + margin-top: 1rem; +} + +.usermessage { + padding: 1rem 1.5rem 1rem 1.5rem; +} + +.usermessagewaiting-light { + padding: 1rem 1.5rem 1rem 1.5rem; + background: linear-gradient(to left, #ede7f6, #e3f2fd, #ede7f6); + background-size: 200% 200%; + background-position: -100% 0; + animation: loading-gradient 2s ease-in-out infinite; + animation-direction: alternate; + animation-name: loading-gradient; +} + +.usermessagewaiting-dark { + padding: 1rem 1.5rem 1rem 1.5rem; + color: #ececf1; + background: linear-gradient(to left, #2e2352, #1d3d60, #2e2352); + background-size: 200% 200%; + background-position: -100% 0; + animation: loading-gradient 2s ease-in-out infinite; + animation-direction: alternate; + animation-name: loading-gradient; +} + +@keyframes loading-gradient { + 0% { + background-position: -100% 0; + } + 100% { + background-position: 100% 0; + } +} + +.apimessage { + padding: 1rem 1.5rem 1rem 1.5rem; + animation: fadein 0.5s; +} + +@keyframes fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.apimessage, +.usermessage, +.usermessagewaiting { + display: flex; +} + +.markdownanswer { + line-height: 1.75; +} + +.markdownanswer a:hover { + opacity: 0.8; +} + +.markdownanswer a { + color: #16bed7; + font-weight: 500; +} + +.markdownanswer code { + color: #15cb19; + font-weight: 500; + white-space: pre-wrap !important; +} + +.markdownanswer ol, +.markdownanswer ul { + margin: 1rem; +} + +.boticon, +.usericon { + margin-right: 1rem; + border-radius: 1rem; +} + +.markdownanswer h1, +.markdownanswer h2, +.markdownanswer h3 { + font-size: inherit; +} + +.center { + display: flex; + justify-content: center; + align-items: center; + position: relative; + flex-direction: column; + padding: 10px; + max-width: 500px; +} + +.cloud { + width: '100%'; + max-width: 500px; + height: 73vh; + border-radius: 0.5rem; + display: flex; + justify-content: center; + align-items: center; +} diff --git a/packages/ui/src/views/chatmessage/ChatMessage.js b/packages/ui/src/views/chatmessage/ChatMessage.js new file mode 100644 index 0000000..fd7c5ab --- /dev/null +++ b/packages/ui/src/views/chatmessage/ChatMessage.js @@ -0,0 +1,395 @@ +import { useState, useRef, useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import ReactMarkdown from 'react-markdown' +import PropTypes from 'prop-types' +import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from 'store/actions' + +import { + ClickAwayListener, + Paper, + Popper, + CircularProgress, + OutlinedInput, + Divider, + InputAdornment, + IconButton, + Box, + Button +} from '@mui/material' +import { useTheme } from '@mui/material/styles' +import { IconMessage, IconX, IconSend, IconEraser } from '@tabler/icons' + +// project import +import { StyledFab } from 'ui-component/button/StyledFab' +import MainCard from 'ui-component/cards/MainCard' +import Transitions from 'ui-component/extended/Transitions' +import './ChatMessage.css' + +// api +import chatmessageApi from 'api/chatmessage' +import predictionApi from 'api/prediction' + +// Hooks +import useApi from 'hooks/useApi' +import useConfirm from 'hooks/useConfirm' +import useNotifier from 'utils/useNotifier' + +export const ChatMessage = ({ chatflowid }) => { + const theme = useTheme() + const customization = useSelector((state) => state.customization) + const { confirm } = useConfirm() + const dispatch = useDispatch() + + useNotifier() + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) + + const [open, setOpen] = useState(false) + const [userInput, setUserInput] = useState('') + const [history, setHistory] = useState([]) + const [loading, setLoading] = useState(false) + const [messages, setMessages] = useState([ + { + message: 'Hi there! How can I help?', + type: 'apiMessage' + } + ]) + + const messagesEndRef = useRef(null) + const inputRef = useRef(null) + const anchorRef = useRef(null) + const prevOpen = useRef(open) + const getChatmessageApi = useApi(chatmessageApi.getChatmessageFromChatflow) + + const handleClose = (event) => { + if (anchorRef.current && anchorRef.current.contains(event.target)) { + return + } + setOpen(false) + } + + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen) + } + + const clearChat = async () => { + const confirmPayload = { + title: `Clear Chat History`, + description: `Are you sure you want to clear all chat history?`, + confirmButtonName: 'Clear', + cancelButtonName: 'Cancel' + } + const isConfirmed = await confirm(confirmPayload) + + if (isConfirmed) { + try { + await chatmessageApi.deleteChatmessage(chatflowid) + enqueueSnackbar({ + message: 'Succesfully cleared all chat history', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + } catch (error) { + const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` + enqueueSnackbar({ + message: errorData, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + } + } + } + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + const addChatMessage = async (message, type) => { + try { + const newChatMessageBody = { + role: type, + content: message, + chatflowid: chatflowid + } + await chatmessageApi.createNewChatmessage(chatflowid, newChatMessageBody) + } catch (error) { + console.error(error) + } + } + + // Handle errors + const handleError = (message = 'Oops! There seems to be an error. Please try again.') => { + setMessages((prevMessages) => [...prevMessages, { message, type: 'apiMessage' }]) + addChatMessage(message, 'apiMessage') + setLoading(false) + setUserInput('') + setTimeout(() => { + inputRef.current.focus() + }, 100) + } + + // Handle form submission + const handleSubmit = async (e) => { + e.preventDefault() + + if (userInput.trim() === '') { + return + } + + setLoading(true) + setMessages((prevMessages) => [...prevMessages, { message: userInput, type: 'userMessage' }]) + addChatMessage(userInput, 'userMessage') + + // Send user question and history to API + try { + const response = await predictionApi.sendMessageAndGetPrediction(chatflowid, { question: userInput, history: history }) + if (response.data) { + const data = response.data + setMessages((prevMessages) => [...prevMessages, { message: data, type: 'apiMessage' }]) + addChatMessage(data, 'apiMessage') + setLoading(false) + setUserInput('') + setTimeout(() => { + inputRef.current.focus() + scrollToBottom() + }, 100) + } + } catch (error) { + const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` + handleError(errorData) + return + } + } + + // Prevent blank submissions and allow for multiline input + const handleEnter = (e) => { + if (e.key === 'Enter' && userInput) { + if (!e.shiftKey && userInput) { + handleSubmit(e) + } + } else if (e.key === 'Enter') { + e.preventDefault() + } + } + + // Get chatmessages successful + useEffect(() => { + if (getChatmessageApi.data) { + const loadedMessages = [] + for (const message of getChatmessageApi.data) { + loadedMessages.push({ + message: message.content, + type: message.role + }) + } + setMessages((prevMessages) => [...prevMessages, ...loadedMessages]) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getChatmessageApi.data]) + + // Keep history in sync with messages + useEffect(() => { + if (messages.length >= 3) { + setHistory([[messages[messages.length - 2].message, messages[messages.length - 1].message]]) + } + }, [messages]) + + // Auto scroll chat to bottom + useEffect(() => { + scrollToBottom() + }, [messages]) + + useEffect(() => { + if (prevOpen.current === true && open === false) { + anchorRef.current.focus() + } + + if (open && chatflowid) { + getChatmessageApi.request(chatflowid) + scrollToBottom() + } + + prevOpen.current = open + + return () => { + setUserInput('') + setHistory([]) + setLoading(false) + setMessages([ + { + message: 'Hi there! How can I help?', + type: 'apiMessage' + } + ]) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, chatflowid]) + + return ( + <> + + {open ? : } + + {open && ( + + + + )} + + {({ TransitionProps }) => ( + + + + +
+
+ {messages.map((message, index) => { + return ( + // The latest message sent by the user will be animated while waiting for a response + + {/* Display the correct icon depending on the message type */} + {message.type === 'apiMessage' ? ( + AI + ) : ( + Me + )} +
+ {/* Messages are being rendered in Markdown format */} + {message.message} +
+
+ ) + })} +
+
+
+ +
+
+
+ setUserInput(e.target.value)} + endAdornment={ + + + {loading ? ( +
+ +
+ ) : ( + // Send icon SVG in input field + + )} +
+
+ } + /> + +
+
+ + + + + )} + + + ) +} + +ChatMessage.propTypes = { chatflowid: PropTypes.string } diff --git a/packages/ui/src/views/settings/index.js b/packages/ui/src/views/settings/index.js new file mode 100644 index 0000000..8d76cc0 --- /dev/null +++ b/packages/ui/src/views/settings/index.js @@ -0,0 +1,104 @@ +import { useState, useEffect } from 'react' +import PropTypes from 'prop-types' + +// material-ui +import { useTheme } from '@mui/material/styles' +import { Box, List, Paper, Popper, ClickAwayListener } from '@mui/material' + +// third-party +import PerfectScrollbar from 'react-perfect-scrollbar' + +// project imports +import MainCard from 'ui-component/cards/MainCard' +import Transitions from 'ui-component/extended/Transitions' +import NavItem from 'layout/MainLayout/Sidebar/MenuList/NavItem' + +import settings from 'menu-items/settings' + +// ==============================|| SETTINGS ||============================== // + +const Settings = ({ chatflow, isSettingsOpen, anchorEl, onSettingsItemClick, onUploadFile, onClose }) => { + const theme = useTheme() + const [settingsMenu, setSettingsMenu] = useState([]) + + const [open, setOpen] = useState(false) + + useEffect(() => { + if (chatflow && !chatflow.id) { + const settingsMenu = settings.children.filter((menu) => menu.id === 'loadChatflow') + setSettingsMenu(settingsMenu) + } else if (chatflow && chatflow.id) { + const settingsMenu = settings.children + setSettingsMenu(settingsMenu) + } + }, [chatflow]) + + useEffect(() => { + setOpen(isSettingsOpen) + }, [isSettingsOpen]) + + // settings list items + const items = settingsMenu.map((menu) => { + return ( + onSettingsItemClick(id)} + onUploadFile={onUploadFile} + /> + ) + }) + + return ( + <> + + {({ TransitionProps }) => ( + + + + + + + {items} + + + + + + + )} + + + ) +} + +Settings.propTypes = { + chatflow: PropTypes.object, + isSettingsOpen: PropTypes.bool, + anchorEl: PropTypes.any, + onSettingsItemClick: PropTypes.func, + onUploadFile: PropTypes.func, + onClose: PropTypes.func +} + +export default Settings diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..a0c9800 --- /dev/null +++ b/turbo.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://turbo.build/schema.json", + "pipeline": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "test": {}, + "dev": { + "cache": false + } + } +}