From f21f5257cac9a50661e2408017729641f00a69f5 Mon Sep 17 00:00:00 2001 From: vinodkiran Date: Tue, 14 Nov 2023 14:35:47 +0530 Subject: [PATCH 01/29] UX Changes: Ability to view as table and search on the dashboard --- .../src/ui-component/button/StyledButton.js | 8 ++ .../src/ui-component/table/FlowListTable.js | 133 ++++++++++++++++++ .../ui/src/ui-component/toolbar/Toolbar.js | 24 ++++ packages/ui/src/views/chatflows/index.js | 96 ++++++++++--- 4 files changed, 240 insertions(+), 21 deletions(-) create mode 100644 packages/ui/src/ui-component/table/FlowListTable.js create mode 100644 packages/ui/src/ui-component/toolbar/Toolbar.js diff --git a/packages/ui/src/ui-component/button/StyledButton.js b/packages/ui/src/ui-component/button/StyledButton.js index 6e0c70786..29e17f804 100644 --- a/packages/ui/src/ui-component/button/StyledButton.js +++ b/packages/ui/src/ui-component/button/StyledButton.js @@ -1,5 +1,6 @@ import { styled } from '@mui/material/styles' import { Button } from '@mui/material' +import MuiToggleButton from '@mui/material/ToggleButton' export const StyledButton = styled(Button)(({ theme, color = 'primary' }) => ({ color: 'white', @@ -9,3 +10,10 @@ export const StyledButton = styled(Button)(({ theme, color = 'primary' }) => ({ backgroundImage: `linear-gradient(rgb(0 0 0/10%) 0 0)` } })) + +export const StyledToggleButton = styled(MuiToggleButton)(({ theme, color = 'primary' }) => ({ + '&.Mui-selected, &.Mui-selected:hover': { + color: 'white', + backgroundColor: theme.palette[color].main + } +})) diff --git a/packages/ui/src/ui-component/table/FlowListTable.js b/packages/ui/src/ui-component/table/FlowListTable.js new file mode 100644 index 000000000..819a49cb6 --- /dev/null +++ b/packages/ui/src/ui-component/table/FlowListTable.js @@ -0,0 +1,133 @@ +import PropTypes from 'prop-types' +import { useNavigate } from 'react-router-dom' +import { IconEdit } from '@tabler/icons' +import moment from 'moment' +import { styled } from '@mui/material/styles' +import Table from '@mui/material/Table' +import TableBody from '@mui/material/TableBody' +import TableCell, { tableCellClasses } from '@mui/material/TableCell' +import TableContainer from '@mui/material/TableContainer' +import TableHead from '@mui/material/TableHead' +import TableRow from '@mui/material/TableRow' +import Paper from '@mui/material/Paper' +import { Button, Typography } from '@mui/material' + +const StyledTableCell = styled(TableCell)(({ theme }) => ({ + [`&.${tableCellClasses.head}`]: { + backgroundColor: theme.palette.common.black, + color: theme.palette.common.white + }, + [`&.${tableCellClasses.body}`]: { + fontSize: 14 + } +})) + +const StyledTableRow = styled(TableRow)(({ theme }) => ({ + '&:nth-of-type(odd)': { + backgroundColor: theme.palette.action.hover + }, + // hide last border + '&:last-child td, &:last-child th': { + border: 0 + } +})) + +export const FlowListTable = ({ data, images, filterFunction }) => { + const navigate = useNavigate() + const goToCanvas = (selectedChatflow) => { + navigate(`/canvas/${selectedChatflow.id}`) + } + return ( + <> + + + + + + Name + + + Nodes + + + Last Modified Date + + + Actions + + + + + {data.filter(filterFunction).map((row, index) => ( + + + + {row.templateName || row.name} + + + + + {images[row.id] && ( +
+ {images[row.id].map((img) => ( +
+ +
+ ))} +
+ )} +
+ {moment(row.updatedDate).format('dddd, MMMM Do, YYYY h:mm:ss A')} + + + +
+ ))} +
+
+
+ + ) +} + +FlowListTable.propTypes = { + data: PropTypes.object, + images: PropTypes.array, + filterFunction: PropTypes.func +} diff --git a/packages/ui/src/ui-component/toolbar/Toolbar.js b/packages/ui/src/ui-component/toolbar/Toolbar.js new file mode 100644 index 000000000..f72ba339f --- /dev/null +++ b/packages/ui/src/ui-component/toolbar/Toolbar.js @@ -0,0 +1,24 @@ +import * as React from 'react' +import ViewListIcon from '@mui/icons-material/ViewList' +import ViewModuleIcon from '@mui/icons-material/ViewModule' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' +import { StyledToggleButton } from '../button/StyledButton' + +export default function Toolbar() { + const [view, setView] = React.useState('list') + + const handleChange = (event, nextView) => { + setView(nextView) + } + + return ( + + + + + + + + + ) +} diff --git a/packages/ui/src/views/chatflows/index.js b/packages/ui/src/views/chatflows/index.js index 6712623e6..e01a93734 100644 --- a/packages/ui/src/views/chatflows/index.js +++ b/packages/ui/src/views/chatflows/index.js @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom' import { useSelector } from 'react-redux' // material-ui -import { Grid, Box, Stack } from '@mui/material' +import { Grid, Box, Stack, Toolbar, ToggleButton, ButtonGroup, Typography, InputAdornment, TextField } from '@mui/material' import { useTheme } from '@mui/material/styles' // project imports @@ -11,7 +11,6 @@ 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' import LoginDialog from 'ui-component/dialog/LoginDialog' // API @@ -24,7 +23,13 @@ import useApi from 'hooks/useApi' import { baseURL } from 'store/constant' // icons -import { IconPlus } from '@tabler/icons' +import { IconPlus, IconSearch } from '@tabler/icons' +import * as React from 'react' +import ViewListIcon from '@mui/icons-material/ViewList' +import ViewModuleIcon from '@mui/icons-material/ViewModule' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' +import { FlowListTable } from '../../ui-component/table/FlowListTable' +import { StyledButton } from '../../ui-component/button/StyledButton' // ==============================|| CHATFLOWS ||============================== // @@ -35,10 +40,24 @@ const Chatflows = () => { const [isLoading, setLoading] = useState(true) const [images, setImages] = useState({}) + const [search, setSearch] = useState('') const [loginDialogOpen, setLoginDialogOpen] = useState(false) const [loginDialogProps, setLoginDialogProps] = useState({}) const getAllChatflowsApi = useApi(chatflowsApi.getAllChatflows) + const [view, setView] = React.useState('card') + + const handleChange = (event, nextView) => { + setView(nextView) + } + + const onSearchChange = (event) => { + setSearch(event.target.value) + } + + function filterFlows(data) { + return data.name.toLowerCase().indexOf(search.toLowerCase()) > -1 + } const onLoginClick = (username, password) => { localStorage.setItem('username', username) @@ -102,26 +121,61 @@ const Chatflows = () => { return ( - -

Chatflows

