Merge branch 'main' of https://github.com/use-the-fork/Flowise into Bug/rework-redis-connection

# Conflicts:
#	packages/components/package.json
#	packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx
#	packages/ui/src/views/apikey/index.jsx
#	packages/ui/src/views/assistants/AssistantDialog.jsx
#	packages/ui/src/views/chatflows/index.jsx
#	packages/ui/src/views/credentials/index.jsx
This commit is contained in:
Greg L 2023-11-23 08:24:59 -05:00
commit ea8e1c8628
38 changed files with 1990 additions and 284 deletions

View File

@ -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.

View File

@ -11,8 +11,8 @@ class ElasticSearchUserPassword implements INodeCredential {
this.label = 'ElasticSearch User Password'
this.name = 'elasticSearchUserPassword'
this.version = 1.0
this.description =
'Refer to <a target="_blank" href="https://www.elastic.co/guide/en/kibana/current/tutorial-secure-access-to-kibana.html">official guide</a> on how to get User Password from ElasticSearch'
this.description = `Use Cloud ID field to enter your Elastic Cloud ID or the URL of the Elastic server instance.
Refer to <a target="_blank" href="https://www.elastic.co/guide/en/elasticsearch/reference/current/setting-up-authentication.html">official guide</a> on how to get User Password from ElasticSearch.`
this.inputs = [
{
label: 'Cloud ID',

View File

@ -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<void>((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
@ -258,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')
@ -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)
@ -279,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`, {

View File

@ -0,0 +1,339 @@
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'
// 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<number>()
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
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 Store',
name: 'vectaraStore',
type: 'VectorStore'
},
{
label: 'Summarizer Prompt Name',
name: 'summarizerPromptName',
description:
'Summarize the results fetched from Vectara. Read <a target="_blank" href="https://docs.vectara.com/docs/learn/grounded-generation/select-a-summarizer">more</a>',
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 <a target="_blank" href="https://docs.vectara.com/docs/learn/grounded-generation/grounded-generation-response-languages">more</a>',
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
}
]
}
async init(): Promise<any> {
return null
}
async run(nodeData: INodeData, input: string): Promise<object> {
const vectorStore = nodeData.inputs?.vectaraStore as VectaraStore
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 ?? {}
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 }
}))
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 rawSummarizedText = ''
for (let i = 0; i < responses.length; i += 1) {
const responseMetadata = responses[i].metadata
const documentMetadata = documents[responses[i].documentIndex].metadata
const combinedMetadata: Record<string, unknown> = {}
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.`)
}
rawSummarizedText = result.responseSet[0].summary[0]?.text
let summarizedText = reorderCitations(rawSummarizedText)
let summaryResponses = applyCitationOrder(responses, rawSummarizedText)
const sourceDocuments: Document[] = summaryResponses.map(
(response: { text: string; metadata: Record<string, unknown>; 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 }

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -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',

View File

@ -144,13 +144,26 @@ 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')) {
elasticSearchClientOptions = {
node: cloudId,
auth: {
username: username,
password: password
},
tls: {
rejectUnauthorized: false
}
}
} else {
elasticSearchClientOptions = {
cloud: {
id: cloudId
},
auth: {
username: username,
password: password
}
}
}
}

View File

@ -39,8 +39,6 @@
"cheerio": "^1.0.0-rc.12",
"chromadb": "^1.5.11",
"cohere-ai": "^6.2.0",
"crypto-js": "^4.1.1",
"css-what": "^6.1.0",
"d3-dsv": "2",
"dotenv": "^16.0.0",
"express": "^4.17.3",
@ -55,7 +53,6 @@
"langsmith": "^0.0.32",
"linkifyjs": "^4.1.1",
"llmonitor": "^0.5.5",
"lodash": "^4.17.21",
"mammoth": "^1.5.1",
"moment": "^2.29.3",
"mongodb": "^6.2.0",
@ -73,27 +70,23 @@
"pyodide": ">=0.21.0-alpha.2",
"redis": "^4.6.7",
"replicate": "^0.12.3",
"socket.io": "^4.6.1",
"srt-parser-2": "^1.2.3",
"typeorm": "^0.3.6",
"vm2": "^3.9.19",
"weaviate-ts-client": "^1.1.0",
"winston": "^3.9.0",
"ws": "^8.9.0",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.21.4"
},
"devDependencies": {
"@types/crypto-js": "^4.1.1",
"@types/gulp": "4.0.9",
"@types/lodash": "^4.14.202",
"@types/node-fetch": "2.6.2",
"@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",
"babel-register": "^6.26.0",
"gulp": "^4.0.2",
"ts-node": "^10.7.0",
"typescript": "^4.8.4"
}
}

View File

@ -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": "",

View File

@ -30,6 +30,7 @@ export interface IChatMessage {
chatflowid: string
sourceDocuments?: string
usedTools?: string
fileAnnotations?: string
chatType: string
chatId: string
memoryType?: string

View File

@ -36,4 +36,7 @@ export class ChatFlow implements IChatFlow {
@UpdateDateColumn()
updatedDate: Date
@Column({ nullable: true, type: 'text' })
category?: string
}

View File

@ -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

View File

@ -0,0 +1,12 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddCategoryToChatFlow1699900910291 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`ALTER TABLE \`chat_flow\` DROP COLUMN \`category\`;`)
}
}

View File

@ -0,0 +1,12 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddFileAnnotationsToChatMessage1700271021237 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`ALTER TABLE \`chat_message\` DROP COLUMN \`fileAnnotations\`;`)
}
}

View File

@ -8,6 +8,8 @@ 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'
import { AddFileAnnotationsToChatMessage1700271021237 } from './1700271021237-AddFileAnnotationsToChatMessage'
export const mysqlMigrations = [
Init1693840429259,
@ -19,5 +21,7 @@ export const mysqlMigrations = [
AddAnalytic1694432361423,
AddChatHistory1694658767766,
AddAssistantEntity1699325775451,
AddUsedToolsToChatMessage1699481607341
AddUsedToolsToChatMessage1699481607341,
AddCategoryToChatFlow1699900910291,
AddFileAnnotationsToChatMessage1700271021237
]

View File

@ -6,6 +6,6 @@ export class AddUsedToolsToChatMessage1699481607341 implements MigrationInterfac
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "usedTools";`)
await queryRunner.query(`ALTER TABLE "chat_message" DROP COLUMN "usedTools";`)
}
}

View File

@ -0,0 +1,11 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddCategoryToChatFlow1699900910291 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN IF NOT EXISTS "category" TEXT;`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "category";`)
}
}

View File

@ -0,0 +1,11 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddFileAnnotationsToChatMessage1700271021237 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "chat_message" ADD COLUMN IF NOT EXISTS "fileAnnotations" TEXT;`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "chat_message" DROP COLUMN "fileAnnotations";`)
}
}

View File

@ -8,6 +8,8 @@ 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'
import { AddFileAnnotationsToChatMessage1700271021237 } from './1700271021237-AddFileAnnotationsToChatMessage'
export const postgresMigrations = [
Init1693891895163,
@ -19,5 +21,7 @@ export const postgresMigrations = [
AddAnalytic1694432361423,
AddChatHistory1694658756136,
AddAssistantEntity1699325775451,
AddUsedToolsToChatMessage1699481607341
AddUsedToolsToChatMessage1699481607341,
AddCategoryToChatFlow1699900910291,
AddFileAnnotationsToChatMessage1700271021237
]

View File

@ -0,0 +1,11 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddCategoryToChatFlow1699900910291 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN "category" TEXT;`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "category";`)
}
}

View File

@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddFileAnnotationsToChatMessage1700271021237 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "temp_chat_message";`)
await queryRunner.query(`ALTER TABLE "chat_message" DROP COLUMN "fileAnnotations";`)
}
}

View File

@ -8,6 +8,8 @@ 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'
import { AddFileAnnotationsToChatMessage1700271021237 } from './1700271021237-AddFileAnnotationsToChatMessage'
export const sqliteMigrations = [
Init1693835579790,
@ -19,5 +21,7 @@ export const sqliteMigrations = [
AddAnalytic1694432361423,
AddChatHistory1694657778173,
AddAssistantEntity1699325775451,
AddUsedToolsToChatMessage1699481607341
AddUsedToolsToChatMessage1699481607341,
AddCategoryToChatFlow1699900910291,
AddFileAnnotationsToChatMessage1700271021237
]

View File

@ -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) => {
@ -355,8 +356,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)
})
@ -782,8 +787,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 +957,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 +966,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
// ----------------------------------------
@ -1135,28 +1148,52 @@ 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,
category: cf.category,
updatedDate: cf.updatedDate
})
})
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
@ -1499,6 +1536,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})`)

View File

@ -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
@ -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
}

View File

@ -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,

View File

@ -0,0 +1,291 @@
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'
import EditIcon from '@mui/icons-material/Edit'
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 { IconX } from '@tabler/icons'
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 TagDialog from '../dialog/TagDialog'
import { generateExportFlowData } from '../../utils/genericHelper'
import useNotifier from '../../utils/useNotifier'
const StyledMenu = styled((props) => (
<Menu
elevation={0}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right'
}}
{...props}
/>
))(({ theme }) => ({
'& .MuiPaper-root': {
borderRadius: 6,
marginTop: theme.spacing(1),
minWidth: 180,
boxShadow:
'rgb(255, 255, 255) 0px 0px 0px 0px, rgba(0, 0, 0, 0.05) 0px 0px 0px 1px, rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px',
'& .MuiMenu-list': {
padding: '4px 0'
},
'& .MuiMenuItem-root': {
'& .MuiSvgIcon-root': {
fontSize: 18,
color: theme.palette.text.secondary,
marginRight: theme.spacing(1.5)
},
'&:active': {
backgroundColor: alpha(theme.palette.primary.main, theme.palette.action.selectedOpacity)
}
}
}
}))
export default function FlowListMenu({ chatflow, updateFlowsApi }) {
const { confirm } = useConfirm()
const dispatch = useDispatch()
const updateChatflowApi = useApi(chatflowsApi.updateChatflow)
useNotifier()
const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args))
const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args))
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,
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) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
}
const handleFlowCategory = () => {
setAnchorEl(null)
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 = {
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) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
}
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) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
}
}
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 (
<div>
<Button
id='demo-customized-button'
aria-controls={open ? 'demo-customized-menu' : undefined}
aria-haspopup='true'
aria-expanded={open ? 'true' : undefined}
disableElevation
onClick={handleClick}
endIcon={<KeyboardArrowDownIcon />}
>
Options
</Button>
<StyledMenu
id='demo-customized-menu'
MenuListProps={{
'aria-labelledby': 'demo-customized-button'
}}
anchorEl={anchorEl}
open={open}
onClose={handleClose}
>
<MenuItem onClick={handleFlowRename} disableRipple>
<EditIcon />
Rename
</MenuItem>
<MenuItem onClick={handleDuplicate} disableRipple>
<FileCopyIcon />
Duplicate
</MenuItem>
<MenuItem onClick={handleExport} disableRipple>
<FileDownloadIcon />
Export
</MenuItem>
<Divider sx={{ my: 0.5 }} />
<MenuItem onClick={handleFlowCategory} disableRipple>
<FileCategoryIcon />
Update Category
</MenuItem>
<Divider sx={{ my: 0.5 }} />
<MenuItem onClick={handleDelete} disableRipple>
<FileDeleteIcon />
Delete
</MenuItem>
</StyledMenu>
<ConfirmDialog />
<SaveChatflowDialog
show={flowDialogOpen}
dialogProps={{
title: `Rename Chatflow`,
confirmButtonName: 'Rename',
cancelButtonName: 'Cancel'
}}
onCancel={() => setFlowDialogOpen(false)}
onConfirm={saveFlowRename}
/>
<TagDialog
isOpen={categoryDialogOpen}
dialogProps={categoryDialogProps}
onClose={() => setCategoryDialogOpen(false)}
onSubmit={saveFlowCategory}
/>
</div>
)
}
FlowListMenu.propTypes = {
chatflow: PropTypes.object,
updateFlowsApi: PropTypes.object
}

View File

@ -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
}
}))

View File

@ -0,0 +1,110 @@
import { useState, useEffect } 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, Typography } from '@mui/material'
const TagDialog = ({ isOpen, dialogProps, onClose, onSubmit }) => {
const [inputValue, setInputValue] = useState('')
const [categoryValues, setCategoryValues] = useState([])
const handleInputChange = (event) => {
setInputValue(event.target.value)
}
const handleInputKeyDown = (event) => {
if (event.key === 'Enter' && inputValue.trim()) {
event.preventDefault()
if (!categoryValues.includes(inputValue)) {
setCategoryValues([...categoryValues, inputValue])
setInputValue('')
}
}
}
const handleDeleteTag = (categoryToDelete) => {
setCategoryValues(categoryValues.filter((category) => category !== categoryToDelete))
}
const handleSubmit = (event) => {
event.preventDefault()
let newCategories = [...categoryValues]
if (inputValue.trim() && !categoryValues.includes(inputValue)) {
newCategories = [...newCategories, inputValue]
setCategoryValues(newCategories)
}
onSubmit(newCategories)
}
useEffect(() => {
if (dialogProps.category) setCategoryValues(dialogProps.category)
return () => {
setInputValue('')
setCategoryValues([])
}
}, [dialogProps])
return (
<Dialog
fullWidth
maxWidth='xs'
open={isOpen}
onClose={onClose}
aria-labelledby='category-dialog-title'
aria-describedby='category-dialog-description'
>
<DialogTitle sx={{ fontSize: '1rem' }} id='alert-dialog-title'>
Set Chatflow Category Tags
</DialogTitle>
<DialogContent>
<Box>
<form onSubmit={handleSubmit}>
{categoryValues.length > 0 && (
<div style={{ marginBottom: 10 }}>
{categoryValues.map((category, index) => (
<Chip
key={index}
label={category}
onDelete={() => handleDeleteTag(category)}
style={{ marginRight: 5, marginBottom: 5 }}
/>
))}
</div>
)}
<TextField
sx={{ mt: 2 }}
fullWidth
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
label='Add a tag'
variant='outlined'
/>
<Typography variant='body2' sx={{ fontStyle: 'italic', mt: 1 }} color='text.secondary'>
Enter a tag and press enter to add it to the list. You can add as many tags as you want.
</Typography>
</form>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant='contained' onClick={handleSubmit}>
Submit
</Button>
</DialogActions>
</Dialog>
)
}
TagDialog.propTypes = {
isOpen: PropTypes.bool,
dialogProps: PropTypes.object,
onClose: PropTypes.func,
onSubmit: PropTypes.func
}
export default TagDialog

View File

@ -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 {
@ -25,33 +26,34 @@ import {
import { useTheme } from '@mui/material/styles'
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 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, IconDownload } from '@tabler/icons'
// Project import
import { MemoizedReactMarkdown } from '@/ui-component/markdown/MemoizedReactMarkdown'
import { CodeBlock } from '@/ui-component/markdown/CodeBlock'
import SourceDocDialog from '@/ui-component/dialog/SourceDocDialog'
import { MultiDropdown } from '@/ui-component/dropdown/MultiDropdown'
import { StyledButton } from '@/ui-component/button/StyledButton'
import { MemoizedReactMarkdown } from 'ui-component/markdown/MemoizedReactMarkdown'
import { CodeBlock } from 'ui-component/markdown/CodeBlock'
import SourceDocDialog from 'ui-component/dialog/SourceDocDialog'
import { MultiDropdown } from 'ui-component/dropdown/MultiDropdown'
import { StyledButton } from 'ui-component/button/StyledButton'
// store
import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions'
import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from 'store/actions'
// API
import chatmessageApi from '@/api/chatmessage'
import useApi from '@/hooks/useApi'
import useConfirm from '@/hooks/useConfirm'
import chatmessageApi from 'api/chatmessage'
import useApi from 'hooks/useApi'
import useConfirm from 'hooks/useConfirm'
// Utils
import { isValidURL, removeDuplicateURL } from '@/utils/genericHelper'
import useNotifier from '@/utils/useNotifier'
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'
import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from 'store/actions'
import '@/views/chatmessage/ChatMessage.css'
import 'views/chatmessage/ChatMessage.css'
import 'react-datepicker/dist/react-datepicker.css'
const DatePickerCustomInput = forwardRef(function DatePickerCustomInput({ value, onClick }, ref) {
@ -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,10 +672,37 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
{message.message}
</MemoizedReactMarkdown>
</div>
{message.fileAnnotations && (
<div style={{ display: 'block', flexDirection: 'row', width: '100%' }}>
{message.fileAnnotations.map((fileAnnotation, index) => {
return (
<Button
sx={{
fontSize: '0.85rem',
textTransform: 'none',
mb: 1,
mr: 1
}}
key={index}
variant='outlined'
onClick={() => downloadFile(fileAnnotation)}
endIcon={
<IconDownload color={theme.palette.primary.main} />
}
>
{fileAnnotation.fileName}
</Button>
)
})}
</div>
)}
{message.sourceDocuments && (
<div style={{ display: 'block', flexDirection: 'row', width: '100%' }}>
{removeDuplicateURL(message).map((source, index) => {
const URL = isValidURL(source.metadata.source)
const URL =
source.metadata && source.metadata.source
? isValidURL(source.metadata.source)
: undefined
return (
<Chip
size='small'

View File

@ -0,0 +1,152 @@
import PropTypes from 'prop-types'
import { useNavigate } from 'react-router-dom'
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 Chip from '@mui/material/Chip'
import { Button, Stack, Typography } from '@mui/material'
import FlowListMenu from '../button/FlowListMenu'
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, updateFlowsApi }) => {
const navigate = useNavigate()
const goToCanvas = (selectedChatflow) => {
navigate(`/canvas/${selectedChatflow.id}`)
}
return (
<>
<TableContainer style={{ marginTop: '30', border: 1 }} component={Paper}>
<Table sx={{ minWidth: 650 }} size='small' aria-label='a dense table'>
<TableHead>
<TableRow sx={{ marginTop: '10', backgroundColor: 'primary' }}>
<StyledTableCell component='th' scope='row' style={{ width: '20%' }} key='0'>
Name
</StyledTableCell>
<StyledTableCell style={{ width: '25%' }} key='1'>
Category
</StyledTableCell>
<StyledTableCell style={{ width: '30%' }} key='2'>
Nodes
</StyledTableCell>
<StyledTableCell style={{ width: '15%' }} key='3'>
Last Modified Date
</StyledTableCell>
<StyledTableCell style={{ width: '10%' }} key='4'>
Actions
</StyledTableCell>
</TableRow>
</TableHead>
<TableBody>
{data.filter(filterFunction).map((row, index) => (
<StyledTableRow key={index}>
<TableCell key='0'>
<Typography
sx={{ fontSize: '1.2rem', fontWeight: 500, overflowWrap: 'break-word', whiteSpace: 'pre-line' }}
>
<Button onClick={() => goToCanvas(row)}>{row.templateName || row.name}</Button>
</Typography>
</TableCell>
<TableCell key='1'>
<div
style={{
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 5
}}
>
&nbsp;
{row.category &&
row.category
.split(';')
.map((tag, index) => (
<Chip key={index} label={tag} style={{ marginRight: 5, marginBottom: 5 }} />
))}
</div>
</TableCell>
<TableCell key='2'>
{images[row.id] && (
<div
style={{
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 5
}}
>
{images[row.id].slice(0, images[row.id].length > 5 ? 5 : images[row.id].length).map((img) => (
<div
key={img}
style={{
width: 35,
height: 35,
marginRight: 5,
borderRadius: '50%',
backgroundColor: 'white',
marginTop: 5
}}
>
<img
style={{ width: '100%', height: '100%', padding: 5, objectFit: 'contain' }}
alt=''
src={img}
/>
</div>
))}
{images[row.id].length > 5 && (
<Typography
sx={{ alignItems: 'center', display: 'flex', fontSize: '.8rem', fontWeight: 200 }}
>
+ {images[row.id].length - 5} More
</Typography>
)}
</div>
)}
</TableCell>
<TableCell key='3'>{moment(row.updatedDate).format('MMMM Do, YYYY')}</TableCell>
<TableCell key='4'>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} justifyContent='center' alignItems='center'>
<FlowListMenu chatflow={row} updateFlowsApi={updateFlowsApi} />
</Stack>
</TableCell>
</StyledTableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
)
}
FlowListTable.propTypes = {
data: PropTypes.object,
images: PropTypes.array,
filterFunction: PropTypes.func,
updateFlowsApi: PropTypes.object
}

View File

@ -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 (
<ToggleButtonGroup value={view} exclusive onChange={handleChange}>
<StyledToggleButton variant='contained' value='list' aria-label='list'>
<ViewListIcon />
</StyledToggleButton>
<StyledToggleButton variant='contained' value='module' aria-label='module'>
<ViewModuleIcon />
</StyledToggleButton>
</ToggleButtonGroup>
)
}

View File

@ -423,10 +423,14 @@ export const removeDuplicateURL = (message) => {
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)
}
})

View File

@ -1,47 +1,188 @@
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from '@/store/actions'
import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from 'store/actions'
// material-ui
import {
Button,
Box,
Chip,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
IconButton,
Popover,
Typography
Collapse,
Typography,
Toolbar,
TextField,
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'
import { StyledButton } from '@/ui-component/button/StyledButton'
import MainCard from 'ui-component/cards/MainCard'
import { StyledButton } from 'ui-component/button/StyledButton'
import APIKeyDialog from './APIKeyDialog'
import ConfirmDialog from '@/ui-component/dialog/ConfirmDialog'
import ConfirmDialog from 'ui-component/dialog/ConfirmDialog'
// API
import apiKeyApi from '@/api/apikey'
import apiKeyApi from 'api/apikey'
// Hooks
import useApi from '@/hooks/useApi'
import useConfirm from '@/hooks/useConfirm'
import useApi from 'hooks/useApi'
import useConfirm from 'hooks/useConfirm'
// utils
import useNotifier from '@/utils/useNotifier'
import useNotifier from 'utils/useNotifier'
// Icons
import { IconTrash, IconEdit, IconCopy, IconX, IconPlus, IconEye, IconEyeOff } from '@tabler/icons'
import APIEmptySVG from '@/assets/images/api_empty.svg'
import {
IconTrash,
IconEdit,
IconCopy,
IconChevronsUp,
IconChevronsDown,
IconX,
IconSearch,
IconPlus,
IconEye,
IconEyeOff
} from '@tabler/icons'
import APIEmptySVG from 'assets/images/api_empty.svg'
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 (
<>
<TableRow sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell scope='row'>{props.apiKey.keyName}</TableCell>
<TableCell>
{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
)}`}
<IconButton title='Copy' color='success' onClick={props.onCopyClick}>
<IconCopy />
</IconButton>
<IconButton title='Show' color='inherit' onClick={props.onShowAPIClick}>
{props.showApiKeys.includes(props.apiKey.apiKey) ? <IconEyeOff /> : <IconEye />}
</IconButton>
<Popover
open={props.open}
anchorEl={props.anchorEl}
onClose={props.onClose}
anchorOrigin={{
vertical: 'top',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left'
}}
>
<Typography variant='h6' sx={{ pl: 1, pr: 1, color: 'white', background: props.theme.palette.success.dark }}>
Copied!
</Typography>
</Popover>
</TableCell>
<TableCell>
{props.apiKey.chatFlows.length}{' '}
{props.apiKey.chatFlows.length > 0 && (
<IconButton aria-label='expand row' size='small' color='inherit' onClick={() => setOpen(!open)}>
{props.apiKey.chatFlows.length > 0 && open ? <IconChevronsUp /> : <IconChevronsDown />}
</IconButton>
)}
</TableCell>
<TableCell>{props.apiKey.createdAt}</TableCell>
<TableCell>
<IconButton title='Edit' color='primary' onClick={props.onEditClick}>
<IconEdit />
</IconButton>
</TableCell>
<TableCell>
<IconButton title='Delete' color='error' onClick={props.onDeleteClick}>
<IconTrash />
</IconButton>
</TableCell>
</TableRow>
{open && (
<TableRow sx={{ '& td': { border: 0 } }}>
<TableCell sx={{ pb: 0, pt: 0 }} colSpan={6}>
<Collapse in={open} timeout='auto' unmountOnExit>
<Box sx={{ mt: 1, mb: 2, borderRadius: '15px', border: '1px solid' }}>
<Table aria-label='chatflow table'>
<TableHead>
<TableRow>
<StyledTableCell sx={{ width: '30%', borderTopLeftRadius: '15px' }}>
Chatflow Name
</StyledTableCell>
<StyledTableCell sx={{ width: '20%' }}>Modified On</StyledTableCell>
<StyledTableCell sx={{ width: '50%', borderTopRightRadius: '15px' }}>Category</StyledTableCell>
</TableRow>
</TableHead>
<TableBody>
{props.apiKey.chatFlows.map((flow, index) => (
<StyledTableRow key={index}>
<TableCell>{flow.flowName}</TableCell>
<TableCell>{moment(flow.updatedDate).format('DD-MMM-YY')}</TableCell>
<TableCell>
&nbsp;
{flow.category &&
flow.category
.split(';')
.map((tag, index) => (
<Chip key={index} label={tag} style={{ marginRight: 5, marginBottom: 5 }} />
))}
</TableCell>
</StyledTableRow>
))}
</TableBody>
</Table>
</Box>
</Collapse>
</TableCell>
</TableRow>
)}
</>
)
}
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)
@ -59,6 +200,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)
@ -106,7 +255,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'
}
@ -171,12 +323,53 @@ const APIKey = () => {
<>
<MainCard sx={{ background: customization.isDarkMode ? theme.palette.common.black : '' }}>
<Stack flexDirection='row'>
<h1>API Keys&nbsp;</h1>
<Box sx={{ flexGrow: 1 }} />
<StyledButton variant='contained' sx={{ color: 'white', mr: 1, height: 37 }} onClick={addNew} startIcon={<IconPlus />}>
Create Key
</StyledButton>
<Box sx={{ flexGrow: 1 }}>
<Toolbar
disableGutters={true}
style={{
margin: 1,
padding: 1,
paddingBottom: 10,
display: 'flex',
justifyContent: 'space-between',
width: '100%'
}}
>
<h1>API Keys&nbsp;</h1>
<TextField
size='small'
sx={{ display: { xs: 'none', sm: 'block' }, ml: 3 }}
variant='outlined'
placeholder='Search key name'
onChange={onSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<IconSearch />
</InputAdornment>
)
}}
/>
<Box sx={{ flexGrow: 1 }} />
<ButtonGroup
sx={{ maxHeight: 40 }}
disableElevation
variant='contained'
aria-label='outlined primary button group'
>
<ButtonGroup disableElevation aria-label='outlined primary button group'>
<StyledButton
variant='contained'
sx={{ color: 'white', mr: 1, height: 37 }}
onClick={addNew}
startIcon={<IconPlus />}
>
Create Key
</StyledButton>
</ButtonGroup>
</ButtonGroup>
</Toolbar>
</Box>
</Stack>
{apiKeys.length <= 0 && (
<Stack sx={{ alignItems: 'center', justifyContent: 'center' }} flexDirection='column'>
@ -193,72 +386,33 @@ const APIKey = () => {
<TableRow>
<TableCell>Key Name</TableCell>
<TableCell>API Key</TableCell>
<TableCell>Usage</TableCell>
<TableCell>Created</TableCell>
<TableCell> </TableCell>
<TableCell> </TableCell>
</TableRow>
</TableHead>
<TableBody>
{apiKeys.map((key, index) => (
<TableRow key={index} sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component='th' scope='row'>
{key.keyName}
</TableCell>
<TableCell>
{showApiKeys.includes(key.apiKey)
? key.apiKey
: `${key.apiKey.substring(0, 2)}${'•'.repeat(18)}${key.apiKey.substring(
key.apiKey.length - 5
)}`}
<IconButton
title='Copy'
color='success'
onClick={(event) => {
navigator.clipboard.writeText(key.apiKey)
setAnchorEl(event.currentTarget)
setTimeout(() => {
handleClosePopOver()
}, 1500)
}}
>
<IconCopy />
</IconButton>
<IconButton title='Show' color='inherit' onClick={() => onShowApiKeyClick(key.apiKey)}>
{showApiKeys.includes(key.apiKey) ? <IconEyeOff /> : <IconEye />}
</IconButton>
<Popover
open={openPopOver}
anchorEl={anchorEl}
onClose={handleClosePopOver}
anchorOrigin={{
vertical: 'top',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left'
}}
>
<Typography
variant='h6'
sx={{ pl: 1, pr: 1, color: 'white', background: theme.palette.success.dark }}
>
Copied!
</Typography>
</Popover>
</TableCell>
<TableCell>{key.createdAt}</TableCell>
<TableCell>
<IconButton title='Edit' color='primary' onClick={() => edit(key)}>
<IconEdit />
</IconButton>
</TableCell>
<TableCell>
<IconButton title='Delete' color='error' onClick={() => deleteKey(key)}>
<IconTrash />
</IconButton>
</TableCell>
</TableRow>
{apiKeys.filter(filterKeys).map((key, index) => (
<APIKeyRow
key={index}
apiKey={key}
showApiKeys={showApiKeys}
onCopyClick={(event) => {
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)}
/>
))}
</TableBody>
</Table>

View File

@ -2,33 +2,32 @@ import { createPortal } from 'react-dom'
import PropTypes from 'prop-types'
import { useState, useEffect } from 'react'
import { useDispatch } from 'react-redux'
import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from '@/store/actions'
import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from 'store/actions'
import { v4 as uuidv4 } from 'uuid'
import { Box, Typography, Button, IconButton, Dialog, DialogActions, DialogContent, DialogTitle, Stack, OutlinedInput } from '@mui/material'
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 { StyledButton } from 'ui-component/button/StyledButton'
import { TooltipWithParser } from 'ui-component/tooltip/TooltipWithParser'
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'
// API
import assistantsApi from '@/api/assistants'
import assistantsApi from 'api/assistants'
// Hooks
import useConfirm from '@/hooks/useConfirm'
import useApi from '@/hooks/useApi'
import useApi from 'hooks/useApi'
// utils
import useNotifier from '@/utils/useNotifier'
import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions'
import useNotifier from 'utils/useNotifier'
import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from 'store/actions'
const assistantAvailableModels = [
{
@ -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) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
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) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
@ -351,8 +326,71 @@ const AssistantDialog = ({ show, dialogProps, onCancel, onConfirm }) => {
)
}
})
onCancel()
}
setLoading(false)
} catch (error) {
const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}`
enqueueSnackbar({
message: `Failed to sync Assistant: ${errorData}`,
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
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) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
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) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
onCancel()
}
}
@ -578,7 +616,12 @@ const AssistantDialog = ({ show, dialogProps, onCancel, onConfirm }) => {
</DialogContent>
<DialogActions>
{dialogProps.type === 'EDIT' && (
<StyledButton color='error' variant='contained' onClick={() => deleteAssistant()}>
<StyledButton color='secondary' variant='contained' onClick={() => onSyncClick()}>
Sync
</StyledButton>
)}
{dialogProps.type === 'EDIT' && (
<StyledButton color='error' variant='contained' onClick={() => onDeleteClick()}>
Delete
</StyledButton>
)}
@ -590,7 +633,13 @@ const AssistantDialog = ({ show, dialogProps, onCancel, onConfirm }) => {
{dialogProps.confirmButtonName}
</StyledButton>
</DialogActions>
<ConfirmDialog />
<DeleteConfirmDialog
show={deleteDialogOpen}
dialogProps={deleteDialogProps}
onCancel={() => setDeleteDialogOpen(false)}
onDelete={() => deleteAssistant()}
onDeleteBoth={() => deleteAssistant(true)}
/>
{loading && <BackdropLoader open={loading} />}
</Dialog>
) : null

View File

@ -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 ? (
<Dialog
fullWidth
maxWidth='xs'
open={show}
onClose={onCancel}
aria-labelledby='alert-dialog-title'
aria-describedby='alert-dialog-description'
>
<DialogTitle sx={{ fontSize: '1rem' }} id='alert-dialog-title'>
{dialogProps.title}
</DialogTitle>
<DialogContent>
<span>{dialogProps.description}</span>
<div style={{ display: 'flex', flexDirection: 'column', marginTop: 20 }}>
<StyledButton sx={{ mb: 1 }} color='orange' variant='contained' onClick={onDelete}>
Delete only from Flowise
</StyledButton>
<StyledButton sx={{ mb: 1 }} color='error' variant='contained' onClick={onDeleteBoth}>
Delete from both OpenAI and Flowise
</StyledButton>
<Button onClick={onCancel}>{dialogProps.cancelButtonName}</Button>
</div>
</DialogContent>
</Dialog>
) : 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

View File

@ -3,28 +3,31 @@ 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, InputAdornment, TextField } from '@mui/material'
import { useTheme } from '@mui/material/styles'
// project imports
import MainCard from '@/ui-component/cards/MainCard'
import ItemCard from '@/ui-component/cards/ItemCard'
import { gridSpacing } from '@/store/constant'
import WorkflowEmptySVG from '@/assets/images/workflow_empty.svg'
import { StyledButton } from '@/ui-component/button/StyledButton'
import LoginDialog from '@/ui-component/dialog/LoginDialog'
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 LoginDialog from 'ui-component/dialog/LoginDialog'
// API
import chatflowsApi from '@/api/chatflows'
import chatflowsApi from 'api/chatflows'
// Hooks
import useApi from '@/hooks/useApi'
import useApi from 'hooks/useApi'
// const
import { baseURL } from '@/store/constant'
import { baseURL } from 'store/constant'
// icons
import { IconPlus } 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'
import { StyledButton } from '../../ui-component/button/StyledButton'
// ==============================|| CHATFLOWS ||============================== //
@ -35,10 +38,28 @@ 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(localStorage.getItem('flowDisplayStyle') || 'card')
const handleChange = (event, nextView) => {
localStorage.setItem('flowDisplayStyle', nextView)
setView(nextView)
}
const onSearchChange = (event) => {
setSearch(event.target.value)
}
function filterFlows(data) {
return (
data.name.toLowerCase().indexOf(search.toLowerCase()) > -1 ||
(data.category && data.category.toLowerCase().indexOf(search.toLowerCase()) > -1)
)
}
const onLoginClick = (username, password) => {
localStorage.setItem('username', username)
@ -102,26 +123,86 @@ const Chatflows = () => {
return (
<MainCard sx={{ background: customization.isDarkMode ? theme.palette.common.black : '' }}>
<Stack flexDirection='row'>
<h1>Chatflows</h1>
<Grid sx={{ mb: 1.25 }} container direction='row'>
<Box sx={{ flexGrow: 1 }} />
<Grid item>
<StyledButton variant='contained' sx={{ color: 'white' }} onClick={addNew} startIcon={<IconPlus />}>
Add New
</StyledButton>
<Stack flexDirection='column'>
<Box sx={{ flexGrow: 1 }}>
<Toolbar
disableGutters={true}
style={{
margin: 1,
padding: 1,
paddingBottom: 10,
display: 'flex',
justifyContent: 'space-between',
width: '100%'
}}
>
<h1>Chatflows</h1>
<TextField
size='small'
sx={{ display: { xs: 'none', sm: 'block' }, ml: 3 }}
variant='outlined'
placeholder='Search name or category'
onChange={onSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<IconSearch />
</InputAdornment>
)
}}
/>
<Box sx={{ flexGrow: 1 }} />
<ButtonGroup sx={{ maxHeight: 40 }} disableElevation variant='contained' aria-label='outlined primary button group'>
<ButtonGroup disableElevation variant='contained' aria-label='outlined primary button group'>
<ToggleButtonGroup sx={{ maxHeight: 40 }} value={view} color='primary' exclusive onChange={handleChange}>
<ToggleButton
sx={{ color: theme?.customization?.isDarkMode ? 'white' : 'inherit' }}
variant='contained'
value='card'
title='Card View'
selectedColor='#00abc0'
>
<IconLayoutGrid />
</ToggleButton>
<ToggleButton
sx={{ color: theme?.customization?.isDarkMode ? 'white' : 'inherit' }}
variant='contained'
value='list'
title='List View'
>
<IconList />
</ToggleButton>
</ToggleButtonGroup>
</ButtonGroup>
<Box sx={{ width: 5 }} />
<ButtonGroup disableElevation aria-label='outlined primary button group'>
<StyledButton variant='contained' onClick={addNew} startIcon={<IconPlus />}>
Add New
</StyledButton>
</ButtonGroup>
</ButtonGroup>
</Toolbar>
</Box>
{!isLoading && (!view || view === 'card') && getAllChatflowsApi.data && (
<Grid container spacing={gridSpacing}>
{getAllChatflowsApi.data.filter(filterFlows).map((data, index) => (
<Grid key={index} item lg={3} md={4} sm={6} xs={12}>
<ItemCard onClick={() => goToCanvas(data)} data={data} images={images[data.id]} />
</Grid>
))}
</Grid>
</Grid>
)}
{!isLoading && view === 'list' && getAllChatflowsApi.data && (
<FlowListTable
sx={{ mt: 20 }}
data={getAllChatflowsApi.data}
images={images}
filterFunction={filterFlows}
updateFlowsApi={getAllChatflowsApi}
/>
)}
</Stack>
<Grid container spacing={gridSpacing}>
{!isLoading &&
getAllChatflowsApi.data &&
getAllChatflowsApi.data.map((data, index) => (
<Grid key={index} item lg={3} md={4} sm={6} xs={12}>
<ItemCard onClick={() => goToCanvas(data)} data={data} images={images[data.id]} />
</Grid>
))}
</Grid>
{!isLoading && (!getAllChatflowsApi.data || getAllChatflowsApi.data.length === 0) && (
<Stack sx={{ alignItems: 'center', justifyContent: 'center' }} flexDirection='column'>
<Box sx={{ p: 2, height: 'auto' }}>

View File

@ -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,10 +359,30 @@ export const ChatMessage = ({ open, chatflowid, isDialog }) => {
{message.message}
</MemoizedReactMarkdown>
</div>
{message.fileAnnotations && (
<div style={{ display: 'block', flexDirection: 'row', width: '100%' }}>
{message.fileAnnotations.map((fileAnnotation, index) => {
return (
<Button
sx={{ fontSize: '0.85rem', textTransform: 'none', mb: 1 }}
key={index}
variant='outlined'
onClick={() => downloadFile(fileAnnotation)}
endIcon={<IconDownload color={theme.palette.primary.main} />}
>
{fileAnnotation.fileName}
</Button>
)
})}
</div>
)}
{message.sourceDocuments && (
<div style={{ display: 'block', flexDirection: 'row', width: '100%' }}>
{removeDuplicateURL(message).map((source, index) => {
const URL = isValidURL(source.metadata.source)
const URL =
source.metadata && source.metadata.source
? isValidURL(source.metadata.source)
: undefined
return (
<Chip
size='small'

View File

@ -1,36 +1,52 @@
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from '@/store/actions'
import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from 'store/actions'
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
import MainCard from '@/ui-component/cards/MainCard'
import { StyledButton } from '@/ui-component/button/StyledButton'
import MainCard from 'ui-component/cards/MainCard'
import { StyledButton } from 'ui-component/button/StyledButton'
import CredentialListDialog from './CredentialListDialog'
import ConfirmDialog from '@/ui-component/dialog/ConfirmDialog'
import ConfirmDialog from 'ui-component/dialog/ConfirmDialog'
import AddEditCredentialDialog from './AddEditCredentialDialog'
// API
import credentialsApi from '@/api/credentials'
import credentialsApi from 'api/credentials'
// Hooks
import useApi from '@/hooks/useApi'
import useConfirm from '@/hooks/useConfirm'
import useApi from 'hooks/useApi'
import useConfirm from 'hooks/useConfirm'
// utils
import useNotifier from '@/utils/useNotifier'
import useNotifier from 'utils/useNotifier'
// Icons
import { IconTrash, IconEdit, IconX, IconPlus } from '@tabler/icons'
import CredentialEmptySVG from '@/assets/images/credential_empty.svg'
import { IconTrash, IconEdit, IconX, IconPlus, IconSearch } from '@tabler/icons'
import CredentialEmptySVG from 'assets/images/credential_empty.svg'
// const
import { baseURL } from '@/store/constant'
import { SET_COMPONENT_CREDENTIALS } from '@/store/actions'
import { baseURL } from 'store/constant'
import { SET_COMPONENT_CREDENTIALS } from 'store/actions'
// ==============================|| Credentials ||============================== //
@ -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 = () => {
<>
<MainCard sx={{ background: customization.isDarkMode ? theme.palette.common.black : '' }}>
<Stack flexDirection='row'>
<h1>Credentials&nbsp;</h1>
<Box sx={{ flexGrow: 1 }} />
<StyledButton
variant='contained'
sx={{ color: 'white', mr: 1, height: 37 }}
onClick={listCredential}
startIcon={<IconPlus />}
>
Add Credential
</StyledButton>
<Box sx={{ flexGrow: 1 }}>
<Toolbar
disableGutters={true}
style={{
margin: 1,
padding: 1,
paddingBottom: 10,
display: 'flex',
justifyContent: 'space-between',
width: '100%'
}}
>
<h1>Credentials&nbsp;</h1>
<TextField
size='small'
sx={{ display: { xs: 'none', sm: 'block' }, ml: 3 }}
variant='outlined'
placeholder='Search credential name'
onChange={onSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<IconSearch />
</InputAdornment>
)
}}
/>
<Box sx={{ flexGrow: 1 }} />
<ButtonGroup
sx={{ maxHeight: 40 }}
disableElevation
variant='contained'
aria-label='outlined primary button group'
>
<ButtonGroup disableElevation aria-label='outlined primary button group'>
<StyledButton
variant='contained'
sx={{ color: 'white', mr: 1, height: 37 }}
onClick={listCredential}
startIcon={<IconPlus />}
>
Add Credential
</StyledButton>
</ButtonGroup>
</ButtonGroup>
</Toolbar>
</Box>
</Stack>
{credentials.length <= 0 && (
<Stack sx={{ alignItems: 'center', justifyContent: 'center' }} flexDirection='column'>
@ -205,7 +265,7 @@ const Credentials = () => {
</TableRow>
</TableHead>
<TableBody>
{credentials.map((credential, index) => (
{credentials.filter(filterCredentials).map((credential, index) => (
<TableRow key={index} sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component='th' scope='row'>
<div