Merge branch 'main' into FEATURE/UI-Updates

# Conflicts:
#	packages/server/src/database/migrations/mysql/index.ts
#	packages/server/src/database/migrations/postgres/index.ts
#	packages/server/src/database/migrations/sqlite/index.ts
This commit is contained in:
Henry 2023-11-20 12:18:23 +00:00
commit de7f343f90
29 changed files with 861 additions and 88 deletions

View File

@ -0,0 +1,25 @@
import { INodeParams, INodeCredential } from '../src/Interface'
class MongoDBUrlApi implements INodeCredential {
label: string
name: string
version: number
description: string
inputs: INodeParams[]
constructor() {
this.label = 'MongoDB ATLAS'
this.name = 'mongoDBUrlApi'
this.version = 1.0
this.inputs = [
{
label: 'ATLAS Connection URL',
name: 'mongoDBConnectUrl',
type: 'string',
placeholder: 'mongodb+srv://myDatabaseUser:D1fficultP%40ssw0rd@cluster0.example.mongodb.net/?retryWrites=true&w=majority'
}
]
}
}
module.exports = { credClass: MongoDBUrlApi }

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

View File

@ -0,0 +1,146 @@
import { getBaseClasses, getCredentialData, getCredentialParam, ICommonObject, INode, INodeData, INodeParams } from '../../../src'
import { MongoDBChatMessageHistory } from 'langchain/stores/message/mongodb'
import { BufferMemory, BufferMemoryInput } from 'langchain/memory'
import { BaseMessage, mapStoredMessageToChatMessage } from 'langchain/schema'
import { MongoClient } from 'mongodb'
class MongoDB_Memory implements INode {
label: string
name: string
version: number
description: string
type: string
icon: string
category: string
baseClasses: string[]
credential: INodeParams
inputs: INodeParams[]
constructor() {
this.label = 'MongoDB Atlas Chat Memory'
this.name = 'MongoDBAtlasChatMemory'
this.version = 1.0
this.type = 'MongoDBAtlasChatMemory'
this.icon = 'mongodb.png'
this.category = 'Memory'
this.description = 'Stores the conversation in MongoDB Atlas'
this.baseClasses = [this.type, ...getBaseClasses(BufferMemory)]
this.credential = {
label: 'Connect Credential',
name: 'credential',
type: 'credential',
credentialNames: ['mongoDBUrlApi']
}
this.inputs = [
{
label: 'Database',
name: 'databaseName',
placeholder: '<DB_NAME>',
type: 'string'
},
{
label: 'Collection Name',
name: 'collectionName',
placeholder: '<COLLECTION_NAME>',
type: 'string'
},
{
label: 'Session Id',
name: 'sessionId',
type: 'string',
description: 'If not specified, the first CHAT_MESSAGE_ID will be used as sessionId',
default: '',
additionalParams: true,
optional: true
},
{
label: 'Memory Key',
name: 'memoryKey',
type: 'string',
default: 'chat_history',
additionalParams: true
}
]
}
async init(nodeData: INodeData, _: string, options: ICommonObject): Promise<any> {
return initializeMongoDB(nodeData, options)
}
async clearSessionMemory(nodeData: INodeData, options: ICommonObject): Promise<void> {
const mongodbMemory = await initializeMongoDB(nodeData, options)
const sessionId = nodeData.inputs?.sessionId as string
const chatId = options?.chatId as string
options.logger.info(`Clearing MongoDB memory session ${sessionId ? sessionId : chatId}`)
await mongodbMemory.clear()
options.logger.info(`Successfully cleared MongoDB memory session ${sessionId ? sessionId : chatId}`)
}
}
const initializeMongoDB = async (nodeData: INodeData, options: ICommonObject): Promise<BufferMemory> => {
const databaseName = nodeData.inputs?.databaseName as string
const collectionName = nodeData.inputs?.collectionName as string
const sessionId = nodeData.inputs?.sessionId as string
const memoryKey = nodeData.inputs?.memoryKey as string
const chatId = options?.chatId as string
let isSessionIdUsingChatMessageId = false
if (!sessionId && chatId) isSessionIdUsingChatMessageId = true
const credentialData = await getCredentialData(nodeData.credential ?? '', options)
let mongoDBConnectUrl = getCredentialParam('mongoDBConnectUrl', credentialData, nodeData)
const client = new MongoClient(mongoDBConnectUrl)
await client.connect()
const collection = client.db(databaseName).collection(collectionName)
const mongoDBChatMessageHistory = new MongoDBChatMessageHistory({
collection,
sessionId: sessionId ? sessionId : chatId
})
mongoDBChatMessageHistory.getMessages = async (): Promise<BaseMessage[]> => {
const document = await collection.findOne({
sessionId: (mongoDBChatMessageHistory as any).sessionId
})
const messages = document?.messages || []
return messages.map(mapStoredMessageToChatMessage)
}
mongoDBChatMessageHistory.addMessage = async (message: BaseMessage): Promise<void> => {
const messages = [message].map((msg) => msg.toDict())
await collection.updateOne(
{ sessionId: (mongoDBChatMessageHistory as any).sessionId },
{
$push: { messages: { $each: messages } }
},
{ upsert: true }
)
}
mongoDBChatMessageHistory.clear = async (): Promise<void> => {
await collection.deleteOne({ sessionId: (mongoDBChatMessageHistory as any).sessionId })
}
return new BufferMemoryExtended({
memoryKey,
chatHistory: mongoDBChatMessageHistory,
returnMessages: true,
isSessionIdUsingChatMessageId
})
}
interface BufferMemoryExtendedInput {
isSessionIdUsingChatMessageId: boolean
}
class BufferMemoryExtended extends BufferMemory {
isSessionIdUsingChatMessageId? = false
constructor(fields: BufferMemoryInput & Partial<BufferMemoryExtendedInput>) {
super(fields)
this.isSessionIdUsingChatMessageId = fields.isSessionIdUsingChatMessageId
}
}
module.exports = { nodeClass: MongoDB_Memory }

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -50,7 +50,7 @@ class ElasicsearchUpsert_VectorStores extends ElasticSearchBase implements INode
delete d.metadata.loc
})
// end of workaround
return super.init(nodeData, _, options, flattenDocs)
return super.init(nodeData, _, options, finalDocs)
}
}

View File

@ -0,0 +1,145 @@
import {
getBaseClasses,
getCredentialData,
getCredentialParam,
ICommonObject,
INodeData,
INodeOutputsValue,
INodeParams
} from '../../../src'
import { Embeddings } from 'langchain/embeddings/base'
import { VectorStore } from 'langchain/vectorstores/base'
import { Document } from 'langchain/document'
import { MongoDBAtlasVectorSearch } from 'langchain/vectorstores/mongodb_atlas'
import { Collection, MongoClient } from 'mongodb'
export abstract class MongoDBSearchBase {
label: string
name: string
version: number
description: string
type: string
icon: string
category: string
baseClasses: string[]
inputs: INodeParams[]
credential: INodeParams
outputs: INodeOutputsValue[]
mongoClient: MongoClient
protected constructor() {
this.type = 'MongoDB Atlas'
this.icon = 'mongodb.png'
this.category = 'Vector Stores'
this.baseClasses = [this.type, 'VectorStoreRetriever', 'BaseRetriever']
this.credential = {
label: 'Connect Credential',
name: 'credential',
type: 'credential',
credentialNames: ['mongoDBUrlApi']
}
this.inputs = [
{
label: 'Embeddings',
name: 'embeddings',
type: 'Embeddings'
},
{
label: 'Database',
name: 'databaseName',
placeholder: '<DB_NAME>',
type: 'string'
},
{
label: 'Collection Name',
name: 'collectionName',
placeholder: '<COLLECTION_NAME>',
type: 'string'
},
{
label: 'Index Name',
name: 'indexName',
placeholder: '<VECTOR_INDEX_NAME>',
type: 'string'
},
{
label: 'Content Field',
name: 'textKey',
description: 'Name of the field (column) that contains the actual content',
type: 'string',
default: 'text',
additionalParams: true,
optional: true
},
{
label: 'Embedded Field',
name: 'embeddingKey',
description: 'Name of the field (column) that contains the Embedding',
type: 'string',
default: 'embedding',
additionalParams: true,
optional: true
},
{
label: 'Top K',
name: 'topK',
description: 'Number of top results to fetch. Default to 4',
placeholder: '4',
type: 'number',
additionalParams: true,
optional: true
}
]
this.outputs = [
{
label: 'MongoDB Retriever',
name: 'retriever',
baseClasses: this.baseClasses
},
{
label: 'MongoDB Vector Store',
name: 'vectorStore',
baseClasses: [this.type, ...getBaseClasses(MongoDBAtlasVectorSearch)]
}
]
}
abstract constructVectorStore(
embeddings: Embeddings,
collection: Collection,
indexName: string,
textKey: string,
embeddingKey: string,
docs: Document<Record<string, any>>[] | undefined
): Promise<VectorStore>
async init(nodeData: INodeData, _: string, options: ICommonObject, docs: Document<Record<string, any>>[] | undefined): Promise<any> {
const credentialData = await getCredentialData(nodeData.credential ?? '', options)
const databaseName = nodeData.inputs?.databaseName as string
const collectionName = nodeData.inputs?.collectionName as string
const indexName = nodeData.inputs?.indexName as string
let textKey = nodeData.inputs?.textKey as string
let embeddingKey = nodeData.inputs?.embeddingKey as string
const embeddings = nodeData.inputs?.embeddings as Embeddings
const topK = nodeData.inputs?.topK as string
const k = topK ? parseFloat(topK) : 4
const output = nodeData.outputs?.output as string
let mongoDBConnectUrl = getCredentialParam('mongoDBConnectUrl', credentialData, nodeData)
this.mongoClient = new MongoClient(mongoDBConnectUrl)
const collection = this.mongoClient.db(databaseName).collection(collectionName)
if (!textKey || textKey === '') textKey = 'text'
if (!embeddingKey || embeddingKey === '') embeddingKey = 'embedding'
const vectorStore = await this.constructVectorStore(embeddings, collection, indexName, textKey, embeddingKey, docs)
if (output === 'retriever') {
return vectorStore.asRetriever(k)
} else if (output === 'vectorStore') {
;(vectorStore as any).k = k
return vectorStore
}
return vectorStore
}
}

View File

@ -0,0 +1,39 @@
import { Collection } from 'mongodb'
import { MongoDBAtlasVectorSearch } from 'langchain/vectorstores/mongodb_atlas'
import { Embeddings } from 'langchain/embeddings/base'
import { VectorStore } from 'langchain/vectorstores/base'
import { Document } from 'langchain/document'
import { MongoDBSearchBase } from './MongoDBSearchBase'
import { ICommonObject, INode, INodeData } from '../../../src/Interface'
class MongoDBExisting_VectorStores extends MongoDBSearchBase implements INode {
constructor() {
super()
this.label = 'MongoDB Atlas Load Existing Index'
this.name = 'MongoDBIndex'
this.version = 1.0
this.description = 'Load existing data from MongoDB Atlas (i.e: Document has been upserted)'
}
async init(nodeData: INodeData, _: string, options: ICommonObject): Promise<any> {
return super.init(nodeData, _, options, undefined)
}
async constructVectorStore(
embeddings: Embeddings,
collection: Collection,
indexName: string,
textKey: string,
embeddingKey: string,
_: Document<Record<string, any>>[] | undefined
): Promise<VectorStore> {
return new MongoDBAtlasVectorSearch(embeddings, {
collection: collection,
indexName: indexName,
textKey: textKey,
embeddingKey: embeddingKey
})
}
}
module.exports = { nodeClass: MongoDBExisting_VectorStores }

View File

@ -0,0 +1,59 @@
import { flatten } from 'lodash'
import { Collection } from 'mongodb'
import { Embeddings } from 'langchain/embeddings/base'
import { Document } from 'langchain/document'
import { VectorStore } from 'langchain/vectorstores/base'
import { MongoDBAtlasVectorSearch } from 'langchain/vectorstores/mongodb_atlas'
import { ICommonObject, INode, INodeData } from '../../../src/Interface'
import { MongoDBSearchBase } from './MongoDBSearchBase'
class MongoDBUpsert_VectorStores extends MongoDBSearchBase implements INode {
constructor() {
super()
this.label = 'MongoDB Upsert Document'
this.name = 'MongoDBUpsert'
this.version = 1.0
this.description = 'Upsert documents to MongoDB Atlas'
this.inputs.unshift({
label: 'Document',
name: 'document',
type: 'Document',
list: true
})
}
async constructVectorStore(
embeddings: Embeddings,
collection: Collection,
indexName: string,
textKey: string,
embeddingKey: string,
docs: Document<Record<string, any>>[]
): Promise<VectorStore> {
const mongoDBAtlasVectorSearch = new MongoDBAtlasVectorSearch(embeddings, {
collection: collection,
indexName: indexName,
textKey: textKey,
embeddingKey: embeddingKey
})
await mongoDBAtlasVectorSearch.addDocuments(docs)
return mongoDBAtlasVectorSearch
}
async init(nodeData: INodeData, _: string, options: ICommonObject): Promise<any> {
const docs = nodeData.inputs?.document as Document[]
const flattenDocs = docs && docs.length ? flatten(docs) : []
const finalDocs = []
for (let i = 0; i < flattenDocs.length; i += 1) {
if (flattenDocs[i] && flattenDocs[i].pageContent) {
const document = new Document(flattenDocs[i])
finalDocs.push(document)
}
}
return super.init(nodeData, _, options, finalDocs)
}
}
module.exports = { nodeClass: MongoDBUpsert_VectorStores }

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -56,7 +56,7 @@ class RedisUpsert_VectorStores extends RedisSearchBase implements INode {
}
}
return super.init(nodeData, _, options, flattenDocs)
return super.init(nodeData, _, options, finalDocs)
}
}

View File

@ -49,12 +49,13 @@
"html-to-text": "^9.0.5",
"ioredis": "^5.3.2",
"langchain": "^0.0.165",
"langfuse-langchain": "^1.0.14-alpha.0",
"langfuse-langchain": "^1.0.31",
"langsmith": "^0.0.32",
"linkifyjs": "^4.1.1",
"llmonitor": "^0.5.5",
"mammoth": "^1.5.1",
"moment": "^2.29.3",
"mongodb": "^6.2.0",
"mysql2": "^3.5.1",
"node-fetch": "^2.6.11",
"node-html-markdown": "^1.3.0",

View File

@ -72,6 +72,7 @@ export interface INodeParams {
fileType?: string
additionalParams?: boolean
loadMethod?: string
hidden?: boolean
}
export interface INodeExecutionData {

View File

@ -250,6 +250,7 @@ export const additionalCallbacks = async (nodeData: INodeData, options: ICommonO
baseUrl: langFuseEndpoint ?? 'https://cloud.langfuse.com'
}
if (release) langFuseOptions.release = release
if (options.chatId) langFuseOptions.userId = options.chatId
const handler = new CallbackHandler(langFuseOptions)
callbacks.push(handler)

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

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

@ -9,6 +9,7 @@ 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,
@ -21,5 +22,6 @@ export const mysqlMigrations = [
AddChatHistory1694658767766,
AddAssistantEntity1699325775451,
AddUsedToolsToChatMessage1699481607341,
AddCategoryToChatFlow1699900910291
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 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

@ -9,6 +9,7 @@ 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,
@ -21,5 +22,6 @@ export const postgresMigrations = [
AddChatHistory1694658756136,
AddAssistantEntity1699325775451,
AddUsedToolsToChatMessage1699481607341,
AddCategoryToChatFlow1699900910291
AddCategoryToChatFlow1699900910291,
AddFileAnnotationsToChatMessage1700271021237
]

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

@ -9,6 +9,7 @@ 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,
@ -21,5 +22,6 @@ export const sqliteMigrations = [
AddChatHistory1694657778173,
AddAssistantEntity1699325775451,
AddUsedToolsToChatMessage1699481607341,
AddCategoryToChatFlow1699900910291
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) => {
@ -786,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(
@ -956,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) {
@ -965,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
// ----------------------------------------
@ -1503,6 +1512,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

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

@ -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}
</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) => {

View File

@ -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) => (
<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

@ -207,9 +207,11 @@ const CanvasNode = ({ data }) => {
{data.inputAnchors.map((inputAnchor, index) => (
<NodeInputHandler key={index} inputAnchor={inputAnchor} data={data} />
))}
{data.inputParams.map((inputParam, index) => (
<NodeInputHandler key={index} inputParam={inputParam} data={data} />
))}
{data.inputParams
.filter((inputParam) => !inputParam.hidden)
.map((inputParam, index) => (
<NodeInputHandler key={index} inputParam={inputParam} data={data} />
))}
{data.inputParams.find((param) => param.additionalParams) && (
<div
style={{

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,6 +359,23 @@ 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) => {