- - - - }> - Add New - + + + + + Chatflows + + + + + + ) + }} + /> + + + + + + + + + + + + + + + }> + Add New + + + + + + {!isLoading && (!view || view === 'card') && getAllChatflowsApi.data && ( + + {getAllChatflowsApi.data.filter(filterFlows).map((data, index) => ( + + goToCanvas(data)} data={data} images={images[data.id]} /> + + ))} - + )} + {!isLoading && view === 'list' && getAllChatflowsApi.data && ( + + )}
- - {!isLoading && - getAllChatflowsApi.data && - getAllChatflowsApi.data.map((data, index) => ( - - goToCanvas(data)} data={data} images={images[data.id]} /> - - ))} - + {!isLoading && (!getAllChatflowsApi.data || getAllChatflowsApi.data.length === 0) && ( From 77994ce2178e2c7ba456a1a950bf6c89cde83040 Mon Sep 17 00:00:00 2001 From: vinodkiran Date: Tue, 14 Nov 2023 15:15:34 +0530 Subject: [PATCH 02/29] UX Changes: adding a placeholder for chatflow search. --- packages/ui/src/views/chatflows/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/views/chatflows/index.js b/packages/ui/src/views/chatflows/index.js index e01a93734..f7d1497db 100644 --- a/packages/ui/src/views/chatflows/index.js +++ b/packages/ui/src/views/chatflows/index.js @@ -132,6 +132,7 @@ const Chatflows = () => { size='small' sx={{ width: 400 }} variant='outlined' + placeholder='Search Chatflows' onChange={onSearchChange} InputProps={{ startAdornment: ( From 7ef817bc996200d89c4d5f11d4a556f2ac5a16df Mon Sep 17 00:00:00 2001 From: vinodkiran Date: Tue, 14 Nov 2023 15:48:14 +0530 Subject: [PATCH 03/29] UX Changes: limiting display of node icons to 5 and with label to indicate additional. --- packages/ui/src/ui-component/table/FlowListTable.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/ui-component/table/FlowListTable.js b/packages/ui/src/ui-component/table/FlowListTable.js index 819a49cb6..896ce3ea6 100644 --- a/packages/ui/src/ui-component/table/FlowListTable.js +++ b/packages/ui/src/ui-component/table/FlowListTable.js @@ -37,6 +37,7 @@ export const FlowListTable = ({ data, images, filterFunction }) => { const goToCanvas = (selectedChatflow) => { navigate(`/canvas/${selectedChatflow.id}`) } + let nodeCount = 0 return ( <> @@ -53,7 +54,7 @@ export const FlowListTable = ({ data, images, filterFunction }) => { Name - Nodes + Nodes (Showing first 5) Last Modified Date @@ -84,7 +85,7 @@ export const FlowListTable = ({ data, images, filterFunction }) => { marginTop: 5 }} > - {images[row.id].map((img) => ( + {images[row.id].slice(0, images[row.id].length > 5 ? 5 : images[row.id].length).map((img) => (
{ />
))} + {images[row.id].length > 5 && ( + + + {images[row.id].length - 5} More + + )} )} From 57b31130397ca0097ef04db78137cccc79d7b63e Mon Sep 17 00:00:00 2001 From: vinodkiran Date: Tue, 14 Nov 2023 20:13:24 +0530 Subject: [PATCH 04/29] UX Changes: minor UI tweaks/adjustments and fixes for small screens --- .../src/ui-component/table/FlowListTable.js | 20 ++++++------- packages/ui/src/views/chatflows/index.js | 28 +++++++++++++------ 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/ui-component/table/FlowListTable.js b/packages/ui/src/ui-component/table/FlowListTable.js index 896ce3ea6..9dfc95221 100644 --- a/packages/ui/src/ui-component/table/FlowListTable.js +++ b/packages/ui/src/ui-component/table/FlowListTable.js @@ -37,26 +37,20 @@ export const FlowListTable = ({ data, images, filterFunction }) => { const goToCanvas = (selectedChatflow) => { navigate(`/canvas/${selectedChatflow.id}`) } - let nodeCount = 0 + return ( <> - + Name - + Nodes (Showing first 5) - + Last Modified Date @@ -75,7 +69,7 @@ export const FlowListTable = ({ data, images, filterFunction }) => { - + {images[row.id] && (
{
)}
- {moment(row.updatedDate).format('dddd, MMMM Do, YYYY h:mm:ss A')} + + {moment(row.updatedDate).format('dddd, MMMM Do, YYYY h:mm:ss A')} + + ) + } + }) + } + } + + const handleDelete = async () => { + setAnchorEl(null) + 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) + await updateFlowsApi.request() + } 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 handleDuplicate = () => { + setAnchorEl(null) + try { + localStorage.setItem('duplicatedFlowData', chatflow.flowData) + window.open(`${uiBaseURL}/canvas`, '_blank') + } catch (e) { + console.error(e) + } + } + const handleExport = () => { + setAnchorEl(null) + try { + const flowData = JSON.parse(chatflow.flowData) + let dataStr = JSON.stringify(generateExportFlowData(flowData), null, 2) + 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) + } + } + + return ( +
+ + + + + Rename + + + + Duplicate + + + + Export + + + + + Delete + + + + setFlowDialogOpen(false)} + onConfirm={saveFlowRename} + /> +
+ ) +} + +FlowListMenu.propTypes = { + chatflow: PropTypes.object, + updateFlowsApi: PropTypes.object +} diff --git a/packages/ui/src/ui-component/table/FlowListTable.js b/packages/ui/src/ui-component/table/FlowListTable.js index 9dfc95221..8dc25e96e 100644 --- a/packages/ui/src/ui-component/table/FlowListTable.js +++ b/packages/ui/src/ui-component/table/FlowListTable.js @@ -1,6 +1,5 @@ import PropTypes from 'prop-types' import { useNavigate } from 'react-router-dom' -import { IconEdit } from '@tabler/icons' import moment from 'moment' import { styled } from '@mui/material/styles' import Table from '@mui/material/Table' @@ -10,7 +9,8 @@ import TableContainer from '@mui/material/TableContainer' import TableHead from '@mui/material/TableHead' import TableRow from '@mui/material/TableRow' import Paper from '@mui/material/Paper' -import { Button, Typography } from '@mui/material' +import { Button, Stack, Typography } from '@mui/material' +import FlowListMenu from '../button/FlowListMenu' const StyledTableCell = styled(TableCell)(({ theme }) => ({ [`&.${tableCellClasses.head}`]: { @@ -32,7 +32,7 @@ const StyledTableRow = styled(TableRow)(({ theme }) => ({ } })) -export const FlowListTable = ({ data, images, filterFunction }) => { +export const FlowListTable = ({ data, images, filterFunction, updateFlowsApi }) => { const navigate = useNavigate() const goToCanvas = (selectedChatflow) => { navigate(`/canvas/${selectedChatflow.id}`) @@ -44,7 +44,7 @@ export const FlowListTable = ({ data, images, filterFunction }) => {
- + Name @@ -53,7 +53,7 @@ export const FlowListTable = ({ data, images, filterFunction }) => { Last Modified Date - + Actions @@ -65,7 +65,7 @@ export const FlowListTable = ({ data, images, filterFunction }) => { - {row.templateName || row.name} + @@ -111,15 +111,13 @@ export const FlowListTable = ({ data, images, filterFunction }) => { {moment(row.updatedDate).format('dddd, MMMM Do, YYYY h:mm:ss A')} - - + + + {/**/} + + ))} @@ -133,5 +131,6 @@ export const FlowListTable = ({ data, images, filterFunction }) => { FlowListTable.propTypes = { data: PropTypes.object, images: PropTypes.array, - filterFunction: PropTypes.func + filterFunction: PropTypes.func, + updateFlowsApi: PropTypes.object } diff --git a/packages/ui/src/views/chatflows/index.js b/packages/ui/src/views/chatflows/index.js index b7784a896..f008bfa87 100644 --- a/packages/ui/src/views/chatflows/index.js +++ b/packages/ui/src/views/chatflows/index.js @@ -23,10 +23,8 @@ import useApi from 'hooks/useApi' import { baseURL } from 'store/constant' // icons -import { IconPlus, IconSearch } from '@tabler/icons' +import { IconPlus, IconSearch, IconLayoutCards, IconLayoutColumns } from '@tabler/icons' import * as React from 'react' -import ViewListIcon from '@mui/icons-material/ViewList' -import ViewModuleIcon from '@mui/icons-material/ViewModule' import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import { FlowListTable } from '../../ui-component/table/FlowListTable' import { StyledButton } from '../../ui-component/button/StyledButton' @@ -159,10 +157,10 @@ const Chatflows = () => { > - + - + @@ -185,7 +183,13 @@ const Chatflows = () => { )} {!isLoading && view === 'list' && getAllChatflowsApi.data && ( - + )} From a7b34848cd3fe814b4e3820c2409c77f0d67c5ca Mon Sep 17 00:00:00 2001 From: vinodkiran Date: Thu, 16 Nov 2023 08:29:06 +0530 Subject: [PATCH 06/29] UX Changes: Ability to set category (tags) to each chatflow; corresponding changes to table display and table search --- .../server/src/database/entities/ChatFlow.ts | 3 + .../1699900910291-AddCategoryToChatFlow.ts | 12 +++ .../src/database/migrations/mysql/index.ts | 4 +- .../1699900910291-AddCategoryToChatFlow.ts | 11 +++ .../src/database/migrations/postgres/index.ts | 4 +- .../1699900910291-AddCategoryToChatFlow.ts | 11 +++ .../src/database/migrations/sqlite/index.ts | 4 +- .../src/ui-component/button/FlowListMenu.js | 55 ++++++++++- .../ui/src/ui-component/dialog/TagDialog.js | 91 +++++++++++++++++++ .../src/ui-component/table/FlowListTable.js | 35 +++++-- packages/ui/src/views/chatflows/index.js | 7 +- 11 files changed, 220 insertions(+), 17 deletions(-) create mode 100644 packages/server/src/database/migrations/mysql/1699900910291-AddCategoryToChatFlow.ts create mode 100644 packages/server/src/database/migrations/postgres/1699900910291-AddCategoryToChatFlow.ts create mode 100644 packages/server/src/database/migrations/sqlite/1699900910291-AddCategoryToChatFlow.ts create mode 100644 packages/ui/src/ui-component/dialog/TagDialog.js diff --git a/packages/server/src/database/entities/ChatFlow.ts b/packages/server/src/database/entities/ChatFlow.ts index 376a100b4..b3131c2ea 100644 --- a/packages/server/src/database/entities/ChatFlow.ts +++ b/packages/server/src/database/entities/ChatFlow.ts @@ -36,4 +36,7 @@ export class ChatFlow implements IChatFlow { @UpdateDateColumn() updatedDate: Date + + @Column({ nullable: true, type: 'text' }) + category?: string } diff --git a/packages/server/src/database/migrations/mysql/1699900910291-AddCategoryToChatFlow.ts b/packages/server/src/database/migrations/mysql/1699900910291-AddCategoryToChatFlow.ts new file mode 100644 index 000000000..424f3b0e0 --- /dev/null +++ b/packages/server/src/database/migrations/mysql/1699900910291-AddCategoryToChatFlow.ts @@ -0,0 +1,12 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddCategoryToChatFlow1699900910291 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const columnExists = await queryRunner.hasColumn('chat_flow', 'category') + if (!columnExists) queryRunner.query(`ALTER TABLE \`chat_flow\` ADD COLUMN \`category\` TEXT;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`chat_flow\` DROP COLUMN \`category\`;`) + } +} diff --git a/packages/server/src/database/migrations/mysql/index.ts b/packages/server/src/database/migrations/mysql/index.ts index 4b7b8a954..53d652eea 100644 --- a/packages/server/src/database/migrations/mysql/index.ts +++ b/packages/server/src/database/migrations/mysql/index.ts @@ -8,6 +8,7 @@ import { AddAnalytic1694432361423 } from './1694432361423-AddAnalytic' import { AddChatHistory1694658767766 } from './1694658767766-AddChatHistory' import { AddAssistantEntity1699325775451 } from './1699325775451-AddAssistantEntity' import { AddUsedToolsToChatMessage1699481607341 } from './1699481607341-AddUsedToolsToChatMessage' +import { AddCategoryToChatFlow1699900910291 } from './1699900910291-AddCategoryToChatFlow' export const mysqlMigrations = [ Init1693840429259, @@ -19,5 +20,6 @@ export const mysqlMigrations = [ AddAnalytic1694432361423, AddChatHistory1694658767766, AddAssistantEntity1699325775451, - AddUsedToolsToChatMessage1699481607341 + AddUsedToolsToChatMessage1699481607341, + AddCategoryToChatFlow1699900910291 ] diff --git a/packages/server/src/database/migrations/postgres/1699900910291-AddCategoryToChatFlow.ts b/packages/server/src/database/migrations/postgres/1699900910291-AddCategoryToChatFlow.ts new file mode 100644 index 000000000..f5d964391 --- /dev/null +++ b/packages/server/src/database/migrations/postgres/1699900910291-AddCategoryToChatFlow.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddCategoryToChatFlow1699900910291 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN IF NOT EXISTS "category" TEXT;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "category";`) + } +} diff --git a/packages/server/src/database/migrations/postgres/index.ts b/packages/server/src/database/migrations/postgres/index.ts index 75562c0b5..70642eb66 100644 --- a/packages/server/src/database/migrations/postgres/index.ts +++ b/packages/server/src/database/migrations/postgres/index.ts @@ -8,6 +8,7 @@ import { AddAnalytic1694432361423 } from './1694432361423-AddAnalytic' import { AddChatHistory1694658756136 } from './1694658756136-AddChatHistory' import { AddAssistantEntity1699325775451 } from './1699325775451-AddAssistantEntity' import { AddUsedToolsToChatMessage1699481607341 } from './1699481607341-AddUsedToolsToChatMessage' +import { AddCategoryToChatFlow1699900910291 } from './1699900910291-AddCategoryToChatFlow' export const postgresMigrations = [ Init1693891895163, @@ -19,5 +20,6 @@ export const postgresMigrations = [ AddAnalytic1694432361423, AddChatHistory1694658756136, AddAssistantEntity1699325775451, - AddUsedToolsToChatMessage1699481607341 + AddUsedToolsToChatMessage1699481607341, + AddCategoryToChatFlow1699900910291 ] diff --git a/packages/server/src/database/migrations/sqlite/1699900910291-AddCategoryToChatFlow.ts b/packages/server/src/database/migrations/sqlite/1699900910291-AddCategoryToChatFlow.ts new file mode 100644 index 000000000..270b29988 --- /dev/null +++ b/packages/server/src/database/migrations/sqlite/1699900910291-AddCategoryToChatFlow.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddCategoryToChatFlow1699900910291 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN "category" TEXT;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "category";`) + } +} diff --git a/packages/server/src/database/migrations/sqlite/index.ts b/packages/server/src/database/migrations/sqlite/index.ts index 4a14fc407..fe7611ea6 100644 --- a/packages/server/src/database/migrations/sqlite/index.ts +++ b/packages/server/src/database/migrations/sqlite/index.ts @@ -8,6 +8,7 @@ import { AddAnalytic1694432361423 } from './1694432361423-AddAnalytic' import { AddChatHistory1694657778173 } from './1694657778173-AddChatHistory' import { AddAssistantEntity1699325775451 } from './1699325775451-AddAssistantEntity' import { AddUsedToolsToChatMessage1699481607341 } from './1699481607341-AddUsedToolsToChatMessage' +import { AddCategoryToChatFlow1699900910291 } from './1699900910291-AddCategoryToChatFlow' export const sqliteMigrations = [ Init1693835579790, @@ -19,5 +20,6 @@ export const sqliteMigrations = [ AddAnalytic1694432361423, AddChatHistory1694657778173, AddAssistantEntity1699325775451, - AddUsedToolsToChatMessage1699481607341 + AddUsedToolsToChatMessage1699481607341, + AddCategoryToChatFlow1699900910291 ] diff --git a/packages/ui/src/ui-component/button/FlowListMenu.js b/packages/ui/src/ui-component/button/FlowListMenu.js index fb759505c..f4ffbd32d 100644 --- a/packages/ui/src/ui-component/button/FlowListMenu.js +++ b/packages/ui/src/ui-component/button/FlowListMenu.js @@ -7,6 +7,7 @@ import Divider from '@mui/material/Divider' import FileCopyIcon from '@mui/icons-material/FileCopy' import FileDownloadIcon from '@mui/icons-material/Downloading' import FileDeleteIcon from '@mui/icons-material/Delete' +import FileCategoryIcon from '@mui/icons-material/Category' import Button from '@mui/material/Button' import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' import PropTypes from 'prop-types' @@ -22,6 +23,7 @@ import ConfirmDialog from '../dialog/ConfirmDialog' import SaveChatflowDialog from '../dialog/SaveChatflowDialog' import { useState } from 'react' import useApi from '../../hooks/useApi' +import TagDialog from '../dialog/TagDialog' const StyledMenu = styled((props) => ( dispatch(enqueueSnackbarAction(...args)) const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) - const [anchorEl, setAnchorEl] = React.useState(null) const open = Boolean(anchorEl) const handleClick = (event) => { @@ -79,12 +83,10 @@ export default function FlowListMenu({ chatflow, updateFlowsApi }) { const handleClose = () => { setAnchorEl(null) } - const handleFlowRename = () => { setAnchorEl(null) setFlowDialogOpen(true) } - const saveFlowRename = async (chatflowName) => { const updateBody = { name: chatflowName, @@ -110,7 +112,39 @@ export default function FlowListMenu({ chatflow, updateFlowsApi }) { }) } } - + const handleFlowCategory = () => { + setAnchorEl(null) + if (chatflow.category) setCategoryValues(chatflow.category.split(';')) + else setCategoryValues([]) + setCategoryDialogOpen(true) + } + const saveFlowCategory = async (categories) => { + // save categories as string + const categoryTags = categories.join(';') + const updateBody = { + category: categoryTags, + chatflow + } + try { + await updateChatflowApi.request(chatflow.id, updateBody) + await updateFlowsApi.request() + } 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 handleDelete = async () => { setAnchorEl(null) const confirmPayload = { @@ -143,7 +177,6 @@ export default function FlowListMenu({ chatflow, updateFlowsApi }) { } } } - const handleDuplicate = () => { setAnchorEl(null) try { @@ -206,6 +239,11 @@ export default function FlowListMenu({ chatflow, updateFlowsApi }) { Export + + + Update Category + + Delete @@ -222,6 +260,13 @@ export default function FlowListMenu({ chatflow, updateFlowsApi }) { onCancel={() => setFlowDialogOpen(false)} onConfirm={saveFlowRename} /> + setCategoryDialogOpen(false)} + tags={categoryValues} + setTags={setCategoryValues} + onSubmit={saveFlowCategory} + /> ) } diff --git a/packages/ui/src/ui-component/dialog/TagDialog.js b/packages/ui/src/ui-component/dialog/TagDialog.js new file mode 100644 index 000000000..be133fa4e --- /dev/null +++ b/packages/ui/src/ui-component/dialog/TagDialog.js @@ -0,0 +1,91 @@ +import { useState } from 'react' +import Dialog from '@mui/material/Dialog' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import TextField from '@mui/material/TextField' +import Chip from '@mui/material/Chip' +import PropTypes from 'prop-types' +import { DialogActions, DialogContent, DialogTitle } from '@mui/material' + +const TagDialog = ({ isOpen, onClose, tags, setTags, onSubmit }) => { + const [inputValue, setInputValue] = useState('') + + const handleInputChange = (event) => { + setInputValue(event.target.value) + } + + const handleInputKeyDown = (event) => { + if (event.key === 'Enter' && inputValue.trim()) { + event.preventDefault() + if (!tags.includes(inputValue)) { + setTags([...tags, inputValue]) + setInputValue('') + } + } + } + + const handleDeleteTag = (tagToDelete) => { + setTags(tags.filter((tag) => tag !== tagToDelete)) + } + + const handleSubmit = (event) => { + event.preventDefault() + onSubmit(tags) + onClose() + } + + return ( + + + Set Chatflow Category Tags + + + +
+
+ {tags.map((tag, index) => ( + handleDeleteTag(tag)} + style={{ marginRight: 5, marginBottom: 5 }} + /> + ))} +
+ + +
+
+ + + + +
+ ) +} + +TagDialog.propTypes = { + isOpen: PropTypes.bool, + onClose: PropTypes.func, + tags: PropTypes.array, + setTags: PropTypes.func, + onSubmit: PropTypes.func +} + +export default TagDialog diff --git a/packages/ui/src/ui-component/table/FlowListTable.js b/packages/ui/src/ui-component/table/FlowListTable.js index 8dc25e96e..08caed57f 100644 --- a/packages/ui/src/ui-component/table/FlowListTable.js +++ b/packages/ui/src/ui-component/table/FlowListTable.js @@ -11,6 +11,7 @@ import TableRow from '@mui/material/TableRow' import Paper from '@mui/material/Paper' import { Button, Stack, Typography } from '@mui/material' import FlowListMenu from '../button/FlowListMenu' +import Chip from '@mui/material/Chip' const StyledTableCell = styled(TableCell)(({ theme }) => ({ [`&.${tableCellClasses.head}`]: { @@ -47,13 +48,16 @@ export const FlowListTable = ({ data, images, filterFunction, updateFlowsApi }) Name - - Nodes (Showing first 5) + + Category - Last Modified Date + Nodes (Showing first 5) + Last Modified Date + + Actions @@ -68,8 +72,25 @@ export const FlowListTable = ({ data, images, filterFunction, updateFlowsApi }) - +
+   + {row.category && + row.category + .split(';') + .map((tag, index) => ( + + ))} +
+
+ {images[row.id] && (
)} - - {moment(row.updatedDate).format('dddd, MMMM Do, YYYY h:mm:ss A')} - + {moment(row.updatedDate).format('MMMM Do, YYYY')} + + {/**/} diff --git a/packages/ui/src/views/chatflows/index.js b/packages/ui/src/views/chatflows/index.js index 44c670d62..34c6523b7 100644 --- a/packages/ui/src/views/chatflows/index.js +++ b/packages/ui/src/views/chatflows/index.js @@ -23,7 +23,7 @@ import useApi from 'hooks/useApi' import { baseURL } from 'store/constant' // icons -import { IconPlus, IconSearch, IconLayoutCards, IconLayoutColumns } from '@tabler/icons' +import { IconPlus, IconSearch, IconLayoutGrid, IconList } from '@tabler/icons' import * as React from 'react' import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import { FlowListTable } from '../../ui-component/table/FlowListTable' @@ -138,7 +138,7 @@ const Chatflows = () => {

Chatflows

{ }} /> - + - - - + + + - - + + From 97247713ef7c2f0134c3114c1de84fea040a47f5 Mon Sep 17 00:00:00 2001 From: vinodkiran Date: Fri, 17 Nov 2023 11:24:55 +0530 Subject: [PATCH 08/29] UX Changes: Fixed 2 edge cases, (1) UI-tag not added, when the user clicks the submit without hitting enter and (2) server side error when the update/rename is attempted before any flow is opened --- packages/server/src/index.ts | 8 ++++++-- packages/ui/src/ui-component/dialog/TagDialog.js | 8 +++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index ba6c3ce0e..5a5b2eda7 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -355,8 +355,12 @@ export class App { this.AppDataSource.getRepository(ChatFlow).merge(chatflow, updateChatFlow) const result = await this.AppDataSource.getRepository(ChatFlow).save(chatflow) - // Update chatflowpool inSync to false, to build Langchain again because data has been changed - this.chatflowPool.updateInSync(chatflow.id, false) + // chatFlowPool is initialized only when a flow is opened + // if the user attempts to rename/update category without opening any flow, chatFlowPool will be undefined + if (this.chatflowPool) { + // Update chatflowpool inSync to false, to build Langchain again because data has been changed + this.chatflowPool.updateInSync(chatflow.id, false) + } return res.json(result) }) diff --git a/packages/ui/src/ui-component/dialog/TagDialog.js b/packages/ui/src/ui-component/dialog/TagDialog.js index be133fa4e..778bf2cce 100644 --- a/packages/ui/src/ui-component/dialog/TagDialog.js +++ b/packages/ui/src/ui-component/dialog/TagDialog.js @@ -5,7 +5,7 @@ import Button from '@mui/material/Button' import TextField from '@mui/material/TextField' import Chip from '@mui/material/Chip' import PropTypes from 'prop-types' -import { DialogActions, DialogContent, DialogTitle } from '@mui/material' +import { DialogActions, DialogContent, DialogTitle, Typography } from '@mui/material' const TagDialog = ({ isOpen, onClose, tags, setTags, onSubmit }) => { const [inputValue, setInputValue] = useState('') @@ -30,6 +30,9 @@ const TagDialog = ({ isOpen, onClose, tags, setTags, onSubmit }) => { const handleSubmit = (event) => { event.preventDefault() + if (inputValue.trim() && !tags.includes(inputValue)) { + setTags([...tags, inputValue]) + } onSubmit(tags) onClose() } @@ -67,6 +70,9 @@ const TagDialog = ({ isOpen, onClose, tags, setTags, onSubmit }) => { label='Add a tag' variant='outlined' /> + + Enter a tag and press enter to add it to the list. You can add as many tags as you want. + From a0397c008e035ec64e8063b4b2c7859a5d2eadc8 Mon Sep 17 00:00:00 2001 From: vinodkiran Date: Fri, 17 Nov 2023 11:25:59 +0530 Subject: [PATCH 09/29] UX Changes: Column display fixes for 'xs' mode --- packages/ui/src/ui-component/table/FlowListTable.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/ui-component/table/FlowListTable.js b/packages/ui/src/ui-component/table/FlowListTable.js index 68641d442..e33a8ba13 100644 --- a/packages/ui/src/ui-component/table/FlowListTable.js +++ b/packages/ui/src/ui-component/table/FlowListTable.js @@ -48,7 +48,7 @@ export const FlowListTable = ({ data, images, filterFunction, updateFlowsApi }) Name - + Category @@ -72,7 +72,7 @@ export const FlowListTable = ({ data, images, filterFunction, updateFlowsApi }) - +
Date: Fri, 17 Nov 2023 12:29:14 +0530 Subject: [PATCH 10/29] UX Changes: Addition of search filters for API Keys and Credentials. --- packages/ui/src/views/apikey/index.js | 71 ++++++++++++++--- packages/ui/src/views/credentials/index.js | 88 ++++++++++++++++++---- 2 files changed, 136 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/views/apikey/index.js b/packages/ui/src/views/apikey/index.js index a2b2e639f..e08baac2d 100644 --- a/packages/ui/src/views/apikey/index.js +++ b/packages/ui/src/views/apikey/index.js @@ -16,7 +16,11 @@ import { Paper, IconButton, Popover, - Typography + Typography, + Toolbar, + TextField, + InputAdornment, + ButtonGroup } from '@mui/material' import { useTheme } from '@mui/material/styles' @@ -37,7 +41,7 @@ import useConfirm from 'hooks/useConfirm' import useNotifier from 'utils/useNotifier' // Icons -import { IconTrash, IconEdit, IconCopy, IconX, IconPlus, IconEye, IconEyeOff } from '@tabler/icons' +import { IconTrash, IconEdit, IconCopy, IconX, IconPlus, IconEye, IconEyeOff, IconSearch } from '@tabler/icons' import APIEmptySVG from 'assets/images/api_empty.svg' // ==============================|| APIKey ||============================== // @@ -59,6 +63,14 @@ const APIKey = () => { const [showApiKeys, setShowApiKeys] = useState([]) const openPopOver = Boolean(anchorEl) + const [search, setSearch] = useState('') + const onSearchChange = (event) => { + setSearch(event.target.value) + } + function filterKeys(data) { + return data.keyName.toLowerCase().indexOf(search.toLowerCase()) > -1 + } + const { confirm } = useConfirm() const getAllAPIKeysApi = useApi(apiKeyApi.getAllAPIKeys) @@ -171,12 +183,53 @@ const APIKey = () => { <> -

API Keys 

- - - }> - Create Key - + + +

API Keys 

+ + + + ) + }} + /> + + + + } + > + Create Key + + + +
+
{apiKeys.length <= 0 && ( @@ -199,7 +252,7 @@ const APIKey = () => { - {apiKeys.map((key, index) => ( + {apiKeys.filter(filterKeys).map((key, index) => ( {key.keyName} diff --git a/packages/ui/src/views/credentials/index.js b/packages/ui/src/views/credentials/index.js index 9db990a7c..31e358314 100644 --- a/packages/ui/src/views/credentials/index.js +++ b/packages/ui/src/views/credentials/index.js @@ -4,7 +4,23 @@ import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackba import moment from 'moment' // material-ui -import { Button, Box, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, IconButton } from '@mui/material' +import { + Button, + Box, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + IconButton, + Toolbar, + TextField, + InputAdornment, + ButtonGroup +} from '@mui/material' import { useTheme } from '@mui/material/styles' // project imports @@ -25,7 +41,7 @@ import useConfirm from 'hooks/useConfirm' import useNotifier from 'utils/useNotifier' // Icons -import { IconTrash, IconEdit, IconX, IconPlus } from '@tabler/icons' +import { IconTrash, IconEdit, IconX, IconPlus, IconSearch } from '@tabler/icons' import CredentialEmptySVG from 'assets/images/credential_empty.svg' // const @@ -56,6 +72,14 @@ const Credentials = () => { const getAllCredentialsApi = useApi(credentialsApi.getAllCredentials) const getAllComponentsCredentialsApi = useApi(credentialsApi.getAllComponentsCredentials) + const [search, setSearch] = useState('') + const onSearchChange = (event) => { + setSearch(event.target.value) + } + function filterCredentials(data) { + return data.credentialName.toLowerCase().indexOf(search.toLowerCase()) > -1 + } + const listCredential = () => { const dialogProp = { title: 'Add New Credential', @@ -168,17 +192,53 @@ const Credentials = () => { <> -

Credentials 

- - - } - > - Add Credential - + + +

Credentials 

+ + + + ) + }} + /> + + + + } + > + Add Credential + + + +
+
{credentials.length <= 0 && ( @@ -205,7 +265,7 @@ const Credentials = () => {
- {credentials.map((credential, index) => ( + {credentials.filter(filterCredentials).map((credential, index) => (
Date: Fri, 17 Nov 2023 12:35:01 +0000 Subject: [PATCH 11/29] add fix where tags are not added when submit is clicked without enter --- .../src/ui-component/button/FlowListMenu.js | 57 ++++++++++------- .../ui/src/ui-component/dialog/TagDialog.js | 63 +++++++++++-------- .../src/ui-component/table/FlowListTable.js | 2 +- 3 files changed, 75 insertions(+), 47 deletions(-) diff --git a/packages/ui/src/ui-component/button/FlowListMenu.js b/packages/ui/src/ui-component/button/FlowListMenu.js index 441922982..b242d2cb2 100644 --- a/packages/ui/src/ui-component/button/FlowListMenu.js +++ b/packages/ui/src/ui-component/button/FlowListMenu.js @@ -1,4 +1,7 @@ -import * as React from 'react' +import { useState } from 'react' +import { useDispatch } from 'react-redux' +import PropTypes from 'prop-types' + import { styled, alpha } from '@mui/material/styles' import Menu from '@mui/material/Menu' import MenuItem from '@mui/material/MenuItem' @@ -10,21 +13,22 @@ import FileDeleteIcon from '@mui/icons-material/Delete' import FileCategoryIcon from '@mui/icons-material/Category' import Button from '@mui/material/Button' import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' -import PropTypes from 'prop-types' -import { uiBaseURL } from '../../store/constant' -import { generateExportFlowData } from '../../utils/genericHelper' -import chatflowsApi from 'api/chatflows' -import useConfirm from 'hooks/useConfirm' -import useNotifier from '../../utils/useNotifier' -import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction } from '../../store/actions' import { IconX } from '@tabler/icons' -import { useDispatch } from 'react-redux' + +import chatflowsApi from 'api/chatflows' + +import useApi from '../../hooks/useApi' +import useConfirm from 'hooks/useConfirm' +import { uiBaseURL } from '../../store/constant' +import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction } from '../../store/actions' + import ConfirmDialog from '../dialog/ConfirmDialog' import SaveChatflowDialog from '../dialog/SaveChatflowDialog' -import { useState } from 'react' -import useApi from '../../hooks/useApi' import TagDialog from '../dialog/TagDialog' +import { generateExportFlowData } from '../../utils/genericHelper' +import useNotifier from '../../utils/useNotifier' + const StyledMenu = styled((props) => ( ( export default function FlowListMenu({ chatflow, updateFlowsApi }) { const { confirm } = useConfirm() const dispatch = useDispatch() - const [flowDialogOpen, setFlowDialogOpen] = useState(false) - const [categoryValues, setCategoryValues] = useState([]) - - const [categoryDialogOpen, setCategoryDialogOpen] = useState(false) const updateChatflowApi = useApi(chatflowsApi.updateChatflow) - // ==============================|| Snackbar ||============================== // useNotifier() const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) - const [anchorEl, setAnchorEl] = React.useState(null) + + const [flowDialogOpen, setFlowDialogOpen] = useState(false) + const [categoryDialogOpen, setCategoryDialogOpen] = useState(false) + const [categoryDialogProps, setCategoryDialogProps] = useState({}) + const [anchorEl, setAnchorEl] = useState(null) const open = Boolean(anchorEl) + const handleClick = (event) => { setAnchorEl(event.currentTarget) } + const handleClose = () => { setAnchorEl(null) } + const handleFlowRename = () => { setAnchorEl(null) setFlowDialogOpen(true) } + const saveFlowRename = async (chatflowName) => { const updateBody = { name: chatflowName, @@ -111,13 +118,19 @@ export default function FlowListMenu({ chatflow, updateFlowsApi }) { }) } } + const handleFlowCategory = () => { setAnchorEl(null) - if (chatflow.category) setCategoryValues(chatflow.category.split(';')) - else setCategoryValues([]) + if (chatflow.category) { + setCategoryDialogProps({ + category: chatflow.category.split(';') + }) + } setCategoryDialogOpen(true) } + const saveFlowCategory = async (categories) => { + setCategoryDialogOpen(false) // save categories as string const categoryTags = categories.join(';') const updateBody = { @@ -144,6 +157,7 @@ export default function FlowListMenu({ chatflow, updateFlowsApi }) { }) } } + const handleDelete = async () => { setAnchorEl(null) const confirmPayload = { @@ -176,6 +190,7 @@ export default function FlowListMenu({ chatflow, updateFlowsApi }) { } } } + const handleDuplicate = () => { setAnchorEl(null) try { @@ -185,6 +200,7 @@ export default function FlowListMenu({ chatflow, updateFlowsApi }) { console.error(e) } } + const handleExport = () => { setAnchorEl(null) try { @@ -261,9 +277,8 @@ export default function FlowListMenu({ chatflow, updateFlowsApi }) { /> setCategoryDialogOpen(false)} - tags={categoryValues} - setTags={setCategoryValues} onSubmit={saveFlowCategory} />
diff --git a/packages/ui/src/ui-component/dialog/TagDialog.js b/packages/ui/src/ui-component/dialog/TagDialog.js index 778bf2cce..82c35dde6 100644 --- a/packages/ui/src/ui-component/dialog/TagDialog.js +++ b/packages/ui/src/ui-component/dialog/TagDialog.js @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import Dialog from '@mui/material/Dialog' import Box from '@mui/material/Box' import Button from '@mui/material/Button' @@ -7,8 +7,9 @@ import Chip from '@mui/material/Chip' import PropTypes from 'prop-types' import { DialogActions, DialogContent, DialogTitle, Typography } from '@mui/material' -const TagDialog = ({ isOpen, onClose, tags, setTags, onSubmit }) => { +const TagDialog = ({ isOpen, dialogProps, onClose, onSubmit }) => { const [inputValue, setInputValue] = useState('') + const [categoryValues, setCategoryValues] = useState([]) const handleInputChange = (event) => { setInputValue(event.target.value) @@ -17,34 +18,44 @@ const TagDialog = ({ isOpen, onClose, tags, setTags, onSubmit }) => { const handleInputKeyDown = (event) => { if (event.key === 'Enter' && inputValue.trim()) { event.preventDefault() - if (!tags.includes(inputValue)) { - setTags([...tags, inputValue]) + if (!categoryValues.includes(inputValue)) { + setCategoryValues([...categoryValues, inputValue]) setInputValue('') } } } - const handleDeleteTag = (tagToDelete) => { - setTags(tags.filter((tag) => tag !== tagToDelete)) + const handleDeleteTag = (categoryToDelete) => { + setCategoryValues(categoryValues.filter((category) => category !== categoryToDelete)) } const handleSubmit = (event) => { event.preventDefault() - if (inputValue.trim() && !tags.includes(inputValue)) { - setTags([...tags, inputValue]) + let newCategories = [...categoryValues] + if (inputValue.trim() && !categoryValues.includes(inputValue)) { + newCategories = [...newCategories, inputValue] + setCategoryValues(newCategories) } - onSubmit(tags) - onClose() + onSubmit(newCategories) } + useEffect(() => { + if (dialogProps.category) setCategoryValues(dialogProps.category) + + return () => { + setInputValue('') + setCategoryValues([]) + } + }, [dialogProps]) + return ( Set Chatflow Category Tags @@ -52,17 +63,20 @@ const TagDialog = ({ isOpen, onClose, tags, setTags, onSubmit }) => {
-
- {tags.map((tag, index) => ( - handleDeleteTag(tag)} - style={{ marginRight: 5, marginBottom: 5 }} - /> - ))} -
+ {categoryValues.length > 0 && ( +
+ {categoryValues.map((category, index) => ( + handleDeleteTag(category)} + style={{ marginRight: 5, marginBottom: 5 }} + /> + ))} +
+ )} { label='Add a tag' variant='outlined' /> - + Enter a tag and press enter to add it to the list. You can add as many tags as you want. @@ -88,9 +102,8 @@ const TagDialog = ({ isOpen, onClose, tags, setTags, onSubmit }) => { TagDialog.propTypes = { isOpen: PropTypes.bool, + dialogProps: PropTypes.object, onClose: PropTypes.func, - tags: PropTypes.array, - setTags: PropTypes.func, onSubmit: PropTypes.func } diff --git a/packages/ui/src/ui-component/table/FlowListTable.js b/packages/ui/src/ui-component/table/FlowListTable.js index e33a8ba13..c879d82d5 100644 --- a/packages/ui/src/ui-component/table/FlowListTable.js +++ b/packages/ui/src/ui-component/table/FlowListTable.js @@ -9,9 +9,9 @@ import TableContainer from '@mui/material/TableContainer' import TableHead from '@mui/material/TableHead' import TableRow from '@mui/material/TableRow' import Paper from '@mui/material/Paper' +import Chip from '@mui/material/Chip' import { Button, Stack, Typography } from '@mui/material' import FlowListMenu from '../button/FlowListMenu' -import Chip from '@mui/material/Chip' const StyledTableCell = styled(TableCell)(({ theme }) => ({ [`&.${tableCellClasses.head}`]: { From 0b4bf0193123e955fc990a8180139528ff7f6cd5 Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 17 Nov 2023 12:44:16 +0000 Subject: [PATCH 12/29] unhide columns when xs mode --- .../ui/src/ui-component/table/FlowListTable.js | 14 ++++++-------- packages/ui/src/views/chatflows/index.js | 7 +------ 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/ui-component/table/FlowListTable.js b/packages/ui/src/ui-component/table/FlowListTable.js index c879d82d5..e3baa2e28 100644 --- a/packages/ui/src/ui-component/table/FlowListTable.js +++ b/packages/ui/src/ui-component/table/FlowListTable.js @@ -51,13 +51,13 @@ export const FlowListTable = ({ data, images, filterFunction, updateFlowsApi }) Category - + Nodes - + Last Modified Date - + Actions
@@ -90,7 +90,7 @@ export const FlowListTable = ({ data, images, filterFunction, updateFlowsApi }) ))}
- + {images[row.id] && (
)} - - {moment(row.updatedDate).format('MMMM Do, YYYY')} - - + {moment(row.updatedDate).format('MMMM Do, YYYY')} + diff --git a/packages/ui/src/views/chatflows/index.js b/packages/ui/src/views/chatflows/index.js index 34c6523b7..7f288a952 100644 --- a/packages/ui/src/views/chatflows/index.js +++ b/packages/ui/src/views/chatflows/index.js @@ -152,12 +152,7 @@ const Chatflows = () => { /> - + Date: Sat, 18 Nov 2023 16:47:15 +0530 Subject: [PATCH 13/29] API Keys: Displaying the names of the chatflows associated with the keys and Warning the user before deletion. --- packages/server/src/index.ts | 30 +++++- packages/ui/src/views/apikey/index.js | 138 +++++++++++++++----------- 2 files changed, 104 insertions(+), 64 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index ba6c3ce0e..4307946ba 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1135,28 +1135,50 @@ export class App { // API Keys // ---------------------------------------- + const addChatflowsCount = async (keys: any, res: Response) => { + if (keys) { + const updatedKeys: any[] = [] + //iterate through keys and get chatflows + for (const key of keys) { + const chatflows = await this.AppDataSource.getRepository(ChatFlow) + .createQueryBuilder('cf') + .where('cf.apikeyid = :apikeyid', { apikeyid: key.id }) + .getMany() + const linkedChatFlows: any[] = [] + chatflows.map((cf) => { + linkedChatFlows.push({ + flowName: cf.name + }) + }) + key.chatFlows = linkedChatFlows + updatedKeys.push(key) + } + return res.json(updatedKeys) + } + return res.json(keys) + } // Get api keys this.app.get('/api/v1/apikey', async (req: Request, res: Response) => { const keys = await getAPIKeys() - return res.json(keys) + return addChatflowsCount(keys, res) }) // Add new api key this.app.post('/api/v1/apikey', async (req: Request, res: Response) => { const keys = await addAPIKey(req.body.keyName) - return res.json(keys) + return addChatflowsCount(keys, res) }) // Update api key this.app.put('/api/v1/apikey/:id', async (req: Request, res: Response) => { const keys = await updateAPIKey(req.params.id, req.body.keyName) - return res.json(keys) + return addChatflowsCount(keys, res) }) // Delete new api key this.app.delete('/api/v1/apikey/:id', async (req: Request, res: Response) => { const keys = await deleteAPIKey(req.params.id) - return res.json(keys) + return addChatflowsCount(keys, res) }) // Verify api key diff --git a/packages/ui/src/views/apikey/index.js b/packages/ui/src/views/apikey/index.js index a2b2e639f..226baaee3 100644 --- a/packages/ui/src/views/apikey/index.js +++ b/packages/ui/src/views/apikey/index.js @@ -6,6 +6,7 @@ import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackba import { Button, Box, + Chip, Stack, Table, TableBody, @@ -37,7 +38,7 @@ import useConfirm from 'hooks/useConfirm' import useNotifier from 'utils/useNotifier' // Icons -import { IconTrash, IconEdit, IconCopy, IconX, IconPlus, IconEye, IconEyeOff } from '@tabler/icons' +import { IconTrash, IconEdit, IconCopy, IconCornerDownRight, IconX, IconPlus, IconEye, IconEyeOff } from '@tabler/icons' import APIEmptySVG from 'assets/images/api_empty.svg' // ==============================|| APIKey ||============================== // @@ -106,7 +107,10 @@ const APIKey = () => { const deleteKey = async (key) => { const confirmPayload = { title: `Delete`, - description: `Delete key ${key.keyName}?`, + description: + key.chatFlows.length === 0 + ? `Delete key [${key.keyName}] ? ` + : `Delete key [${key.keyName}] ?\n There are ${key.chatFlows.length} chatflows using this key.`, confirmButtonName: 'Delete', cancelButtonName: 'Cancel' } @@ -193,6 +197,7 @@ const APIKey = () => { Key Name API Key + Usage Created @@ -200,65 +205,78 @@ const APIKey = () => { {apiKeys.map((key, index) => ( - - - {key.keyName} - - - {showApiKeys.includes(key.apiKey) - ? key.apiKey - : `${key.apiKey.substring(0, 2)}${'•'.repeat(18)}${key.apiKey.substring( - key.apiKey.length - 5 - )}`} - { - navigator.clipboard.writeText(key.apiKey) - setAnchorEl(event.currentTarget) - setTimeout(() => { - handleClosePopOver() - }, 1500) - }} - > - - - onShowApiKeyClick(key.apiKey)}> - {showApiKeys.includes(key.apiKey) ? : } - - - + + + {key.keyName} + + + {showApiKeys.includes(key.apiKey) + ? key.apiKey + : `${key.apiKey.substring(0, 2)}${'•'.repeat(18)}${key.apiKey.substring( + key.apiKey.length - 5 + )}`} + { + navigator.clipboard.writeText(key.apiKey) + setAnchorEl(event.currentTarget) + setTimeout(() => { + handleClosePopOver() + }, 1500) + }} > - Copied! - - - - {key.createdAt} - - edit(key)}> - - - - - deleteKey(key)}> - - - - + + + onShowApiKeyClick(key.apiKey)}> + {showApiKeys.includes(key.apiKey) ? : } + + + + Copied! + + + + {key.chatFlows.length} + {key.createdAt} + + edit(key)}> + + + + + deleteKey(key)}> + + + + + {key.chatFlows.length > 0 && ( + + + {' '} + {key.chatFlows.map((flow, index) => ( + + ))} + + + )} + ))}
From c7add456479fe0e21d80121de6be33f68b55a960 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 20 Nov 2023 00:55:58 +0000 Subject: [PATCH 14/29] add file annotations, sync and delete assistant --- .../agents/OpenAIAssistant/OpenAIAssistant.ts | 125 +++++++++++-- packages/server/src/Interface.ts | 1 + .../src/database/entities/ChatMessage.ts | 3 + ...1021237-AddFileAnnotationsToChatMessage.ts | 12 ++ .../src/database/migrations/mysql/index.ts | 4 +- ...1699481607341-AddUsedToolsToChatMessage.ts | 2 +- ...1021237-AddFileAnnotationsToChatMessage.ts | 11 ++ .../src/database/migrations/postgres/index.ts | 4 +- ...1021237-AddFileAnnotationsToChatMessage.ts | 20 +++ .../src/database/migrations/sqlite/index.ts | 4 +- packages/server/src/index.ts | 16 +- packages/ui/src/api/assistants.js | 3 +- .../ui-component/dialog/ViewMessagesDialog.js | 50 +++++- .../src/views/assistants/AssistantDialog.js | 165 ++++++++++++------ .../views/assistants/DeleteConfirmDialog.js | 47 +++++ .../ui/src/views/chatmessage/ChatMessage.js | 51 +++++- 16 files changed, 436 insertions(+), 82 deletions(-) create mode 100644 packages/server/src/database/migrations/mysql/1700271021237-AddFileAnnotationsToChatMessage.ts create mode 100644 packages/server/src/database/migrations/postgres/1700271021237-AddFileAnnotationsToChatMessage.ts create mode 100644 packages/server/src/database/migrations/sqlite/1700271021237-AddFileAnnotationsToChatMessage.ts create mode 100644 packages/ui/src/views/assistants/DeleteConfirmDialog.js diff --git a/packages/components/nodes/agents/OpenAIAssistant/OpenAIAssistant.ts b/packages/components/nodes/agents/OpenAIAssistant/OpenAIAssistant.ts index 56e1b290e..ed7baf7de 100644 --- a/packages/components/nodes/agents/OpenAIAssistant/OpenAIAssistant.ts +++ b/packages/components/nodes/agents/OpenAIAssistant/OpenAIAssistant.ts @@ -111,7 +111,7 @@ class OpenAIAssistant_Agents implements INode { const openai = new OpenAI({ apiKey: openAIApiKey }) options.logger.info(`Clearing OpenAI Thread ${sessionId}`) - await openai.beta.threads.del(sessionId) + if (sessionId) await openai.beta.threads.del(sessionId) options.logger.info(`Successfully cleared OpenAI Thread ${sessionId}`) } @@ -135,16 +135,25 @@ class OpenAIAssistant_Agents implements INode { const openai = new OpenAI({ apiKey: openAIApiKey }) - // Retrieve assistant try { const assistantDetails = JSON.parse(assistant.details) const openAIAssistantId = assistantDetails.id + + // Retrieve assistant const retrievedAssistant = await openai.beta.assistants.retrieve(openAIAssistantId) if (formattedTools.length) { - let filteredTools = uniqWith([...retrievedAssistant.tools, ...formattedTools], isEqual) + let filteredTools = [] + for (const tool of retrievedAssistant.tools) { + if (tool.type === 'code_interpreter' || tool.type === 'retrieval') filteredTools.push(tool) + } + filteredTools = uniqWith([...filteredTools, ...formattedTools], isEqual) + // filter out tool with empty function filteredTools = filteredTools.filter((tool) => !(tool.type === 'function' && !(tool as any).function)) await openai.beta.assistants.update(openAIAssistantId, { tools: filteredTools }) + } else { + let filteredTools = retrievedAssistant.tools.filter((tool) => tool.type !== 'function') + await openai.beta.assistants.update(openAIAssistantId, { tools: filteredTools }) } const chatmessage = await appDataSource.getRepository(databaseEntities['ChatMessage']).findOneBy({ @@ -152,14 +161,45 @@ class OpenAIAssistant_Agents implements INode { }) let threadId = '' + let isNewThread = false if (!chatmessage) { const thread = await openai.beta.threads.create({}) threadId = thread.id + isNewThread = true } else { const thread = await openai.beta.threads.retrieve(chatmessage.sessionId) threadId = thread.id } + // List all runs + if (!isNewThread) { + const promise = (threadId: string) => { + return new Promise((resolve) => { + const timeout = setInterval(async () => { + const allRuns = await openai.beta.threads.runs.list(threadId) + if (allRuns.data && allRuns.data.length) { + const firstRunId = allRuns.data[0].id + const runStatus = allRuns.data.find((run) => run.id === firstRunId)?.status + if ( + runStatus && + (runStatus === 'cancelled' || + runStatus === 'completed' || + runStatus === 'expired' || + runStatus === 'failed') + ) { + clearInterval(timeout) + resolve() + } + } else { + clearInterval(timeout) + resolve() + } + }, 500) + }) + } + await promise(threadId) + } + // Add message to thread await openai.beta.threads.messages.create(threadId, { role: 'user', @@ -217,27 +257,41 @@ class OpenAIAssistant_Agents implements INode { }) resolve(state) } else { - reject( - new Error( - `Error processing thread: ${state}, Thread ID: ${threadId}, Run ID: ${runId}. submit_tool_outputs.tool_calls are empty` - ) - ) + await openai.beta.threads.runs.cancel(threadId, runId) + resolve('requires_action_retry') } } } else if (state === 'cancelled' || state === 'expired' || state === 'failed') { clearInterval(timeout) - reject(new Error(`Error processing thread: ${state}, Thread ID: ${threadId}, Run ID: ${runId}`)) + reject( + new Error(`Error processing thread: ${state}, Thread ID: ${threadId}, Run ID: ${runId}, Status: ${state}`) + ) } }, 500) }) } // Polling run status + let runThreadId = runThread.id let state = await promise(threadId, runThread.id) while (state === 'requires_action') { state = await promise(threadId, runThread.id) } + let retries = 3 + while (state === 'requires_action_retry') { + if (retries > 0) { + retries -= 1 + const newRunThread = await openai.beta.threads.runs.create(threadId, { + assistant_id: retrievedAssistant.id + }) + runThreadId = newRunThread.id + state = await promise(threadId, newRunThread.id) + } else { + throw new Error(`Error processing thread: ${state}, Thread ID: ${threadId}`) + } + } + // List messages const messages = await openai.beta.threads.messages.list(threadId) const messageData = messages.data ?? [] @@ -245,12 +299,58 @@ class OpenAIAssistant_Agents implements INode { if (!assistantMessages.length) return '' let returnVal = '' + const fileAnnotations = [] for (let i = 0; i < assistantMessages[0].content.length; i += 1) { if (assistantMessages[0].content[i].type === 'text') { const content = assistantMessages[0].content[i] as MessageContentText - returnVal += content.text.value - //TODO: handle annotations + if (content.text.annotations) { + const message_content = content.text + const annotations = message_content.annotations + + const dirPath = path.join(getUserHome(), '.flowise', 'openai-assistant') + + // Iterate over the annotations and add footnotes + for (let index = 0; index < annotations.length; index++) { + const annotation = annotations[index] + let filePath = '' + + // Gather citations based on annotation attributes + const file_citation = (annotation as OpenAI.Beta.Threads.Messages.MessageContentText.Text.FileCitation) + .file_citation + if (file_citation) { + const cited_file = await openai.files.retrieve(file_citation.file_id) + // eslint-disable-next-line no-useless-escape + const fileName = cited_file.filename.split(/[\/\\]/).pop() ?? cited_file.filename + filePath = path.join(getUserHome(), '.flowise', 'openai-assistant', fileName) + await downloadFile(cited_file, filePath, dirPath, openAIApiKey) + fileAnnotations.push({ + filePath, + fileName + }) + } else { + const file_path = (annotation as OpenAI.Beta.Threads.Messages.MessageContentText.Text.FilePath).file_path + if (file_path) { + const cited_file = await openai.files.retrieve(file_path.file_id) + // eslint-disable-next-line no-useless-escape + const fileName = cited_file.filename.split(/[\/\\]/).pop() ?? cited_file.filename + filePath = path.join(getUserHome(), '.flowise', 'openai-assistant', fileName) + await downloadFile(cited_file, filePath, dirPath, openAIApiKey) + fileAnnotations.push({ + filePath, + fileName + }) + } + } + + // Replace the text with a footnote + message_content.value = message_content.value.replace(`${annotation.text}`, `${filePath}`) + } + + returnVal += message_content.value + } else { + returnVal += content.text.value + } } else { const content = assistantMessages[0].content[i] as MessageContentImageFile const fileId = content.image_file.file_id @@ -271,7 +371,8 @@ class OpenAIAssistant_Agents implements INode { return { text: returnVal, usedTools, - assistant: { assistantId: openAIAssistantId, threadId, runId: runThread.id, messages: messageData } + fileAnnotations, + assistant: { assistantId: openAIAssistantId, threadId, runId: runThreadId, messages: messageData } } } catch (error) { throw new Error(error) diff --git a/packages/server/src/Interface.ts b/packages/server/src/Interface.ts index 8d0965f48..1d2724642 100644 --- a/packages/server/src/Interface.ts +++ b/packages/server/src/Interface.ts @@ -30,6 +30,7 @@ export interface IChatMessage { chatflowid: string sourceDocuments?: string usedTools?: string + fileAnnotations?: string chatType: string chatId: string memoryType?: string diff --git a/packages/server/src/database/entities/ChatMessage.ts b/packages/server/src/database/entities/ChatMessage.ts index b51aa4340..4054a26dd 100644 --- a/packages/server/src/database/entities/ChatMessage.ts +++ b/packages/server/src/database/entities/ChatMessage.ts @@ -23,6 +23,9 @@ export class ChatMessage implements IChatMessage { @Column({ nullable: true, type: 'text' }) usedTools?: string + @Column({ nullable: true, type: 'text' }) + fileAnnotations?: string + @Column() chatType: string diff --git a/packages/server/src/database/migrations/mysql/1700271021237-AddFileAnnotationsToChatMessage.ts b/packages/server/src/database/migrations/mysql/1700271021237-AddFileAnnotationsToChatMessage.ts new file mode 100644 index 000000000..a352cde8c --- /dev/null +++ b/packages/server/src/database/migrations/mysql/1700271021237-AddFileAnnotationsToChatMessage.ts @@ -0,0 +1,12 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddFileAnnotationsToChatMessage1700271021237 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const columnExists = await queryRunner.hasColumn('chat_message', 'fileAnnotations') + if (!columnExists) queryRunner.query(`ALTER TABLE \`chat_message\` ADD COLUMN \`fileAnnotations\` TEXT;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`chat_message\` DROP COLUMN \`fileAnnotations\`;`) + } +} diff --git a/packages/server/src/database/migrations/mysql/index.ts b/packages/server/src/database/migrations/mysql/index.ts index 4b7b8a954..eff089cda 100644 --- a/packages/server/src/database/migrations/mysql/index.ts +++ b/packages/server/src/database/migrations/mysql/index.ts @@ -8,6 +8,7 @@ import { AddAnalytic1694432361423 } from './1694432361423-AddAnalytic' import { AddChatHistory1694658767766 } from './1694658767766-AddChatHistory' import { AddAssistantEntity1699325775451 } from './1699325775451-AddAssistantEntity' import { AddUsedToolsToChatMessage1699481607341 } from './1699481607341-AddUsedToolsToChatMessage' +import { AddFileAnnotationsToChatMessage1700271021237 } from './1700271021237-AddFileAnnotationsToChatMessage' export const mysqlMigrations = [ Init1693840429259, @@ -19,5 +20,6 @@ export const mysqlMigrations = [ AddAnalytic1694432361423, AddChatHistory1694658767766, AddAssistantEntity1699325775451, - AddUsedToolsToChatMessage1699481607341 + AddUsedToolsToChatMessage1699481607341, + AddFileAnnotationsToChatMessage1700271021237 ] diff --git a/packages/server/src/database/migrations/postgres/1699481607341-AddUsedToolsToChatMessage.ts b/packages/server/src/database/migrations/postgres/1699481607341-AddUsedToolsToChatMessage.ts index f9f893f82..ae34c8132 100644 --- a/packages/server/src/database/migrations/postgres/1699481607341-AddUsedToolsToChatMessage.ts +++ b/packages/server/src/database/migrations/postgres/1699481607341-AddUsedToolsToChatMessage.ts @@ -6,6 +6,6 @@ export class AddUsedToolsToChatMessage1699481607341 implements MigrationInterfac } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "usedTools";`) + await queryRunner.query(`ALTER TABLE "chat_message" DROP COLUMN "usedTools";`) } } diff --git a/packages/server/src/database/migrations/postgres/1700271021237-AddFileAnnotationsToChatMessage.ts b/packages/server/src/database/migrations/postgres/1700271021237-AddFileAnnotationsToChatMessage.ts new file mode 100644 index 000000000..8824f57d5 --- /dev/null +++ b/packages/server/src/database/migrations/postgres/1700271021237-AddFileAnnotationsToChatMessage.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddFileAnnotationsToChatMessage1700271021237 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_message" ADD COLUMN IF NOT EXISTS "fileAnnotations" TEXT;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_message" DROP COLUMN "fileAnnotations";`) + } +} diff --git a/packages/server/src/database/migrations/postgres/index.ts b/packages/server/src/database/migrations/postgres/index.ts index 75562c0b5..93d02f3e0 100644 --- a/packages/server/src/database/migrations/postgres/index.ts +++ b/packages/server/src/database/migrations/postgres/index.ts @@ -8,6 +8,7 @@ import { AddAnalytic1694432361423 } from './1694432361423-AddAnalytic' import { AddChatHistory1694658756136 } from './1694658756136-AddChatHistory' import { AddAssistantEntity1699325775451 } from './1699325775451-AddAssistantEntity' import { AddUsedToolsToChatMessage1699481607341 } from './1699481607341-AddUsedToolsToChatMessage' +import { AddFileAnnotationsToChatMessage1700271021237 } from './1700271021237-AddFileAnnotationsToChatMessage' export const postgresMigrations = [ Init1693891895163, @@ -19,5 +20,6 @@ export const postgresMigrations = [ AddAnalytic1694432361423, AddChatHistory1694658756136, AddAssistantEntity1699325775451, - AddUsedToolsToChatMessage1699481607341 + AddUsedToolsToChatMessage1699481607341, + AddFileAnnotationsToChatMessage1700271021237 ] diff --git a/packages/server/src/database/migrations/sqlite/1700271021237-AddFileAnnotationsToChatMessage.ts b/packages/server/src/database/migrations/sqlite/1700271021237-AddFileAnnotationsToChatMessage.ts new file mode 100644 index 000000000..af29fba4b --- /dev/null +++ b/packages/server/src/database/migrations/sqlite/1700271021237-AddFileAnnotationsToChatMessage.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddFileAnnotationsToChatMessage1700271021237 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temp_chat_message" ("id" varchar PRIMARY KEY NOT NULL, "role" varchar NOT NULL, "chatflowid" varchar NOT NULL, "content" text NOT NULL, "sourceDocuments" text, "usedTools" text, "fileAnnotations" text, "createdDate" datetime NOT NULL DEFAULT (datetime('now')), "chatType" VARCHAR NOT NULL DEFAULT 'INTERNAL', "chatId" VARCHAR NOT NULL, "memoryType" VARCHAR, "sessionId" VARCHAR);` + ) + await queryRunner.query( + `INSERT INTO "temp_chat_message" ("id", "role", "chatflowid", "content", "sourceDocuments", "usedTools", "createdDate", "chatType", "chatId", "memoryType", "sessionId") SELECT "id", "role", "chatflowid", "content", "sourceDocuments", "usedTools", "createdDate", "chatType", "chatId", "memoryType", "sessionId" FROM "chat_message";` + ) + await queryRunner.query(`DROP TABLE "chat_message";`) + await queryRunner.query(`ALTER TABLE "temp_chat_message" RENAME TO "chat_message";`) + await queryRunner.query(`CREATE INDEX "IDX_e574527322272fd838f4f0f3d3" ON "chat_message" ("chatflowid") ;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "temp_chat_message";`) + await queryRunner.query(`ALTER TABLE "chat_message" DROP COLUMN "fileAnnotations";`) + } +} diff --git a/packages/server/src/database/migrations/sqlite/index.ts b/packages/server/src/database/migrations/sqlite/index.ts index 4a14fc407..edba59305 100644 --- a/packages/server/src/database/migrations/sqlite/index.ts +++ b/packages/server/src/database/migrations/sqlite/index.ts @@ -8,6 +8,7 @@ import { AddAnalytic1694432361423 } from './1694432361423-AddAnalytic' import { AddChatHistory1694657778173 } from './1694657778173-AddChatHistory' import { AddAssistantEntity1699325775451 } from './1699325775451-AddAssistantEntity' import { AddUsedToolsToChatMessage1699481607341 } from './1699481607341-AddUsedToolsToChatMessage' +import { AddFileAnnotationsToChatMessage1700271021237 } from './1700271021237-AddFileAnnotationsToChatMessage' export const sqliteMigrations = [ Init1693835579790, @@ -19,5 +20,6 @@ export const sqliteMigrations = [ AddAnalytic1694432361423, AddChatHistory1694657778173, AddAssistantEntity1699325775451, - AddUsedToolsToChatMessage1699481607341 + AddUsedToolsToChatMessage1699481607341, + AddFileAnnotationsToChatMessage1700271021237 ] diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index ba6c3ce0e..57245f2cd 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -138,6 +138,7 @@ export class App { '/api/v1/node-icon/', '/api/v1/components-credentials-icon/', '/api/v1/chatflows-streaming', + '/api/v1/openai-assistants-file', '/api/v1/ip' ] this.app.use((req, res, next) => { @@ -782,8 +783,8 @@ export class App { await openai.beta.assistants.update(assistantDetails.id, { name: assistantDetails.name, - description: assistantDetails.description, - instructions: assistantDetails.instructions, + description: assistantDetails.description ?? '', + instructions: assistantDetails.instructions ?? '', model: assistantDetails.model, tools: filteredTools, file_ids: uniqWith( @@ -952,7 +953,7 @@ export class App { const results = await this.AppDataSource.getRepository(Assistant).delete({ id: req.params.id }) - await openai.beta.assistants.del(assistantDetails.id) + if (req.query.isDeleteBoth) await openai.beta.assistants.del(assistantDetails.id) return res.json(results) } catch (error: any) { @@ -961,6 +962,14 @@ export class App { } }) + // Download file from assistant + this.app.post('/api/v1/openai-assistants-file', async (req: Request, res: Response) => { + const filePath = path.join(getUserHome(), '.flowise', 'openai-assistant', req.body.fileName) + res.setHeader('Content-Disposition', 'attachment; filename=' + path.basename(filePath)) + const fileStream = fs.createReadStream(filePath) + fileStream.pipe(res) + }) + // ---------------------------------------- // Configuration // ---------------------------------------- @@ -1499,6 +1508,7 @@ export class App { } if (result?.sourceDocuments) apiMessage.sourceDocuments = JSON.stringify(result.sourceDocuments) if (result?.usedTools) apiMessage.usedTools = JSON.stringify(result.usedTools) + if (result?.fileAnnotations) apiMessage.fileAnnotations = JSON.stringify(result.fileAnnotations) await this.addChatMessage(apiMessage) logger.debug(`[server]: Finished running ${nodeToExecuteData.label} (${nodeToExecuteData.id})`) diff --git a/packages/ui/src/api/assistants.js b/packages/ui/src/api/assistants.js index 63dd5e18a..ac941126d 100644 --- a/packages/ui/src/api/assistants.js +++ b/packages/ui/src/api/assistants.js @@ -12,7 +12,8 @@ const createNewAssistant = (body) => client.post(`/assistants`, body) const updateAssistant = (id, body) => client.put(`/assistants/${id}`, body) -const deleteAssistant = (id) => client.delete(`/assistants/${id}`) +const deleteAssistant = (id, isDeleteBoth) => + isDeleteBoth ? client.delete(`/assistants/${id}?isDeleteBoth=true`) : client.delete(`/assistants/${id}`) export default { getAllAssistants, diff --git a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.js b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.js index 2e52d5963..29a64155c 100644 --- a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.js +++ b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.js @@ -7,6 +7,7 @@ import rehypeMathjax from 'rehype-mathjax' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' +import axios from 'axios' // material-ui import { @@ -28,7 +29,7 @@ import DatePicker from 'react-datepicker' import robotPNG from 'assets/images/robot.png' import userPNG from 'assets/images/account.png' import msgEmptySVG from 'assets/images/message_empty.svg' -import { IconFileExport, IconEraser, IconX } from '@tabler/icons' +import { IconFileExport, IconEraser, IconX, IconDownload } from '@tabler/icons' // Project import import { MemoizedReactMarkdown } from 'ui-component/markdown/MemoizedReactMarkdown' @@ -48,6 +49,7 @@ import useConfirm from 'hooks/useConfirm' // Utils import { isValidURL, removeDuplicateURL } from 'utils/genericHelper' import useNotifier from 'utils/useNotifier' +import { baseURL } from 'store/constant' import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from 'store/actions' @@ -130,6 +132,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { } if (chatmsg.sourceDocuments) msg.sourceDocuments = JSON.parse(chatmsg.sourceDocuments) if (chatmsg.usedTools) msg.usedTools = JSON.parse(chatmsg.usedTools) + if (chatmsg.fileAnnotations) msg.fileAnnotations = JSON.parse(chatmsg.fileAnnotations) if (!Object.prototype.hasOwnProperty.call(obj, chatPK)) { obj[chatPK] = { @@ -253,6 +256,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { } if (chatmsg.sourceDocuments) obj.sourceDocuments = JSON.parse(chatmsg.sourceDocuments) if (chatmsg.usedTools) obj.usedTools = JSON.parse(chatmsg.usedTools) + if (chatmsg.fileAnnotations) obj.fileAnnotations = JSON.parse(chatmsg.fileAnnotations) loadedMessages.push(obj) } @@ -318,6 +322,26 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { window.open(data, '_blank') } + const downloadFile = async (fileAnnotation) => { + try { + const response = await axios.post( + `${baseURL}/api/v1/openai-assistants-file`, + { fileName: fileAnnotation.fileName }, + { responseType: 'blob' } + ) + const blob = new Blob([response.data], { type: response.headers['content-type'] }) + const downloadUrl = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = downloadUrl + link.download = fileAnnotation.fileName + document.body.appendChild(link) + link.click() + link.remove() + } catch (error) { + console.error('Download failed:', error) + } + } + const onSourceDialogClick = (data, title) => { setSourceDialogProps({ data, title }) setSourceDialogOpen(true) @@ -648,6 +672,30 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { {message.message} + {message.fileAnnotations && ( +
+ {message.fileAnnotations.map((fileAnnotation, index) => { + return ( + + ) + })} +
+ )} {message.sourceDocuments && (
{removeDuplicateURL(message).map((source, index) => { diff --git a/packages/ui/src/views/assistants/AssistantDialog.js b/packages/ui/src/views/assistants/AssistantDialog.js index e841fc4fa..30087baed 100644 --- a/packages/ui/src/views/assistants/AssistantDialog.js +++ b/packages/ui/src/views/assistants/AssistantDialog.js @@ -9,12 +9,12 @@ import { Box, Typography, Button, IconButton, Dialog, DialogActions, DialogConte import { StyledButton } from 'ui-component/button/StyledButton' import { TooltipWithParser } from 'ui-component/tooltip/TooltipWithParser' -import ConfirmDialog from 'ui-component/dialog/ConfirmDialog' import { Dropdown } from 'ui-component/dropdown/Dropdown' import { MultiDropdown } from 'ui-component/dropdown/MultiDropdown' import CredentialInputHandler from 'views/canvas/CredentialInputHandler' import { File } from 'ui-component/file/File' import { BackdropLoader } from 'ui-component/loading/BackdropLoader' +import DeleteConfirmDialog from './DeleteConfirmDialog' // Icons import { IconX } from '@tabler/icons' @@ -23,7 +23,6 @@ import { IconX } from '@tabler/icons' import assistantsApi from 'api/assistants' // Hooks -import useConfirm from 'hooks/useConfirm' import useApi from 'hooks/useApi' // utils @@ -71,14 +70,8 @@ const assistantAvailableModels = [ const AssistantDialog = ({ show, dialogProps, onCancel, onConfirm }) => { const portalElement = document.getElementById('portal') - - const dispatch = useDispatch() - - // ==============================|| Snackbar ||============================== // - useNotifier() - const { confirm } = useConfirm() - + const dispatch = useDispatch() const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) @@ -97,6 +90,8 @@ const AssistantDialog = ({ show, dialogProps, onCancel, onConfirm }) => { const [assistantFiles, setAssistantFiles] = useState([]) const [uploadAssistantFiles, setUploadAssistantFiles] = useState('') const [loading, setLoading] = useState(false) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [deleteDialogProps, setDeleteDialogProps] = useState({}) useEffect(() => { if (show) dispatch({ type: SHOW_CANVAS_DIALOG }) @@ -123,20 +118,7 @@ const AssistantDialog = ({ show, dialogProps, onCancel, onConfirm }) => { useEffect(() => { if (getAssistantObjApi.data) { - setOpenAIAssistantId(getAssistantObjApi.data.id) - setAssistantName(getAssistantObjApi.data.name) - setAssistantDesc(getAssistantObjApi.data.description) - setAssistantModel(getAssistantObjApi.data.model) - setAssistantInstructions(getAssistantObjApi.data.instructions) - setAssistantFiles(getAssistantObjApi.data.files ?? []) - - let tools = [] - if (getAssistantObjApi.data.tools && getAssistantObjApi.data.tools.length) { - for (const tool of getAssistantObjApi.data.tools) { - tools.push(tool.type) - } - } - setAssistantTools(tools) + syncData(getAssistantObjApi.data) } }, [getAssistantObjApi.data]) @@ -199,6 +181,23 @@ const AssistantDialog = ({ show, dialogProps, onCancel, onConfirm }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [dialogProps]) + const syncData = (data) => { + setOpenAIAssistantId(data.id) + setAssistantName(data.name) + setAssistantDesc(data.description) + setAssistantModel(data.model) + setAssistantInstructions(data.instructions) + setAssistantFiles(data.files ?? []) + + let tools = [] + if (data.tools && data.tools.length) { + for (const tool of data.tools) { + tools.push(tool.type) + } + } + setAssistantTools(tools) + } + const addNewAssistant = async () => { setLoading(true) try { @@ -309,41 +308,17 @@ const AssistantDialog = ({ show, dialogProps, onCancel, onConfirm }) => { } } - const deleteAssistant = async () => { - const confirmPayload = { - title: `Delete Assistant`, - description: `Delete Assistant ${assistantName}?`, - confirmButtonName: 'Delete', - cancelButtonName: 'Cancel' - } - const isConfirmed = await confirm(confirmPayload) - - if (isConfirmed) { - try { - const delResp = await assistantsApi.deleteAssistant(assistantId) - if (delResp.data) { - enqueueSnackbar({ - message: 'Assistant deleted', - options: { - key: new Date().getTime() + Math.random(), - variant: 'success', - action: (key) => ( - - ) - } - }) - onConfirm() - } - } catch (error) { - const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` + const onSyncClick = async () => { + setLoading(true) + try { + const getResp = await assistantsApi.getAssistantObj(openAIAssistantId, assistantCredential) + if (getResp.data) { + syncData(getResp.data) enqueueSnackbar({ - message: `Failed to delete Assistant: ${errorData}`, + message: 'Assistant successfully synced!', options: { key: new Date().getTime() + Math.random(), - variant: 'error', - persist: true, + variant: 'success', action: (key) => ( + ) + } + }) + setLoading(false) + } + } + + const onDeleteClick = () => { + setDeleteDialogProps({ + title: `Delete Assistant`, + description: `Delete Assistant ${assistantName}?`, + cancelButtonName: 'Cancel' + }) + setDeleteDialogOpen(true) + } + + const deleteAssistant = async (isDeleteBoth) => { + setDeleteDialogOpen(false) + try { + const delResp = await assistantsApi.deleteAssistant(assistantId, isDeleteBoth) + if (delResp.data) { + enqueueSnackbar({ + message: 'Assistant deleted', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + onConfirm() + } + } catch (error) { + const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` + enqueueSnackbar({ + message: `Failed to delete Assistant: ${errorData}`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + onCancel() } } @@ -578,7 +616,12 @@ const AssistantDialog = ({ show, dialogProps, onCancel, onConfirm }) => { {dialogProps.type === 'EDIT' && ( - deleteAssistant()}> + onSyncClick()}> + Sync + + )} + {dialogProps.type === 'EDIT' && ( + onDeleteClick()}> Delete )} @@ -590,7 +633,13 @@ const AssistantDialog = ({ show, dialogProps, onCancel, onConfirm }) => { {dialogProps.confirmButtonName} - + setDeleteDialogOpen(false)} + onDelete={() => deleteAssistant()} + onDeleteBoth={() => deleteAssistant(true)} + /> {loading && } ) : null diff --git a/packages/ui/src/views/assistants/DeleteConfirmDialog.js b/packages/ui/src/views/assistants/DeleteConfirmDialog.js new file mode 100644 index 000000000..f4453631b --- /dev/null +++ b/packages/ui/src/views/assistants/DeleteConfirmDialog.js @@ -0,0 +1,47 @@ +import { createPortal } from 'react-dom' +import PropTypes from 'prop-types' +import { Button, Dialog, DialogContent, DialogTitle } from '@mui/material' +import { StyledButton } from 'ui-component/button/StyledButton' + +const DeleteConfirmDialog = ({ show, dialogProps, onCancel, onDelete, onDeleteBoth }) => { + const portalElement = document.getElementById('portal') + + const component = show ? ( + + + {dialogProps.title} + + + {dialogProps.description} +
+ + Delete only from Flowise + + + Delete from both OpenAI and Flowise + + +
+
+
+ ) : null + + return createPortal(component, portalElement) +} + +DeleteConfirmDialog.propTypes = { + show: PropTypes.bool, + dialogProps: PropTypes.object, + onDeleteBoth: PropTypes.func, + onDelete: PropTypes.func, + onCancel: PropTypes.func +} + +export default DeleteConfirmDialog diff --git a/packages/ui/src/views/chatmessage/ChatMessage.js b/packages/ui/src/views/chatmessage/ChatMessage.js index 0cf5695be..7cfd04740 100644 --- a/packages/ui/src/views/chatmessage/ChatMessage.js +++ b/packages/ui/src/views/chatmessage/ChatMessage.js @@ -7,10 +7,11 @@ import rehypeMathjax from 'rehype-mathjax' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' +import axios from 'axios' -import { CircularProgress, OutlinedInput, Divider, InputAdornment, IconButton, Box, Chip } from '@mui/material' +import { CircularProgress, OutlinedInput, Divider, InputAdornment, IconButton, Box, Chip, Button } from '@mui/material' import { useTheme } from '@mui/material/styles' -import { IconSend } from '@tabler/icons' +import { IconSend, IconDownload } from '@tabler/icons' // project import import { CodeBlock } from 'ui-component/markdown/CodeBlock' @@ -139,7 +140,13 @@ export const ChatMessage = ({ open, chatflowid, isDialog }) => { setMessages((prevMessages) => [ ...prevMessages, - { message: text, sourceDocuments: data?.sourceDocuments, usedTools: data?.usedTools, type: 'apiMessage' } + { + message: text, + sourceDocuments: data?.sourceDocuments, + usedTools: data?.usedTools, + fileAnnotations: data?.fileAnnotations, + type: 'apiMessage' + } ]) } @@ -170,6 +177,26 @@ export const ChatMessage = ({ open, chatflowid, isDialog }) => { } } + const downloadFile = async (fileAnnotation) => { + try { + const response = await axios.post( + `${baseURL}/api/v1/openai-assistants-file`, + { fileName: fileAnnotation.fileName }, + { responseType: 'blob' } + ) + const blob = new Blob([response.data], { type: response.headers['content-type'] }) + const downloadUrl = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = downloadUrl + link.download = fileAnnotation.fileName + document.body.appendChild(link) + link.click() + link.remove() + } catch (error) { + console.error('Download failed:', error) + } + } + // Get chatmessages successful useEffect(() => { if (getChatmessageApi.data?.length) { @@ -183,6 +210,7 @@ export const ChatMessage = ({ open, chatflowid, isDialog }) => { } if (message.sourceDocuments) obj.sourceDocuments = JSON.parse(message.sourceDocuments) if (message.usedTools) obj.usedTools = JSON.parse(message.usedTools) + if (message.fileAnnotations) obj.fileAnnotations = JSON.parse(message.fileAnnotations) return obj }) setMessages((prevMessages) => [...prevMessages, ...loadedMessages]) @@ -331,6 +359,23 @@ export const ChatMessage = ({ open, chatflowid, isDialog }) => { {message.message}
+ {message.fileAnnotations && ( +
+ {message.fileAnnotations.map((fileAnnotation, index) => { + return ( + + ) + })} +
+ )} {message.sourceDocuments && (
{removeDuplicateURL(message).map((source, index) => { From a4a1e7d562dff040f9dd00cc51762e426fe2318f Mon Sep 17 00:00:00 2001 From: vinodkiran Date: Mon, 20 Nov 2023 13:03:28 +0530 Subject: [PATCH 15/29] API Key: Changes to API Key Dashboard to show usage details (chatflows) --- packages/server/src/index.ts | 3 +- packages/ui/src/views/apikey/index.js | 204 ++++++++++++++++---------- 2 files changed, 131 insertions(+), 76 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4307946ba..a8745db73 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1147,7 +1147,8 @@ export class App { const linkedChatFlows: any[] = [] chatflows.map((cf) => { linkedChatFlows.push({ - flowName: cf.name + flowName: cf.name, + updatedDate: cf.updatedDate }) }) key.chatFlows = linkedChatFlows diff --git a/packages/ui/src/views/apikey/index.js b/packages/ui/src/views/apikey/index.js index 226baaee3..72b73baf6 100644 --- a/packages/ui/src/views/apikey/index.js +++ b/packages/ui/src/views/apikey/index.js @@ -6,7 +6,6 @@ import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackba import { Button, Box, - Chip, Stack, Table, TableBody, @@ -17,7 +16,8 @@ import { Paper, IconButton, Popover, - Typography + Typography, + Collapse } from '@mui/material' import { useTheme } from '@mui/material/styles' @@ -38,11 +38,118 @@ import useConfirm from 'hooks/useConfirm' import useNotifier from 'utils/useNotifier' // Icons -import { IconTrash, IconEdit, IconCopy, IconCornerDownRight, IconX, IconPlus, IconEye, IconEyeOff } from '@tabler/icons' +import { IconTrash, IconEdit, IconCopy, IconChevronsUp, IconChevronsDown, IconX, IconPlus, IconEye, IconEyeOff } from '@tabler/icons' import APIEmptySVG from 'assets/images/api_empty.svg' +import * as PropTypes from 'prop-types' // ==============================|| APIKey ||============================== // +function APIKeyRow(props) { + const [open, setOpen] = useState(false) + return ( + <> + *': { borderBottom: 'unset' } }}> + + {props.apiKey.keyName} + + + {props.showApiKeys.includes(props.apiKey.apiKey) + ? props.apiKey.apiKey + : `${props.apiKey.apiKey.substring(0, 2)}${'•'.repeat(18)}${props.apiKey.apiKey.substring( + props.apiKey.apiKey.length - 5 + )}`} + + + + + {props.showApiKeys.includes(props.apiKey.apiKey) ? : } + + + + Copied! + + + + + {props.apiKey.chatFlows.length}{' '} + {props.apiKey.chatFlows.length > 0 && ( + setOpen(!open)}> + {props.apiKey.chatFlows.length > 0 && open ? : } + + )} + + {props.apiKey.createdAt} + + + + + + + + + + + + + + + + + + + Chatflow Name + Modified On + Category + + + + {props.apiKey.chatFlows.map((flow, index) => ( + + + {flow.flowName} + + {flow.updatedDate} + + + ))} + +
+
+
+
+
+ + ) +} + +APIKeyRow.propTypes = { + apiKey: PropTypes.any, + showApiKeys: PropTypes.arrayOf(PropTypes.any), + onCopyClick: PropTypes.func, + onShowAPIClick: PropTypes.func, + open: PropTypes.bool, + anchorEl: PropTypes.any, + onClose: PropTypes.func, + theme: PropTypes.any, + onEditClick: PropTypes.func, + onDeleteClick: PropTypes.func +} const APIKey = () => { const theme = useTheme() const customization = useSelector((state) => state.customization) @@ -205,78 +312,25 @@ const APIKey = () => { {apiKeys.map((key, index) => ( - <> - - - {key.keyName} - - - {showApiKeys.includes(key.apiKey) - ? key.apiKey - : `${key.apiKey.substring(0, 2)}${'•'.repeat(18)}${key.apiKey.substring( - key.apiKey.length - 5 - )}`} - { - navigator.clipboard.writeText(key.apiKey) - setAnchorEl(event.currentTarget) - setTimeout(() => { - handleClosePopOver() - }, 1500) - }} - > - - - onShowApiKeyClick(key.apiKey)}> - {showApiKeys.includes(key.apiKey) ? : } - - - - Copied! - - - - {key.chatFlows.length} - {key.createdAt} - - edit(key)}> - - - - - deleteKey(key)}> - - - - - {key.chatFlows.length > 0 && ( - - - {' '} - {key.chatFlows.map((flow, index) => ( - - ))} - - - )} - + { + navigator.clipboard.writeText(key.apiKey) + setAnchorEl(event.currentTarget) + setTimeout(() => { + handleClosePopOver() + }, 1500) + }} + onShowAPIClick={() => onShowApiKeyClick(key.apiKey)} + open={openPopOver} + anchorEl={anchorEl} + onClose={handleClosePopOver} + theme={theme} + onEditClick={() => edit(key)} + onDeleteClick={() => deleteKey(key)} + /> ))} From c7bf75e2597809fbdb56c04282059ece22f73f66 Mon Sep 17 00:00:00 2001 From: vinodkiran Date: Mon, 20 Nov 2023 18:19:25 +0530 Subject: [PATCH 16/29] UX Changes: persist user display choice in localStorage --- packages/ui/src/views/chatflows/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/views/chatflows/index.js b/packages/ui/src/views/chatflows/index.js index 7f288a952..3c4b89728 100644 --- a/packages/ui/src/views/chatflows/index.js +++ b/packages/ui/src/views/chatflows/index.js @@ -43,9 +43,10 @@ const Chatflows = () => { const [loginDialogProps, setLoginDialogProps] = useState({}) const getAllChatflowsApi = useApi(chatflowsApi.getAllChatflows) - const [view, setView] = React.useState('card') + const [view, setView] = React.useState(localStorage.getItem('flowDisplayStyle') || 'card') const handleChange = (event, nextView) => { + localStorage.setItem('flowDisplayStyle', nextView) setView(nextView) } From 9a3be5f4bf35706adc8cc65466b65c50b85d1f22 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 20 Nov 2023 14:38:33 +0000 Subject: [PATCH 17/29] fix sessionid undefined --- packages/server/src/utils/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/server/src/utils/index.ts b/packages/server/src/utils/index.ts index 239773a9a..6eb979f62 100644 --- a/packages/server/src/utils/index.ts +++ b/packages/server/src/utils/index.ts @@ -985,10 +985,14 @@ export const redactCredentialWithPasswordType = ( * @param {any} instance * @param {string} chatId */ -export const checkMemorySessionId = (instance: any, chatId: string): string => { +export const checkMemorySessionId = (instance: any, chatId: string): string | undefined => { if (instance.memory && instance.memory.isSessionIdUsingChatMessageId && chatId) { instance.memory.sessionId = chatId instance.memory.chatHistory.sessionId = chatId } - return instance.memory ? instance.memory.sessionId ?? instance.memory.chatHistory.sessionId : undefined + + if (instance.memory && instance.memory.sessionId) return instance.memory.sessionId + else if (instance.memory && instance.memory.chatHistory && instance.memory.chatHistory.sessionId) + return instance.memory.chatHistory.sessionId + return undefined } From 40a63008ece274161173daf4c674eb743192fb95 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 20 Nov 2023 19:34:30 +0000 Subject: [PATCH 18/29] add vectara chain --- .../nodes/chains/VectaraChain/VectaraChain.ts | 147 ++++++++++++++++++ .../nodes/chains/VectaraChain/vectara.png | Bin 0 -> 67193 bytes packages/server/src/utils/index.ts | 2 +- .../ui-component/dialog/ViewMessagesDialog.js | 5 +- packages/ui/src/utils/genericHelper.js | 12 +- .../ui/src/views/chatmessage/ChatMessage.js | 5 +- 6 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 packages/components/nodes/chains/VectaraChain/VectaraChain.ts create mode 100644 packages/components/nodes/chains/VectaraChain/vectara.png diff --git a/packages/components/nodes/chains/VectaraChain/VectaraChain.ts b/packages/components/nodes/chains/VectaraChain/VectaraChain.ts new file mode 100644 index 000000000..a2fac534e --- /dev/null +++ b/packages/components/nodes/chains/VectaraChain/VectaraChain.ts @@ -0,0 +1,147 @@ +import { INode, INodeData, INodeParams } from '../../../src/Interface' +import { getBaseClasses } from '../../../src/utils' +import { VectorDBQAChain } from 'langchain/chains' +import { Document } from 'langchain/document' +import { VectaraStore } from 'langchain/vectorstores/vectara' +import fetch from 'node-fetch' + +class VectaraChain_Chains implements INode { + label: string + name: string + version: number + type: string + icon: string + category: string + baseClasses: string[] + description: string + inputs: INodeParams[] + + constructor() { + this.label = 'Vectara QA Chain' + this.name = 'vectaraQAChain' + this.version = 1.0 + this.type = 'VectaraQAChain' + this.icon = 'vectara.png' + this.category = 'Chains' + this.description = 'QA chain for Vectara' + this.baseClasses = [this.type, ...getBaseClasses(VectorDBQAChain)] + this.inputs = [ + { + label: 'Vectara Vector Store', + name: 'vectaraStore', + type: 'VectorStore' + } + ] + } + + async init(): Promise { + return null + } + + async run(nodeData: INodeData, input: string): Promise { + const vectorStore = nodeData.inputs?.vectaraStore as VectaraStore + const topK = (vectorStore as any)?.k ?? 4 + + const headers = await vectorStore.getJsonHeader() + const vectaraFilter = (vectorStore as any).vectaraFilter ?? {} + const corpusId: number[] = (vectorStore as any).corpusId ?? [] + const customerId = (vectorStore as any).customerId ?? '' + + const corpusKeys = corpusId.map((corpusId) => ({ + customerId, + corpusId, + metadataFilter: vectaraFilter?.filter ?? '', + lexicalInterpolationConfig: { lambda: vectaraFilter?.lambda ?? 0.025 } + })) + + let summarizerPromptName = 'vectara-experimental-summary-ext-2023-10-23-med' // can let user select + let responseLang = 'en' // can let user select + let maxSummarizedResults = 5 // can let user specify + + const data = { + query: [ + { + query: input, + start: 0, + numResults: topK, + contextConfig: { + sentencesAfter: vectaraFilter?.contextConfig?.sentencesAfter ?? 2, + sentencesBefore: vectaraFilter?.contextConfig?.sentencesBefore ?? 2 + }, + corpusKey: corpusKeys, + summary: [ + { + summarizerPromptName, + responseLang, + maxSummarizedResults + } + ] + } + ] + } + + try { + const response = await fetch(`https://api.vectara.io/v1/query`, { + method: 'POST', + headers: headers?.headers, + body: JSON.stringify(data) + }) + + if (response.status !== 200) { + throw new Error(`Vectara API returned status code ${response.status}`) + } + + const result = await response.json() + const responses = result.responseSet[0].response + const documents = result.responseSet[0].document + let summarizedText = '' + + for (let i = 0; i < responses.length; i += 1) { + const responseMetadata = responses[i].metadata + const documentMetadata = documents[responses[i].documentIndex].metadata + const combinedMetadata: Record = {} + + responseMetadata.forEach((item: { name: string; value: unknown }) => { + combinedMetadata[item.name] = item.value + }) + + documentMetadata.forEach((item: { name: string; value: unknown }) => { + combinedMetadata[item.name] = item.value + }) + + responses[i].metadata = combinedMetadata + } + + const summaryStatus = result.responseSet[0].summary[0].status + if (summaryStatus.length > 0 && summaryStatus[0].code === 'BAD_REQUEST') { + throw new Error( + `BAD REQUEST: Too much text for the summarizer to summarize. Please try reducing the number of search results to summarize, or the context of each result by adjusting the 'summary_num_sentences', and 'summary_num_results' parameters respectively.` + ) + } + + if ( + summaryStatus.length > 0 && + summaryStatus[0].code === 'NOT_FOUND' && + summaryStatus[0].statusDetail === 'Failed to retrieve summarizer.' + ) { + throw new Error(`BAD REQUEST: summarizer ${summarizerPromptName} is invalid for this account.`) + } + + summarizedText = result.responseSet[0].summary[0]?.text + + const sourceDocuments: Document[] = responses.map( + (response: { text: string; metadata: Record; score: number }) => + new Document({ + pageContent: response.text, + metadata: response.metadata + }) + ) + + return { text: summarizedText, sourceDocuments: sourceDocuments } + } catch (error) { + throw new Error(error) + } + } +} + +module.exports = { nodeClass: VectaraChain_Chains } diff --git a/packages/components/nodes/chains/VectaraChain/vectara.png b/packages/components/nodes/chains/VectaraChain/vectara.png new file mode 100644 index 0000000000000000000000000000000000000000..a13a34e6b837a94f70e01253cd3e17aa02b68f48 GIT binary patch literal 67193 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%0A)QN)(hd`;d-QP6WmNWEHJ!9n^=PgZ@EK~ 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 r=_yJBf`HDk literal 0 HcmV?d00001 diff --git a/packages/server/src/utils/index.ts b/packages/server/src/utils/index.ts index 239773a9a..616454a57 100644 --- a/packages/server/src/utils/index.ts +++ b/packages/server/src/utils/index.ts @@ -842,7 +842,7 @@ export const isFlowValidForStream = (reactFlowNodes: IReactFlowNode[], endingNod let isValidChainOrAgent = false if (endingNodeData.category === 'Chains') { // Chains that are not available to stream - const blacklistChains = ['openApiChain'] + const blacklistChains = ['openApiChain', 'vectaraQAChain'] isValidChainOrAgent = !blacklistChains.includes(endingNodeData.name) } else if (endingNodeData.category === 'Agents') { // Agent that are available to stream diff --git a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.js b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.js index 29a64155c..cadd4abd9 100644 --- a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.js +++ b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.js @@ -699,7 +699,10 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { {message.sourceDocuments && (
{removeDuplicateURL(message).map((source, index) => { - const URL = isValidURL(source.metadata.source) + const URL = + source.metadata && source.metadata.source + ? isValidURL(source.metadata.source) + : undefined return ( { if (!message.sourceDocuments) return newSourceDocuments message.sourceDocuments.forEach((source) => { - if (isValidURL(source.metadata.source) && !visitedURLs.includes(source.metadata.source)) { - visitedURLs.push(source.metadata.source) - newSourceDocuments.push(source) - } else if (!isValidURL(source.metadata.source)) { + if (source.metadata && source.metadata.source) { + if (isValidURL(source.metadata.source) && !visitedURLs.includes(source.metadata.source)) { + visitedURLs.push(source.metadata.source) + newSourceDocuments.push(source) + } else if (!isValidURL(source.metadata.source)) { + newSourceDocuments.push(source) + } + } else { newSourceDocuments.push(source) } }) diff --git a/packages/ui/src/views/chatmessage/ChatMessage.js b/packages/ui/src/views/chatmessage/ChatMessage.js index 7cfd04740..7e805f7e9 100644 --- a/packages/ui/src/views/chatmessage/ChatMessage.js +++ b/packages/ui/src/views/chatmessage/ChatMessage.js @@ -379,7 +379,10 @@ export const ChatMessage = ({ open, chatflowid, isDialog }) => { {message.sourceDocuments && (
{removeDuplicateURL(message).map((source, index) => { - const URL = isValidURL(source.metadata.source) + const URL = + source.metadata && source.metadata.source + ? isValidURL(source.metadata.source) + : undefined return ( Date: Tue, 21 Nov 2023 10:56:32 +0000 Subject: [PATCH 19/29] add more options --- .../nodes/chains/VectaraChain/VectaraChain.ts | 172 +++++++++++++++++- 1 file changed, 166 insertions(+), 6 deletions(-) diff --git a/packages/components/nodes/chains/VectaraChain/VectaraChain.ts b/packages/components/nodes/chains/VectaraChain/VectaraChain.ts index a2fac534e..143c6d5b7 100644 --- a/packages/components/nodes/chains/VectaraChain/VectaraChain.ts +++ b/packages/components/nodes/chains/VectaraChain/VectaraChain.ts @@ -27,9 +27,168 @@ class VectaraChain_Chains implements INode { this.baseClasses = [this.type, ...getBaseClasses(VectorDBQAChain)] this.inputs = [ { - label: 'Vectara Vector Store', + label: 'Vectara Store', name: 'vectaraStore', type: 'VectorStore' + }, + { + label: 'Summarizer Prompt Name', + name: 'summarizerPromptName', + description: + 'Summarize the results fetched from Vectara. Read more', + type: 'options', + options: [ + { + label: 'vectara-summary-ext-v1.2.0 (gpt-3.5-turbo)', + name: 'vectara-summary-ext-v1.2.0' + }, + { + label: 'vectara-experimental-summary-ext-2023-10-23-small (gpt-3.5-turbo)', + name: 'vectara-experimental-summary-ext-2023-10-23-small', + description: 'In beta, available to both Growth and Scale Vectara users' + }, + { + label: 'vectara-summary-ext-v1.3.0 (gpt-4.0)', + name: 'vectara-summary-ext-v1.3.0', + description: 'Only available to paying Scale Vectara users' + }, + { + label: 'vectara-experimental-summary-ext-2023-10-23-med (gpt-4.0)', + name: 'vectara-experimental-summary-ext-2023-10-23-med', + description: 'In beta, only available to paying Scale Vectara users' + } + ], + default: 'vectara-summary-ext-v1.2.0' + }, + { + label: 'Response Language', + name: 'responseLang', + description: + 'Return the response in specific language. If not selected, Vectara will automatically detects the language. Read more', + type: 'options', + options: [ + { + label: 'English', + name: 'eng' + }, + { + label: 'German', + name: 'deu' + }, + { + label: 'French', + name: 'fra' + }, + { + label: 'Chinese', + name: 'zho' + }, + { + label: 'Korean', + name: 'kor' + }, + { + label: 'Arabic', + name: 'ara' + }, + { + label: 'Russian', + name: 'rus' + }, + { + label: 'Thai', + name: 'tha' + }, + { + label: 'Dutch', + name: 'nld' + }, + { + label: 'Italian', + name: 'ita' + }, + { + label: 'Portuguese', + name: 'por' + }, + { + label: 'Spanish', + name: 'spa' + }, + { + label: 'Japanese', + name: 'jpn' + }, + { + label: 'Polish', + name: 'pol' + }, + { + label: 'Turkish', + name: 'tur' + }, + { + label: 'Vietnamese', + name: 'vie' + }, + { + label: 'Indonesian', + name: 'ind' + }, + { + label: 'Czech', + name: 'ces' + }, + { + label: 'Ukrainian', + name: 'ukr' + }, + { + label: 'Greek', + name: 'ell' + }, + { + label: 'Hebrew', + name: 'heb' + }, + { + label: 'Farsi/Persian', + name: 'fas' + }, + { + label: 'Hindi', + name: 'hin' + }, + { + label: 'Urdu', + name: 'urd' + }, + { + label: 'Swedish', + name: 'swe' + }, + { + label: 'Bengali', + name: 'ben' + }, + { + label: 'Malay', + name: 'msa' + }, + { + label: 'Romanian', + name: 'ron' + } + ], + optional: true, + default: 'eng' + }, + { + label: 'Max Summarized Results', + name: 'maxSummarizedResults', + description: 'Maximum results used to build the summarized response', + type: 'number', + default: 7 } ] } @@ -40,7 +199,12 @@ class VectaraChain_Chains implements INode { async run(nodeData: INodeData, input: string): Promise { const vectorStore = nodeData.inputs?.vectaraStore as VectaraStore - const topK = (vectorStore as any)?.k ?? 4 + const responseLang = (nodeData.inputs?.responseLang as string) ?? 'auto' + const summarizerPromptName = nodeData.inputs?.summarizerPromptName as string + const maxSummarizedResultsStr = nodeData.inputs?.maxSummarizedResults as string + const maxSummarizedResults = maxSummarizedResultsStr ? parseInt(maxSummarizedResultsStr, 10) : 7 + + const topK = (vectorStore as any)?.k ?? 10 const headers = await vectorStore.getJsonHeader() const vectaraFilter = (vectorStore as any).vectaraFilter ?? {} @@ -54,10 +218,6 @@ class VectaraChain_Chains implements INode { lexicalInterpolationConfig: { lambda: vectaraFilter?.lambda ?? 0.025 } })) - let summarizerPromptName = 'vectara-experimental-summary-ext-2023-10-23-med' // can let user select - let responseLang = 'en' // can let user select - let maxSummarizedResults = 5 // can let user specify - const data = { query: [ { From c716c1972aeebf50aa7a9bc9086061a72a9716a5 Mon Sep 17 00:00:00 2001 From: vinodkiran Date: Tue, 21 Nov 2023 18:40:20 +0530 Subject: [PATCH 20/29] API Key: UX Fixes and adjustments post the dashboard updates --- packages/server/src/index.ts | 1 + packages/ui/src/views/apikey/index.js | 36 +++++++++++++++------------ 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 922aa307e..2f7d31e25 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1161,6 +1161,7 @@ export class App { chatflows.map((cf) => { linkedChatFlows.push({ flowName: cf.name, + category: cf.category, updatedDate: cf.updatedDate }) }) diff --git a/packages/ui/src/views/apikey/index.js b/packages/ui/src/views/apikey/index.js index 96b0d1de0..73224cb2e 100644 --- a/packages/ui/src/views/apikey/index.js +++ b/packages/ui/src/views/apikey/index.js @@ -6,6 +6,7 @@ import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackba import { Button, Box, + Chip, Stack, Table, TableBody, @@ -56,6 +57,7 @@ import { } from '@tabler/icons' import APIEmptySVG from 'assets/images/api_empty.svg' import * as PropTypes from 'prop-types' +import moment from 'moment/moment' // ==============================|| APIKey ||============================== // @@ -63,10 +65,8 @@ function APIKeyRow(props) { const [open, setOpen] = useState(false) return ( <> - *': { borderBottom: 'unset' } }}> - - {props.apiKey.keyName} - + + {props.apiKey.keyName} {props.showApiKeys.includes(props.apiKey.apiKey) ? props.apiKey.apiKey @@ -118,19 +118,15 @@ function APIKeyRow(props) { - + - +
- Chatflow Name - Modified On - Category + Chatflow Name + Modified On + Category @@ -139,8 +135,16 @@ function APIKeyRow(props) { {flow.flowName} - {flow.updatedDate} - + {moment(flow.updatedDate).format('DD-MMM-YY')} + +   + {flow.category && + flow.category + .split(';') + .map((tag, index) => ( + + ))} + ))} @@ -375,7 +379,7 @@ const APIKey = () => { - {apiKeys.map((key, index) => ( + {apiKeys.filter(filterKeys).map((key, index) => ( Date: Tue, 21 Nov 2023 07:52:00 -0800 Subject: [PATCH 21/29] reorder citations in Vectara response --- .../nodes/chains/VectaraChain/VectaraChain.ts | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/components/nodes/chains/VectaraChain/VectaraChain.ts b/packages/components/nodes/chains/VectaraChain/VectaraChain.ts index 143c6d5b7..2f7d09a2b 100644 --- a/packages/components/nodes/chains/VectaraChain/VectaraChain.ts +++ b/packages/components/nodes/chains/VectaraChain/VectaraChain.ts @@ -5,6 +5,42 @@ import { Document } from 'langchain/document' import { VectaraStore } from 'langchain/vectorstores/vectara' import fetch from 'node-fetch' +// functionality based on https://github.com/vectara/vectara-answer +const reorderCitations = (unorderedSummary: string) => { + const allCitations = unorderedSummary.match(/\[\d+\]/g) || []; + + const uniqueCitations = [...new Set(allCitations)]; + const citationToReplacement: { [key: string]: string } = {}; + uniqueCitations.forEach((citation, index) => { + citationToReplacement[citation] = `[${index + 1}]`; + }); + + return unorderedSummary.replace( + /\[\d+\]/g, + (match) => citationToReplacement[match] + ); +}; +const applyCitationOrder = ( + searchResults: any[], + unorderedSummary: string + ) => { + const orderedSearchResults: any[] = []; + const allCitations = unorderedSummary.match(/\[\d+\]/g) || []; + + const addedIndices = new Set(); + for (let i = 0; i < allCitations.length; i++) { + const citation = allCitations[i]; + const index = Number(citation.slice(1, citation.length - 1)) - 1; + + if (addedIndices.has(index)) continue; + orderedSearchResults.push(searchResults[index]); + addedIndices.add(index); + } + + return orderedSearchResults; +}; + + class VectaraChain_Chains implements INode { label: string name: string @@ -254,7 +290,7 @@ class VectaraChain_Chains implements INode { const result = await response.json() const responses = result.responseSet[0].response const documents = result.responseSet[0].document - let summarizedText = '' + let rawSummarizedText = '' for (let i = 0; i < responses.length; i += 1) { const responseMetadata = responses[i].metadata @@ -287,9 +323,12 @@ class VectaraChain_Chains implements INode { throw new Error(`BAD REQUEST: summarizer ${summarizerPromptName} is invalid for this account.`) } - summarizedText = result.responseSet[0].summary[0]?.text + rawSummarizedText = result.responseSet[0].summary[0]?.text - const sourceDocuments: Document[] = responses.map( + let summarizedText = reorderCitations(rawSummarizedText); + let summaryResponses = applyCitationOrder(responses, rawSummarizedText); + + const sourceDocuments: Document[] = summaryResponses.map( (response: { text: string; metadata: Record; score: number }) => new Document({ pageContent: response.text, From a4c3250a67ea7a2de10b289b4bc842cd19e194f2 Mon Sep 17 00:00:00 2001 From: Ofer Mendelevitch Date: Tue, 21 Nov 2023 09:14:12 -0800 Subject: [PATCH 22/29] fixed lint issues --- .../nodes/chains/VectaraChain/VectaraChain.ts | 59 ++++++++----------- packages/components/package.json | 4 ++ 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/packages/components/nodes/chains/VectaraChain/VectaraChain.ts b/packages/components/nodes/chains/VectaraChain/VectaraChain.ts index 2f7d09a2b..3799d062f 100644 --- a/packages/components/nodes/chains/VectaraChain/VectaraChain.ts +++ b/packages/components/nodes/chains/VectaraChain/VectaraChain.ts @@ -7,39 +7,32 @@ import fetch from 'node-fetch' // functionality based on https://github.com/vectara/vectara-answer const reorderCitations = (unorderedSummary: string) => { - const allCitations = unorderedSummary.match(/\[\d+\]/g) || []; - - const uniqueCitations = [...new Set(allCitations)]; - const citationToReplacement: { [key: string]: string } = {}; + const allCitations = unorderedSummary.match(/\[\d+\]/g) || [] + + const uniqueCitations = [...new Set(allCitations)] + const citationToReplacement: { [key: string]: string } = {} uniqueCitations.forEach((citation, index) => { - citationToReplacement[citation] = `[${index + 1}]`; - }); - - return unorderedSummary.replace( - /\[\d+\]/g, - (match) => citationToReplacement[match] - ); -}; -const applyCitationOrder = ( - searchResults: any[], - unorderedSummary: string - ) => { - const orderedSearchResults: any[] = []; - const allCitations = unorderedSummary.match(/\[\d+\]/g) || []; - - const addedIndices = new Set(); + citationToReplacement[citation] = `[${index + 1}]` + }) + + return unorderedSummary.replace(/\[\d+\]/g, (match) => citationToReplacement[match]) +} +const applyCitationOrder = (searchResults: any[], unorderedSummary: string) => { + const orderedSearchResults: any[] = [] + const allCitations = unorderedSummary.match(/\[\d+\]/g) || [] + + const addedIndices = new Set() for (let i = 0; i < allCitations.length; i++) { - const citation = allCitations[i]; - const index = Number(citation.slice(1, citation.length - 1)) - 1; - - if (addedIndices.has(index)) continue; - orderedSearchResults.push(searchResults[index]); - addedIndices.add(index); + const citation = allCitations[i] + const index = Number(citation.slice(1, citation.length - 1)) - 1 + + if (addedIndices.has(index)) continue + orderedSearchResults.push(searchResults[index]) + addedIndices.add(index) } - - return orderedSearchResults; -}; - + + return orderedSearchResults +} class VectaraChain_Chains implements INode { label: string @@ -325,9 +318,9 @@ class VectaraChain_Chains implements INode { rawSummarizedText = result.responseSet[0].summary[0]?.text - let summarizedText = reorderCitations(rawSummarizedText); - let summaryResponses = applyCitationOrder(responses, rawSummarizedText); - + let summarizedText = reorderCitations(rawSummarizedText) + let summaryResponses = applyCitationOrder(responses, rawSummarizedText) + const sourceDocuments: Document[] = summaryResponses.map( (response: { text: string; metadata: Record; score: number }) => new Document({ diff --git a/packages/components/package.json b/packages/components/package.json index c7a29a9ff..1d4cea573 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -47,6 +47,7 @@ "google-auth-library": "^9.0.0", "graphql": "^16.6.0", "html-to-text": "^9.0.5", + "husky": "^8.0.3", "ioredis": "^5.3.2", "langchain": "^0.0.165", "langfuse-langchain": "^1.0.31", @@ -82,6 +83,9 @@ "@types/object-hash": "^3.0.2", "@types/pg": "^8.10.2", "@types/ws": "^8.5.3", + "eslint-plugin-markdown": "^3.0.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", "gulp": "^4.0.2", "typescript": "^4.8.4" } From 681890a600b804cf116088739ced9dcc3e0d97bc Mon Sep 17 00:00:00 2001 From: tirongi Date: Tue, 21 Nov 2023 19:13:19 +0100 Subject: [PATCH 23/29] Enable inserting custom URL using basic auth --- .../ElectricsearchUserPassword.credential.ts | 2 +- .../Elasticsearch/ElasticSearchBase.ts | 31 ++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/components/credentials/ElectricsearchUserPassword.credential.ts b/packages/components/credentials/ElectricsearchUserPassword.credential.ts index 6c47f7b18..76b9a0eb3 100644 --- a/packages/components/credentials/ElectricsearchUserPassword.credential.ts +++ b/packages/components/credentials/ElectricsearchUserPassword.credential.ts @@ -15,7 +15,7 @@ class ElasticSearchUserPassword implements INodeCredential { 'Refer to official guide on how to get User Password from ElasticSearch' this.inputs = [ { - label: 'Cloud ID', + label: 'Cloud ID or custom server URL', name: 'cloudId', type: 'string' }, diff --git a/packages/components/nodes/vectorstores/Elasticsearch/ElasticSearchBase.ts b/packages/components/nodes/vectorstores/Elasticsearch/ElasticSearchBase.ts index 59294b7ea..68c8392a5 100644 --- a/packages/components/nodes/vectorstores/Elasticsearch/ElasticSearchBase.ts +++ b/packages/components/nodes/vectorstores/Elasticsearch/ElasticSearchBase.ts @@ -144,15 +144,30 @@ export abstract class ElasticSearchBase { } else if (cloudId) { let username = getCredentialParam('username', credentialData, nodeData) let password = getCredentialParam('password', credentialData, nodeData) - elasticSearchClientOptions = { - cloud: { - id: cloudId - }, - auth: { - username: username, - password: password + if (cloudId.startsWith('http')) { + let username = getCredentialParam('username', credentialData, nodeData) + let password = getCredentialParam('password', credentialData, nodeData) + elasticSearchClientOptions = { + node: cloudId, + auth: { + username: username, + password: password + }, + tls: { + rejectUnauthorized: false + } } - } + } else{ + elasticSearchClientOptions = { + cloud: { + id: cloudId + }, + auth: { + username: username, + password: password + } + } + } } return elasticSearchClientOptions } From 8358e59df2dfa2e05e6bfc37b0a0821a2169f39f Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 21 Nov 2023 19:18:08 +0000 Subject: [PATCH 24/29] add claude-2.1 --- .../nodes/chatmodels/ChatAnthropic/ChatAnthropic.ts | 7 ++++++- .../server/marketplaces/chatflows/Claude LLM.json | 11 ++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/components/nodes/chatmodels/ChatAnthropic/ChatAnthropic.ts b/packages/components/nodes/chatmodels/ChatAnthropic/ChatAnthropic.ts index f16968b67..358a15d1e 100644 --- a/packages/components/nodes/chatmodels/ChatAnthropic/ChatAnthropic.ts +++ b/packages/components/nodes/chatmodels/ChatAnthropic/ChatAnthropic.ts @@ -19,7 +19,7 @@ class ChatAnthropic_ChatModels implements INode { constructor() { this.label = 'ChatAnthropic' this.name = 'chatAnthropic' - this.version = 2.0 + this.version = 3.0 this.type = 'ChatAnthropic' this.icon = 'chatAnthropic.png' this.category = 'Chat Models' @@ -48,6 +48,11 @@ class ChatAnthropic_ChatModels implements INode { name: 'claude-2', description: 'Claude 2 latest major version, automatically get updates to the model as they are released' }, + { + label: 'claude-2.1', + name: 'claude-2.1', + description: 'Claude 2 latest full version' + }, { label: 'claude-instant-1', name: 'claude-instant-1', diff --git a/packages/server/marketplaces/chatflows/Claude LLM.json b/packages/server/marketplaces/chatflows/Claude LLM.json index b79898154..0ead3dd82 100644 --- a/packages/server/marketplaces/chatflows/Claude LLM.json +++ b/packages/server/marketplaces/chatflows/Claude LLM.json @@ -1,5 +1,5 @@ { - "description": "Use Anthropic Claude with 100k context window to ingest whole document for QnA", + "description": "Use Anthropic Claude with 200k context window to ingest whole document for QnA", "nodes": [ { "width": 300, @@ -148,7 +148,7 @@ "id": "chatAnthropic_0", "label": "ChatAnthropic", "name": "chatAnthropic", - "version": 2, + "version": 3, "type": "ChatAnthropic", "baseClasses": ["ChatAnthropic", "BaseChatModel", "BaseLanguageModel"], "category": "Chat Models", @@ -171,6 +171,11 @@ "name": "claude-2", "description": "Claude 2 latest major version, automatically get updates to the model as they are released" }, + { + "label": "claude-2.1", + "name": "claude-2.1", + "description": "Claude 2 latest full version" + }, { "label": "claude-instant-1", "name": "claude-instant-1", @@ -268,7 +273,7 @@ } ], "inputs": { - "modelName": "claude-2", + "modelName": "claude-2.1", "temperature": 0.9, "maxTokensToSample": "", "topP": "", From 88c9514cca6a9ede2ae71365ab2305a3e473bafd Mon Sep 17 00:00:00 2001 From: tirongi Date: Tue, 21 Nov 2023 21:45:51 +0100 Subject: [PATCH 25/29] changes based on suggestions --- .../credentials/ElectricsearchUserPassword.credential.ts | 5 +++-- .../nodes/vectorstores/Elasticsearch/ElasticSearchBase.ts | 6 ++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/components/credentials/ElectricsearchUserPassword.credential.ts b/packages/components/credentials/ElectricsearchUserPassword.credential.ts index 76b9a0eb3..ef4f3490b 100644 --- a/packages/components/credentials/ElectricsearchUserPassword.credential.ts +++ b/packages/components/credentials/ElectricsearchUserPassword.credential.ts @@ -12,10 +12,11 @@ class ElasticSearchUserPassword implements INodeCredential { this.name = 'elasticSearchUserPassword' this.version = 1.0 this.description = - 'Refer to official guide on how to get User Password from ElasticSearch' + `Use Cloud ID field to enter your Elastic Cloud ID or the URL of the Elastic server instance. + Refer to official guide on how to get User Password from ElasticSearch.` this.inputs = [ { - label: 'Cloud ID or custom server URL', + label: 'Cloud ID', name: 'cloudId', type: 'string' }, diff --git a/packages/components/nodes/vectorstores/Elasticsearch/ElasticSearchBase.ts b/packages/components/nodes/vectorstores/Elasticsearch/ElasticSearchBase.ts index 68c8392a5..a1233c215 100644 --- a/packages/components/nodes/vectorstores/Elasticsearch/ElasticSearchBase.ts +++ b/packages/components/nodes/vectorstores/Elasticsearch/ElasticSearchBase.ts @@ -145,8 +145,6 @@ export abstract class ElasticSearchBase { let username = getCredentialParam('username', credentialData, nodeData) let password = getCredentialParam('password', credentialData, nodeData) if (cloudId.startsWith('http')) { - let username = getCredentialParam('username', credentialData, nodeData) - let password = getCredentialParam('password', credentialData, nodeData) elasticSearchClientOptions = { node: cloudId, auth: { @@ -157,7 +155,7 @@ export abstract class ElasticSearchBase { rejectUnauthorized: false } } - } else{ + } else { elasticSearchClientOptions = { cloud: { id: cloudId @@ -167,7 +165,7 @@ export abstract class ElasticSearchBase { password: password } } - } + } } return elasticSearchClientOptions } From 08443fdb1f93560fd94cceaba8ca103598fb4316 Mon Sep 17 00:00:00 2001 From: tirongi Date: Tue, 21 Nov 2023 21:55:31 +0100 Subject: [PATCH 26/29] linting --- .../credentials/ElectricsearchUserPassword.credential.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/components/credentials/ElectricsearchUserPassword.credential.ts b/packages/components/credentials/ElectricsearchUserPassword.credential.ts index ef4f3490b..c1ac82c1c 100644 --- a/packages/components/credentials/ElectricsearchUserPassword.credential.ts +++ b/packages/components/credentials/ElectricsearchUserPassword.credential.ts @@ -11,8 +11,7 @@ class ElasticSearchUserPassword implements INodeCredential { this.label = 'ElasticSearch User Password' this.name = 'elasticSearchUserPassword' this.version = 1.0 - this.description = - `Use Cloud ID field to enter your Elastic Cloud ID or the URL of the Elastic server instance. + this.description = `Use Cloud ID field to enter your Elastic Cloud ID or the URL of the Elastic server instance. Refer to official guide on how to get User Password from ElasticSearch.` this.inputs = [ { From 75874f0dfa4080a9c9b5fa3240f6b87a72446a7d Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 22 Nov 2023 03:01:57 +0000 Subject: [PATCH 27/29] fix image fetching method --- .../agents/OpenAIAssistant/OpenAIAssistant.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/components/nodes/agents/OpenAIAssistant/OpenAIAssistant.ts b/packages/components/nodes/agents/OpenAIAssistant/OpenAIAssistant.ts index ed7baf7de..b119599dc 100644 --- a/packages/components/nodes/agents/OpenAIAssistant/OpenAIAssistant.ts +++ b/packages/components/nodes/agents/OpenAIAssistant/OpenAIAssistant.ts @@ -358,7 +358,7 @@ class OpenAIAssistant_Agents implements INode { const dirPath = path.join(getUserHome(), '.flowise', 'openai-assistant') const filePath = path.join(getUserHome(), '.flowise', 'openai-assistant', `${fileObj.filename}.png`) - await downloadFile(fileObj, filePath, dirPath, openAIApiKey) + await downloadImg(openai, fileId, filePath, dirPath) const bitmap = fsDefault.readFileSync(filePath) const base64String = Buffer.from(bitmap).toString('base64') @@ -380,6 +380,22 @@ class OpenAIAssistant_Agents implements INode { } } +const downloadImg = async (openai: OpenAI, fileId: string, filePath: string, dirPath: string) => { + const response = await openai.files.content(fileId) + + // Extract the binary data from the Response object + const image_data = await response.arrayBuffer() + + // Convert the binary data to a Buffer + const image_data_buffer = Buffer.from(image_data) + + // Save the image to a specific location + if (!fsDefault.existsSync(dirPath)) { + fsDefault.mkdirSync(path.dirname(filePath), { recursive: true }) + } + fsDefault.writeFileSync(filePath, image_data_buffer) +} + const downloadFile = async (fileObj: any, filePath: string, dirPath: string, openAIApiKey: string) => { try { const response = await fetch(`https://api.openai.com/v1/files/${fileObj.id}/content`, { From 0d1cc487a7e1e764f6e15eecafdeb67391b9f813 Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 22 Nov 2023 23:55:00 +0000 Subject: [PATCH 28/29] slight ui update --- packages/ui/src/views/apikey/index.js | 92 +++++++++++++++------------ 1 file changed, 53 insertions(+), 39 deletions(-) diff --git a/packages/ui/src/views/apikey/index.js b/packages/ui/src/views/apikey/index.js index 73224cb2e..68113af5b 100644 --- a/packages/ui/src/views/apikey/index.js +++ b/packages/ui/src/views/apikey/index.js @@ -10,7 +10,6 @@ import { Stack, Table, TableBody, - TableCell, TableContainer, TableHead, TableRow, @@ -24,7 +23,8 @@ import { InputAdornment, ButtonGroup } from '@mui/material' -import { useTheme } from '@mui/material/styles' +import TableCell, { tableCellClasses } from '@mui/material/TableCell' +import { useTheme, styled } from '@mui/material/styles' // project imports import MainCard from 'ui-component/cards/MainCard' @@ -60,12 +60,24 @@ import * as PropTypes from 'prop-types' import moment from 'moment/moment' // ==============================|| APIKey ||============================== // +const StyledTableCell = styled(TableCell)(({ theme }) => ({ + [`&.${tableCellClasses.head}`]: { + backgroundColor: theme.palette.action.hover + } +})) + +const StyledTableRow = styled(TableRow)(() => ({ + // hide last border + '&:last-child td, &:last-child th': { + border: 0 + } +})) function APIKeyRow(props) { const [open, setOpen] = useState(false) return ( <> - + {props.apiKey.keyName} {props.showApiKeys.includes(props.apiKey.apiKey) @@ -100,7 +112,7 @@ function APIKeyRow(props) { {props.apiKey.chatFlows.length}{' '} {props.apiKey.chatFlows.length > 0 && ( - setOpen(!open)}> + setOpen(!open)}> {props.apiKey.chatFlows.length > 0 && open ? : } )} @@ -117,42 +129,44 @@ function APIKeyRow(props) { - - - - -
- - - Chatflow Name - Modified On - Category - - - - {props.apiKey.chatFlows.map((flow, index) => ( - - - {flow.flowName} - - {moment(flow.updatedDate).format('DD-MMM-YY')} - -   - {flow.category && - flow.category - .split(';') - .map((tag, index) => ( - - ))} - + {open && ( + + + + +
+ + + + Chatflow Name + + Modified On + Category - ))} - -
-
-
-
-
+ + + {props.apiKey.chatFlows.map((flow, index) => ( + + {flow.flowName} + {moment(flow.updatedDate).format('DD-MMM-YY')} + +   + {flow.category && + flow.category + .split(';') + .map((tag, index) => ( + + ))} + + + ))} + + + + + +
+ )} ) } From e0a0c830e9371a3cdf6b5934f24411d7b877ca6a Mon Sep 17 00:00:00 2001 From: Henry Heng Date: Thu, 23 Nov 2023 11:29:02 +0000 Subject: [PATCH 29/29] update apache 2.0 licensing --- LICENSE.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/LICENSE.md b/LICENSE.md index 4a27187d8..0f4afcd11 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,7 +1,23 @@ - Apache License + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ +Flowise is governed by the Apache License 2.0, with additional terms and conditions outlined below: + +Flowise can be used for commercial purposes for "backend-as-a-service" for your applications or as a development platform for enterprises. However, under specific conditions, you must reach out to the project's administrators to secure a commercial license: + +a. Multi-tenant SaaS service: Unless you have explicit written authorization from Flowise, you may not utilize the Flowise source code to operate a multi-tenant SaaS service that closely resembles the Flowise cloud-based services. +b. Logo and copyright information: While using Flowise in commercial application, you are prohibited from removing or altering the LOGO or copyright information displayed in the Flowise console and UI. + +For inquiries regarding licensing matters, please contact hello@flowiseai.com via email. + +Contributors are required to consent to the following terms related to their contributed code: + +a. The project maintainers have the authority to modify the open-source agreement to be more stringent or lenient. +b. Contributed code can be used for commercial purposes, including Flowise's cloud-based services. + +All other rights and restrictions are in accordance with the Apache License 2.0. + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions.