diff --git a/metrics/prometheus/prometheus.config.yml b/metrics/prometheus/prometheus.config.yml index 82ae71fc7e4..32dd594a4a7 100644 --- a/metrics/prometheus/prometheus.config.yml +++ b/metrics/prometheus/prometheus.config.yml @@ -6,4 +6,8 @@ scrape_configs: - targets: ["localhost:8080","localhost:3000"] metrics_path: /api/v1/metrics/ - scheme: http \ No newline at end of file + scheme: http + + authorization: + type: Bearer + credentials_file: '/etc/prometheus/api_key.txt' diff --git a/packages/components/credentials/ZendeskApi.credential.ts b/packages/components/credentials/ZendeskApi.credential.ts new file mode 100644 index 00000000000..d2be833e2d5 --- /dev/null +++ b/packages/components/credentials/ZendeskApi.credential.ts @@ -0,0 +1,34 @@ +import { INodeParams, INodeCredential } from '../src/Interface' + +class ZendeskApi implements INodeCredential { + label: string + name: string + version: number + description: string + inputs: INodeParams[] + + constructor() { + this.label = 'Zendesk API' + this.name = 'zendeskApi' + this.version = 1.0 + this.description = + 'Refer to official guide on how to get API token from Zendesk' + this.inputs = [ + { + label: 'User Name', + name: 'user', + type: 'string', + placeholder: 'user@example.com' + }, + { + label: 'API Token', + name: 'token', + type: 'password', + placeholder: '' + } + ] + } +} + +module.exports = { credClass: ZendeskApi } + diff --git a/packages/components/models.json b/packages/components/models.json index 6248ffd8700..9bfeea632e0 100644 --- a/packages/components/models.json +++ b/packages/components/models.json @@ -893,6 +893,7 @@ } ], "regions": [ + { "label": "global", "name": "global" }, { "label": "us-east1", "name": "us-east1" }, { "label": "us-east4", "name": "us-east4" }, { "label": "us-central1", "name": "us-central1" }, diff --git a/packages/components/nodes/documentloaders/Zendesk/Zendesk.ts b/packages/components/nodes/documentloaders/Zendesk/Zendesk.ts new file mode 100644 index 00000000000..e5d106ce153 --- /dev/null +++ b/packages/components/nodes/documentloaders/Zendesk/Zendesk.ts @@ -0,0 +1,484 @@ +import { omit } from 'lodash' +import axios from 'axios' +import { ICommonObject, IDocument, INode, INodeData, INodeParams, INodeOutputsValue } from '../../../src/Interface' +import { getCredentialData, getCredentialParam, handleEscapeCharacters } from '../../../src' +import { BaseDocumentLoader } from 'langchain/document_loaders/base' + +interface ZendeskConfig { + zendeskDomain: string + user?: string + token?: string + brandId?: string + publishedArticlesOnly: boolean + locales: string[] + charsPerToken: number + api: { + protocol: string + helpCenterPath: string + articlesEndpoint: string + publicPath: string + } + defaultTitle: string + chunking: { + maxTokens: number + chunkSize: number + overlap: number + } +} + +interface Chunk { + content: string + index: number + tokenSize: number +} + +interface ZendeskArticle { + id: number + name?: string + title?: string + body?: string +} + +interface ZendeskArticlesResponse { + articles?: ZendeskArticle[] + next_page?: string +} + +class Zendesk_DocumentLoaders implements INode { + label: string + name: string + version: number + description: string + type: string + icon: string + category: string + baseClasses: string[] + credential: INodeParams + inputs: INodeParams[] + outputs: INodeOutputsValue[] + + constructor() { + this.label = 'Zendesk' + this.name = 'zendesk' + this.version = 1.0 + this.type = 'Document' + this.icon = 'zendesk.svg' + this.category = 'Document Loaders' + this.description = `Load articles from Zendesk Knowledge Base` + this.baseClasses = [this.type] + this.credential = { + label: 'Connect Credential', + name: 'credential', + type: 'credential', + description: 'Zendesk API Credential', + credentialNames: ['zendeskApi'] + } + this.inputs = [ + { + label: 'Zendesk Domain', + name: 'zendeskDomain', + type: 'string', + placeholder: 'example.zendesk.com', + description: 'Your Zendesk domain (e.g., example.zendesk.com)' + }, + { + label: 'Brand ID', + name: 'brandId', + type: 'string', + optional: true, + placeholder: '123456', + description: 'Optional brand ID to filter articles' + }, + { + label: 'Locale', + name: 'locale', + type: 'string', + default: 'en-us', + optional: true, + placeholder: 'en-us', + description: 'Locale code(s) for articles. Can be a single locale (e.g., en-us) or comma-separated list (e.g., en-us, en-gb, fr-fr). Defaults to en-us if not provided.' + }, + { + label: 'Published Articles Only', + name: 'publishedArticlesOnly', + type: 'boolean', + default: true, + optional: true, + description: 'Only load published articles' + }, + { + label: 'Characters Per Token', + name: 'charsPerToken', + type: 'number', + default: 4, + optional: true, + description: 'Approximate characters per token for size estimation', + step: 1 + }, + { + label: 'Additional Metadata', + name: 'metadata', + type: 'json', + description: 'Additional metadata to be added to the extracted documents', + optional: true, + additionalParams: true + }, + { + label: 'Omit Metadata Keys', + name: 'omitMetadataKeys', + type: 'string', + rows: 4, + description: + 'Each document loader comes with a default set of metadata keys that are extracted from the document. You can use this field to omit some of the default metadata keys. The value should be a list of keys, seperated by comma. Use * to omit all metadata keys except the ones you specify in the Additional Metadata field', + placeholder: 'key1, key2, key3.nestedKey1', + optional: true, + additionalParams: true + } + ] + this.outputs = [ + { + label: 'Document', + name: 'document', + description: 'Array of document objects containing metadata and pageContent', + baseClasses: [...this.baseClasses, 'json'] + }, + { + label: 'Text', + name: 'text', + description: 'Concatenated string from pageContent of documents', + baseClasses: ['string', 'json'] + } + ] + } + + async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { + const zendeskDomain = nodeData.inputs?.zendeskDomain as string + const brandId = nodeData.inputs?.brandId as string + const localeInputRaw = nodeData.inputs?.locale as string + const localeInput = (localeInputRaw && localeInputRaw.trim()) || 'en-us' + const publishedArticlesOnly = (nodeData.inputs?.publishedArticlesOnly as boolean) ?? true + const charsPerToken = (nodeData.inputs?.charsPerToken as number) ?? 4 + const metadata = nodeData.inputs?.metadata + const _omitMetadataKeys = nodeData.inputs?.omitMetadataKeys as string + const output = nodeData.outputs?.output as string + + let omitMetadataKeys: string[] = [] + if (_omitMetadataKeys) { + omitMetadataKeys = _omitMetadataKeys.split(',').map((key) => key.trim()) + } + + // Parse comma-separated locales + const locales = localeInput + .split(',') + .map((loc) => loc.trim()) + .filter((loc) => loc.length > 0) + + // Ensure at least one locale + if (locales.length === 0) { + locales.push('en-us') + } + + const credentialData = await getCredentialData(nodeData.credential ?? '', options) + const user = getCredentialParam('user', credentialData, nodeData) + const token = getCredentialParam('token', credentialData, nodeData) + + // Build configuration + const config: ZendeskConfig = { + zendeskDomain, + user, + token, + brandId, + publishedArticlesOnly, + locales, + charsPerToken, + api: { + protocol: 'https://', + helpCenterPath: '/api/v2/help_center/', + articlesEndpoint: 'articles.json', + publicPath: '/hc/' + }, + defaultTitle: 'Untitled', + chunking: { + maxTokens: 3000, + chunkSize: 1000, + overlap: 200 + } + } + + const loader = new ZendeskLoader(config) + + // Extract articles + let docs: IDocument[] = await loader.load() + + // Apply metadata handling + let parsedMetadata = {} + + if (metadata) { + try { + parsedMetadata = typeof metadata === 'object' ? metadata : JSON.parse(metadata) + } catch (error) { + throw new Error(`Error parsing Additional Metadata: ${error.message}`) + } + } + + docs = docs.map((doc) => ({ + ...doc, + metadata: + _omitMetadataKeys === '*' + ? { ...parsedMetadata } + : omit( + { + ...doc.metadata, + ...parsedMetadata + }, + omitMetadataKeys + ) + })) + + if (output === 'document') { + return docs + } else { + let finaltext = '' + for (const doc of docs) { + finaltext += `${doc.pageContent}\n` + } + return handleEscapeCharacters(finaltext, false) + } + } +} + +class ZendeskLoader extends BaseDocumentLoader { + private config: ZendeskConfig + + constructor(config: ZendeskConfig) { + super() + this.config = config + this.validateConfig(this.config) + } + + /** + * Validate configuration + */ + private validateConfig(config: Partial): void { + const errors: string[] = [] + + if (!config.zendeskDomain) { + errors.push('Zendesk domain is required') + } else if (!config.zendeskDomain.match(/^.+\.zendesk\.com$/)) { + errors.push('Zendesk domain must be a valid zendesk.com domain (e.g., example.zendesk.com)') + } + + if (!config.token) { + errors.push('Zendesk auth token is required') + } + + if (config.user && !config.user.includes('@')) { + errors.push('Zendesk auth user must be a valid email address') + } + + if (config.brandId && !/^\d+$/.test(config.brandId)) { + errors.push('Brand ID must be a numeric string') + } + + if (!config.locales || !config.locales.length || !config.locales[0]) { + errors.push('Locale is required') + } + + if (errors.length > 0) { + const errorMessage = 'Configuration validation failed:\n • ' + errors.join('\n • ') + throw new Error(errorMessage) + } + } + + /** + * Helper to fetch all articles with pagination + */ + private async fetchAllArticles( + locale: string, + brandId: string | undefined, + config: ZendeskConfig, + axiosHeaders: Record + ): Promise { + const allArticles: ZendeskArticle[] = [] + let page = 1 + let hasMore = true + const baseUri = `${config.api.protocol}${config.zendeskDomain}${config.api.helpCenterPath}` + + while (hasMore) { + let articlesUri = `${baseUri}${config.api.articlesEndpoint}?locale=${locale}&page=${page}` + + // Add status filter if publishedOnly is true + if (config.publishedArticlesOnly) { + articlesUri += '&status=published' + } + + if (brandId) { + articlesUri += `&brand_id=${brandId}` + } + + try { + const resp = await axios.get(articlesUri, { headers: axiosHeaders }) + const data = resp.data + + if (data.articles && data.articles.length > 0) { + allArticles.push(...data.articles) + page++ + hasMore = !!data.next_page + } else { + hasMore = false + } + } catch (error: any) { + if (error.response) { + const status = error.response.status + const statusText = error.response.statusText + + if (status === 401) { + throw new Error(`Authentication failed (${status}): Please check your Zendesk credentials`) + } else if (status === 403) { + throw new Error( + `Access forbidden (${status}): You don't have permission to access this Zendesk instance` + ) + } else if (status === 404) { + throw new Error(`Not found (${status}): The Zendesk URL or brand ID may be incorrect`) + } else if (status >= 500) { + throw new Error(`Zendesk server error (${status}): ${statusText}. Please try again later`) + } else { + throw new Error(`HTTP error (${status}): ${statusText}`) + } + } else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') { + throw new Error( + `Network error: Cannot connect to Zendesk. Please check the domain: ${config.zendeskDomain}` + ) + } else { + throw new Error(`Request failed: ${error.message}`) + } + } + } + + return allArticles + } + + /** + * Build article URL from domain and article details + */ + private buildArticleUrl(config: ZendeskConfig, locale: string, articleId: number): string | null { + if (!config.zendeskDomain || !articleId) { + return null + } + + return `${config.api.protocol}${config.zendeskDomain}${config.api.publicPath}${locale}/articles/${articleId}` + } + + /** + * Chunk text content based on token limits + */ + private chunkContent(content: string, chunkSize: number, overlap: number, charsPerToken: number): Chunk[] { + const chunks: Chunk[] = [] + const contentLength = content.length + const chunkCharSize = chunkSize * charsPerToken + const overlapCharSize = overlap * charsPerToken + + let start = 0 + let chunkIndex = 0 + + while (start < contentLength) { + const end = Math.min(start + chunkCharSize, contentLength) + const chunk = content.substring(start, end) + + chunks.push({ + content: chunk, + index: chunkIndex, + tokenSize: Math.ceil(chunk.length / charsPerToken) + }) + + chunkIndex++ + + // Move start position, accounting for overlap + if (end >= contentLength) break + start = end - overlapCharSize + } + + return chunks + } + + /** + * Transform article to required format with chunking support + */ + private transformArticle(article: ZendeskArticle, config: ZendeskConfig, locale: string): IDocument[] { + const articleUrl = this.buildArticleUrl(config, locale, article.id) + const content = article.body || '' + const tokenSize = Math.ceil(content.length / config.charsPerToken) + const title = article.name || article.title || config.defaultTitle + const articleId = String(article.id) + + // If article is small enough, return as single document + if (tokenSize <= config.chunking.maxTokens) { + return [ + { + pageContent: content, + metadata: { + title: title, + url: articleUrl, + id: articleId + } + } + ] + } + + // Article needs chunking + const chunks = this.chunkContent( + content, + config.chunking.chunkSize, + config.chunking.overlap, + config.charsPerToken + ) + + return chunks.map((chunk) => ({ + pageContent: chunk.content, + metadata: { + title: title, + url: articleUrl, + id: `${articleId}-${chunk.index + 1}` + } + })) + } + + /** + * Extract all articles from Zendesk + */ + private async extractAllArticles(config: ZendeskConfig): Promise { + const allTransformedArticles: IDocument[] = [] + + // Setup authentication headers + let axiosHeaders: Record = {} + if (config.user && config.token) { + const authString = `${config.user}/token:${config.token}` + const encoded = Buffer.from(authString).toString('base64') + axiosHeaders = { + Authorization: `Basic ${encoded}` + } + } + + // Process each locale + for (const locale of config.locales) { + const articles = await this.fetchAllArticles(locale, config.brandId || undefined, config, axiosHeaders) + // Transform each article to the required format + for (const article of articles) { + const transformedChunks = this.transformArticle(article, config, locale) + // Process each chunk (will be 1 chunk for small articles) + for (const chunk of transformedChunks) { + allTransformedArticles.push(chunk) + } + } + } + + return allTransformedArticles + } + + async load(): Promise { + return await this.extractAllArticles(this.config) + } +} + +module.exports = { nodeClass: Zendesk_DocumentLoaders } + diff --git a/packages/components/nodes/documentloaders/Zendesk/zendesk.svg b/packages/components/nodes/documentloaders/Zendesk/zendesk.svg new file mode 100644 index 00000000000..cc7edc68ce2 --- /dev/null +++ b/packages/components/nodes/documentloaders/Zendesk/zendesk.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/components/nodes/documentloaders/Zendesk/zendesk.test.ts b/packages/components/nodes/documentloaders/Zendesk/zendesk.test.ts new file mode 100644 index 00000000000..021ba4a96b2 --- /dev/null +++ b/packages/components/nodes/documentloaders/Zendesk/zendesk.test.ts @@ -0,0 +1,718 @@ +const { nodeClass: Zendesk_DocumentLoaders } = require('./Zendesk') +import { INodeData } from '../../../src/Interface' +import axios from 'axios' + +// Mock axios +jest.mock('axios') +const mockedAxios = axios as jest.Mocked + +// Mock credential helpers +jest.mock('../../../src', () => ({ + getCredentialData: jest.fn(), + getCredentialParam: jest.fn(), + handleEscapeCharacters: jest.fn((str: string) => str) +})) + +const { getCredentialData, getCredentialParam } = require('../../../src') + +// Helper function to create a valid INodeData object +function createNodeData(id: string, inputs: any, credential?: string): INodeData { + return { + id: id, + label: 'Zendesk', + name: 'zendesk', + type: 'Document', + icon: 'zendesk.svg', + version: 1.0, + category: 'Document Loaders', + baseClasses: ['Document'], + inputs: inputs, + credential: credential, + outputs: { + output: 'document' + } + } +} + +describe('Zendesk', () => { + let nodeClass: any + + beforeEach(() => { + nodeClass = new Zendesk_DocumentLoaders() + jest.clearAllMocks() + + // Default credential mocks + ;(getCredentialData as jest.Mock).mockResolvedValue({}) + ;(getCredentialParam as jest.Mock).mockImplementation((param: string) => { + if (param === 'user') return 'user@example.com' + if (param === 'token') return 'test-token' + return undefined + }) + }) + + describe('Configuration Validation', () => { + it('should throw error when zendeskDomain is not provided', async () => { + const nodeData = createNodeData('test-1', { + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Configuration validation failed' + ) + }) + + it('should use default locale (en-us) when locale is not provided', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Test Article', + body: 'Test content' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-2', { + zendeskDomain: 'example.zendesk.com' + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + // Verify the API was called with en-us locale + const callUrl = mockedAxios.get.mock.calls[0][0] as string + expect(callUrl).toContain('locale=en-us') + }) + + it('should throw error when zendeskDomain is invalid', async () => { + const nodeData = createNodeData('test-3', { + zendeskDomain: 'invalid-domain.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Zendesk domain must be a valid zendesk.com domain' + ) + }) + + it('should throw error when token is not provided', async () => { + ;(getCredentialParam as jest.Mock).mockImplementation((param: string) => { + if (param === 'user') return 'user@example.com' + return undefined + }) + + const nodeData = createNodeData('test-4', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Zendesk auth token is required' + ) + }) + + it('should throw error when user is not a valid email', async () => { + ;(getCredentialParam as jest.Mock).mockImplementation((param: string) => { + if (param === 'user') return 'invalid-user' + if (param === 'token') return 'test-token' + return undefined + }) + + const nodeData = createNodeData('test-5', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Zendesk auth user must be a valid email address' + ) + }) + + it('should throw error when brandId is not numeric', async () => { + const nodeData = createNodeData('test-6', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + brandId: 'invalid-id' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Brand ID must be a numeric string' + ) + }) + + it('should accept valid configuration', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Test Article', + body: 'Test content' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-7', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + }) + }) + + describe('Article Fetching', () => { + it('should fetch articles successfully', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Article 1', + body: 'Content 1' + }, + { + id: 2, + name: 'Article 2', + body: 'Content 2' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-8', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result).toHaveLength(2) + expect(result[0].pageContent).toBe('Content 1') + expect(result[0].metadata.title).toBe('Article 1') + expect(result[0].metadata.id).toBe('1') + }) + + it('should handle pagination', async () => { + const firstPage = { + data: { + articles: [ + { + id: 1, + name: 'Article 1', + body: 'Content 1' + } + ], + next_page: 'https://example.zendesk.com/api/v2/help_center/articles.json?page=2' + } + } + + const secondPage = { + data: { + articles: [ + { + id: 2, + name: 'Article 2', + body: 'Content 2' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage) + + const nodeData = createNodeData('test-9', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result).toHaveLength(2) + expect(mockedAxios.get).toHaveBeenCalledTimes(2) + }) + + it('should add status filter when publishedArticlesOnly is true', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Article 1', + body: 'Content 1' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-10', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + publishedArticlesOnly: true + }) + + await nodeClass.init(nodeData, '', {}) + const callUrl = mockedAxios.get.mock.calls[0][0] as string + expect(callUrl).toContain('status=published') + }) + + it('should add brand_id filter when brandId is provided', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Article 1', + body: 'Content 1' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-11', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + brandId: '123456' + }) + + await nodeClass.init(nodeData, '', {}) + const callUrl = mockedAxios.get.mock.calls[0][0] as string + expect(callUrl).toContain('brand_id=123456') + }) + + it('should handle comma-separated locales', async () => { + const mockArticlesEn = { + data: { + articles: [ + { + id: 1, + name: 'English Article', + body: 'English content' + } + ] + } + } + + const mockArticlesFr = { + data: { + articles: [ + { + id: 2, + name: 'French Article', + body: 'French content' + } + ] + } + } + + mockedAxios.get + .mockResolvedValueOnce(mockArticlesEn) + .mockResolvedValueOnce(mockArticlesFr) + + const nodeData = createNodeData('test-11b', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us, fr-fr' + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result).toHaveLength(2) + expect(mockedAxios.get).toHaveBeenCalledTimes(2) + // Verify both locales were called + const callUrls = mockedAxios.get.mock.calls.map((call) => call[0] as string) + expect(callUrls[0]).toContain('locale=en-us') + expect(callUrls[1]).toContain('locale=fr-fr') + }) + }) + + describe('Error Handling', () => { + it('should handle 401 authentication error', async () => { + const error = { + response: { + status: 401, + statusText: 'Unauthorized' + } + } + + mockedAxios.get.mockRejectedValueOnce(error) + + const nodeData = createNodeData('test-12', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Authentication failed (401)' + ) + }) + + it('should handle 403 forbidden error', async () => { + const error = { + response: { + status: 403, + statusText: 'Forbidden' + } + } + + mockedAxios.get.mockRejectedValueOnce(error) + + const nodeData = createNodeData('test-13', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Access forbidden (403)' + ) + }) + + it('should handle 404 not found error', async () => { + const error = { + response: { + status: 404, + statusText: 'Not Found' + } + } + + mockedAxios.get.mockRejectedValueOnce(error) + + const nodeData = createNodeData('test-14', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Not found (404)' + ) + }) + + it('should handle 500 server error', async () => { + const error = { + response: { + status: 500, + statusText: 'Internal Server Error' + } + } + + mockedAxios.get.mockRejectedValueOnce(error) + + const nodeData = createNodeData('test-15', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Zendesk server error (500)' + ) + }) + + it('should handle network error (ENOTFOUND)', async () => { + const error = { + code: 'ENOTFOUND', + message: 'getaddrinfo ENOTFOUND' + } + + mockedAxios.get.mockRejectedValueOnce(error) + + const nodeData = createNodeData('test-16', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Network error: Cannot connect to Zendesk' + ) + }) + + it('should handle network error (ECONNREFUSED)', async () => { + const error = { + code: 'ECONNREFUSED', + message: 'Connection refused' + } + + mockedAxios.get.mockRejectedValueOnce(error) + + const nodeData = createNodeData('test-17', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Network error: Cannot connect to Zendesk' + ) + }) + }) + + describe('Chunking Logic', () => { + it('should not chunk small articles', async () => { + const smallContent = 'a'.repeat(1000) // Small content + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Small Article', + body: smallContent + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-18', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + charsPerToken: 4 + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result).toHaveLength(1) + expect(result[0].metadata.id).toBe('1') + }) + + it('should chunk large articles', async () => { + // Create content that exceeds maxTokens (3000 * 4 = 12000 chars) + const largeContent = 'a'.repeat(15000) + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Large Article', + body: largeContent + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-19', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + charsPerToken: 4 + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result.length).toBeGreaterThan(1) + expect(result[0].metadata.id).toContain('1-') + }) + + it('should maintain article title across chunks', async () => { + const largeContent = 'a'.repeat(15000) + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Large Article Title', + body: largeContent + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-20', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + charsPerToken: 4 + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result.length).toBeGreaterThan(1) + result.forEach((doc: any) => { + expect(doc.metadata.title).toBe('Large Article Title') + }) + }) + }) + + describe('Metadata Handling', () => { + it('should include article URL in metadata', async () => { + const articleId = 123 + const mockArticles = { + data: { + articles: [ + { + id: articleId, + name: 'Test Article', + body: 'Content' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-21', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result[0].metadata.url).toBe(`https://example.zendesk.com/hc/en-us/articles/${articleId}`) + expect(result[0].metadata.id).toBe(String(articleId)) + expect(result[0].pageContent).toBe('Content') + }) + + it('should handle additional metadata', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Test Article', + body: 'Content' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-22', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + metadata: JSON.stringify({ customKey: 'customValue' }) + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result[0].metadata.customKey).toBe('customValue') + expect(result[0].metadata.title).toBe('Test Article') + }) + + it('should omit specified metadata keys', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Test Article', + body: 'Content' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-23', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + omitMetadataKeys: 'url' + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result[0].metadata.url).toBeUndefined() + expect(result[0].metadata.title).toBeDefined() + expect(result[0].metadata.id).toBeDefined() + }) + }) + + describe('Output Modes', () => { + it('should return documents array when output is document', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Test Article', + body: 'Content' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-24', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + nodeData.outputs = { output: 'document' } + + const result = await nodeClass.init(nodeData, '', {}) + expect(Array.isArray(result)).toBe(true) + expect(result[0]).toHaveProperty('pageContent') + expect(result[0]).toHaveProperty('metadata') + }) + + it('should return concatenated text when output is text', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Article 1', + body: 'Content 1' + }, + { + id: 2, + name: 'Article 2', + body: 'Content 2' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-25', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + nodeData.outputs = { output: 'text' } + + const result = await nodeClass.init(nodeData, '', {}) + expect(typeof result).toBe('string') + // Check that both article contents are present in the concatenated text + // The result should be "Content 1\nContent 2\n" (or similar with escape handling) + const normalizedResult = result.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() + expect(normalizedResult).toContain('Content 1') + expect(normalizedResult).toContain('Content 2') + }) + }) + + describe('Authentication', () => { + it('should set Authorization header with Basic auth', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Test Article', + body: 'Content' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-26', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await nodeClass.init(nodeData, '', {}) + const callConfig = mockedAxios.get.mock.calls[0][1] + expect(callConfig?.headers?.Authorization).toBeDefined() + expect(callConfig?.headers?.Authorization).toContain('Basic') + }) + }) +}) + diff --git a/packages/server/.env.example b/packages/server/.env.example index 282e4cd33fc..0ed92a1336e 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -169,7 +169,6 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200 # REDIS_KEEP_ALIVE= # ENABLE_BULLMQ_DASHBOARD= - ############################################################################################################ ############################################## SECURITY #################################################### ############################################################################################################ diff --git a/packages/server/src/controllers/export-import/index.ts b/packages/server/src/controllers/export-import/index.ts index ae2a869283a..6e12c891865 100644 --- a/packages/server/src/controllers/export-import/index.ts +++ b/packages/server/src/controllers/export-import/index.ts @@ -49,7 +49,45 @@ const importData = async (req: Request, res: Response, next: NextFunction) => { } } +const exportChatflowMessages = async (req: Request, res: Response, next: NextFunction) => { + try { + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError( + StatusCodes.NOT_FOUND, + `Error: exportImportController.exportChatflowMessages - workspace ${workspaceId} not found!` + ) + } + + const { chatflowId, chatType, feedbackType, startDate, endDate } = req.body + if (!chatflowId) { + throw new InternalFlowiseError( + StatusCodes.BAD_REQUEST, + 'Error: exportImportController.exportChatflowMessages - chatflowId is required!' + ) + } + + const apiResponse = await exportImportService.exportChatflowMessages( + chatflowId, + chatType, + feedbackType, + startDate, + endDate, + workspaceId + ) + + // Set headers for file download + res.setHeader('Content-Type', 'application/json') + res.setHeader('Content-Disposition', `attachment; filename="${chatflowId}-Message.json"`) + + return res.json(apiResponse) + } catch (error) { + next(error) + } +} + export default { exportData, - importData + importData, + exportChatflowMessages } diff --git a/packages/server/src/controllers/get-upload-path/index.ts b/packages/server/src/controllers/get-upload-path/index.ts deleted file mode 100644 index 05e76591f4b..00000000000 --- a/packages/server/src/controllers/get-upload-path/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Request, Response, NextFunction } from 'express' -import { getStoragePath } from 'flowise-components' - -const getPathForUploads = async (req: Request, res: Response, next: NextFunction) => { - try { - const apiResponse = { - storagePath: getStoragePath() - } - return res.json(apiResponse) - } catch (error) { - next(error) - } -} - -export default { - getPathForUploads -} diff --git a/packages/server/src/enterprise/middleware/passport/index.ts b/packages/server/src/enterprise/middleware/passport/index.ts index dc76580301d..c8d0896cbe2 100644 --- a/packages/server/src/enterprise/middleware/passport/index.ts +++ b/packages/server/src/enterprise/middleware/passport/index.ts @@ -429,6 +429,34 @@ export const verifyToken = (req: Request, res: Response, next: NextFunction) => })(req, res, next) } +export const verifyTokenForBullMQDashboard = (req: Request, res: Response, next: NextFunction) => { + passport.authenticate('jwt', { session: true }, (err: any, user: LoggedInUser, info: object) => { + if (err) { + return next(err) + } + + // @ts-ignore + if (info && info.name === 'TokenExpiredError') { + if (req.cookies && req.cookies.refreshToken) { + return res.redirect('/signin?retry=true') + } + return res.redirect('/signin') + } + + if (!user) { + return res.redirect('/signin') + } + + const identityManager = getRunningExpressApp().identityManager + if (identityManager.isEnterprise() && !identityManager.isLicenseValid()) { + return res.redirect('/license-expired') + } + + req.user = user + next() + })(req, res, next) +} + const storeSSOUserPayload = (ssoToken: string, returnUser: any) => { const app = getRunningExpressApp() app.cachePool.addSSOTokenCache(ssoToken, returnUser) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 258be4cbd53..f4aefc9182f 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -18,7 +18,7 @@ import { Telemetry } from './utils/telemetry' import flowiseApiV1Router from './routes' import errorHandlerMiddleware from './middlewares/errors' import { WHITELIST_URLS } from './utils/constants' -import { initializeJwtCookieMiddleware, verifyToken } from './enterprise/middleware/passport' +import { initializeJwtCookieMiddleware, verifyToken, verifyTokenForBullMQDashboard } from './enterprise/middleware/passport' import { IdentityManager } from './IdentityManager' import { SSEStreamer } from './utils/SSEStreamer' import { validateAPIKey } from './utils/validateKey' @@ -331,7 +331,17 @@ export class App { }) if (process.env.MODE === MODE.QUEUE && process.env.ENABLE_BULLMQ_DASHBOARD === 'true' && !this.identityManager.isCloud()) { - this.app.use('/admin/queues', this.queueManager.getBullBoardRouter()) + // Initialize admin queues rate limiter + const id = 'bullmq_admin_dashboard' + await this.rateLimiterManager.addRateLimiter( + id, + 60, + 100, + process.env.ADMIN_RATE_LIMIT_MESSAGE || 'Too many requests to admin dashboard, please try again later.' + ) + + const rateLimiter = this.rateLimiterManager.getRateLimiterById(id) + this.app.use('/admin/queues', rateLimiter, verifyTokenForBullMQDashboard, this.queueManager.getBullBoardRouter()) } // ---------------------------------------- diff --git a/packages/server/src/routes/export-import/index.ts b/packages/server/src/routes/export-import/index.ts index 17b28a7c346..68723d9fea2 100644 --- a/packages/server/src/routes/export-import/index.ts +++ b/packages/server/src/routes/export-import/index.ts @@ -5,6 +5,8 @@ const router = express.Router() router.post('/export', checkPermission('workspace:export'), exportImportController.exportData) +router.post('/chatflow-messages', checkPermission('workspace:export'), exportImportController.exportChatflowMessages) + router.post('/import', checkPermission('workspace:import'), exportImportController.importData) export default router diff --git a/packages/server/src/routes/get-upload-path/index.ts b/packages/server/src/routes/get-upload-path/index.ts deleted file mode 100644 index 48827c9a1a2..00000000000 --- a/packages/server/src/routes/get-upload-path/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import express from 'express' -import getUploadPathController from '../../controllers/get-upload-path' -const router = express.Router() - -// READ -router.get('/', getUploadPathController.getPathForUploads) - -export default router diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 4c4930f44e1..bb7ce05d896 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -19,7 +19,6 @@ import fetchLinksRouter from './fetch-links' import filesRouter from './files' import flowConfigRouter from './flow-config' import getUploadFileRouter from './get-upload-file' -import getUploadPathRouter from './get-upload-path' import internalChatmessagesRouter from './internal-chat-messages' import internalPredictionRouter from './internal-predictions' import leadsRouter from './leads' @@ -93,7 +92,6 @@ router.use('/flow-config', flowConfigRouter) router.use('/internal-chatmessage', internalChatmessagesRouter) router.use('/internal-prediction', internalPredictionRouter) router.use('/get-upload-file', getUploadFileRouter) -router.use('/get-upload-path', getUploadPathRouter) router.use('/leads', leadsRouter) router.use('/load-prompt', loadPromptRouter) router.use('/marketplaces', marketplacesRouter) diff --git a/packages/server/src/services/export-import/index.ts b/packages/server/src/services/export-import/index.ts index 05e7747fd97..3e729bb5fa6 100644 --- a/packages/server/src/services/export-import/index.ts +++ b/packages/server/src/services/export-import/index.ts @@ -13,20 +13,21 @@ import { Tool } from '../../database/entities/Tool' import { Variable } from '../../database/entities/Variable' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { getErrorMessage } from '../../errors/utils' -import { Platform } from '../../Interface' -import assistantsService from '../../services/assistants' -import chatflowsService from '../../services/chatflows' +import assistantsService from '../assistants' +import chatflowService from '../chatflows' +import chatMessagesService from '../chat-messages' import { getRunningExpressApp } from '../../utils/getRunningExpressApp' +import { utilGetChatMessage } from '../../utils/getChatMessage' +import { getStoragePath, parseJsonBody } from 'flowise-components' +import path from 'path' import { checkUsageLimit } from '../../utils/quotaUsage' -import { sanitizeNullBytes } from '../../utils/sanitize.util' -import assistantService from '../assistants' -import chatMessagesService from '../chat-messages' -import chatflowService from '../chatflows' import documenStoreService from '../documentstore' import executionService, { ExecutionFilters } from '../executions' import marketplacesService from '../marketplaces' import toolsService from '../tools' import variableService from '../variables' +import { ChatMessageRatingType, ChatType, Platform } from '../../Interface' +import { sanitizeNullBytes } from '../../utils/sanitize.util' type ExportInput = { agentflow: boolean @@ -101,17 +102,17 @@ const exportData = async (exportInput: ExportInput, activeWorkspaceId: string): AgentFlowV2 = 'data' in AgentFlowV2 ? AgentFlowV2.data : AgentFlowV2 let AssistantCustom: Assistant[] = - exportInput.assistantCustom === true ? await assistantService.getAllAssistants(activeWorkspaceId, 'CUSTOM') : [] + exportInput.assistantCustom === true ? await assistantsService.getAllAssistants(activeWorkspaceId, 'CUSTOM') : [] let AssistantFlow: ChatFlow[] | { data: ChatFlow[]; total: number } = exportInput.assistantCustom === true ? await chatflowService.getAllChatflows('ASSISTANT', activeWorkspaceId) : [] AssistantFlow = 'data' in AssistantFlow ? AssistantFlow.data : AssistantFlow let AssistantOpenAI: Assistant[] = - exportInput.assistantOpenAI === true ? await assistantService.getAllAssistants(activeWorkspaceId, 'OPENAI') : [] + exportInput.assistantOpenAI === true ? await assistantsService.getAllAssistants(activeWorkspaceId, 'OPENAI') : [] let AssistantAzure: Assistant[] = - exportInput.assistantAzure === true ? await assistantService.getAllAssistants(activeWorkspaceId, 'AZURE') : [] + exportInput.assistantAzure === true ? await assistantsService.getAllAssistants(activeWorkspaceId, 'AZURE') : [] let ChatFlow: ChatFlow[] | { data: ChatFlow[]; total: number } = exportInput.chatflow === true ? await chatflowService.getAllChatflows('CHATFLOW', activeWorkspaceId) : [] @@ -635,7 +636,7 @@ const importData = async (importData: ExportData, orgId: string, activeWorkspace importData.Tool = importData.Tool || [] importData.Variable = importData.Variable || [] - let queryRunner + let queryRunner: QueryRunner try { queryRunner = getRunningExpressApp().AppDataSource.createQueryRunner() await queryRunner.connect() @@ -644,7 +645,7 @@ const importData = async (importData: ExportData, orgId: string, activeWorkspace if (importData.AgentFlow.length > 0) { importData.AgentFlow = reduceSpaceForChatflowFlowData(importData.AgentFlow) importData.AgentFlow = insertWorkspaceId(importData.AgentFlow, activeWorkspaceId) - const existingChatflowCount = await chatflowsService.getAllChatflowsCountByOrganization('MULTIAGENT', orgId) + const existingChatflowCount = await chatflowService.getAllChatflowsCountByOrganization('MULTIAGENT', orgId) const newChatflowCount = importData.AgentFlow.length await checkUsageLimit( 'flows', @@ -657,7 +658,7 @@ const importData = async (importData: ExportData, orgId: string, activeWorkspace if (importData.AgentFlowV2.length > 0) { importData.AgentFlowV2 = reduceSpaceForChatflowFlowData(importData.AgentFlowV2) importData.AgentFlowV2 = insertWorkspaceId(importData.AgentFlowV2, activeWorkspaceId) - const existingChatflowCount = await chatflowsService.getAllChatflowsCountByOrganization('AGENTFLOW', orgId) + const existingChatflowCount = await chatflowService.getAllChatflowsCountByOrganization('AGENTFLOW', orgId) const newChatflowCount = importData.AgentFlowV2.length await checkUsageLimit( 'flows', @@ -682,7 +683,7 @@ const importData = async (importData: ExportData, orgId: string, activeWorkspace if (importData.AssistantFlow.length > 0) { importData.AssistantFlow = reduceSpaceForChatflowFlowData(importData.AssistantFlow) importData.AssistantFlow = insertWorkspaceId(importData.AssistantFlow, activeWorkspaceId) - const existingChatflowCount = await chatflowsService.getAllChatflowsCountByOrganization('ASSISTANT', orgId) + const existingChatflowCount = await chatflowService.getAllChatflowsCountByOrganization('ASSISTANT', orgId) const newChatflowCount = importData.AssistantFlow.length await checkUsageLimit( 'flows', @@ -719,7 +720,7 @@ const importData = async (importData: ExportData, orgId: string, activeWorkspace if (importData.ChatFlow.length > 0) { importData.ChatFlow = reduceSpaceForChatflowFlowData(importData.ChatFlow) importData.ChatFlow = insertWorkspaceId(importData.ChatFlow, activeWorkspaceId) - const existingChatflowCount = await chatflowsService.getAllChatflowsCountByOrganization('CHATFLOW', orgId) + const existingChatflowCount = await chatflowService.getAllChatflowsCountByOrganization('CHATFLOW', orgId) const newChatflowCount = importData.ChatFlow.length await checkUsageLimit( 'flows', @@ -800,8 +801,160 @@ const importData = async (importData: ExportData, orgId: string, activeWorkspace } } +// Export chatflow messages +const exportChatflowMessages = async ( + chatflowId: string, + chatType?: ChatType[] | string, + feedbackType?: ChatMessageRatingType[] | string, + startDate?: string, + endDate?: string, + workspaceId?: string +) => { + try { + // Parse chatType if it's a string + let parsedChatTypes: ChatType[] | undefined + if (chatType) { + if (typeof chatType === 'string') { + const parsed = parseJsonBody(chatType) + parsedChatTypes = Array.isArray(parsed) ? parsed : [chatType as ChatType] + } else if (Array.isArray(chatType)) { + parsedChatTypes = chatType + } + } + + // Parse feedbackType if it's a string + let parsedFeedbackTypes: ChatMessageRatingType[] | undefined + if (feedbackType) { + if (typeof feedbackType === 'string') { + const parsed = parseJsonBody(feedbackType) + parsedFeedbackTypes = Array.isArray(parsed) ? parsed : [feedbackType as ChatMessageRatingType] + } else if (Array.isArray(feedbackType)) { + parsedFeedbackTypes = feedbackType + } + } + + // Get all chat messages for the chatflow with feedback + const chatMessages = await utilGetChatMessage({ + chatflowid: chatflowId, + chatTypes: parsedChatTypes, + feedbackTypes: parsedFeedbackTypes, + startDate, + endDate, + sortOrder: 'DESC', + feedback: true, + activeWorkspaceId: workspaceId + }) + + const storagePath = getStoragePath() + const exportObj: { [key: string]: any } = {} + + // Process each chat message + for (const chatmsg of chatMessages) { + const chatPK = getChatPK(chatmsg) + const filePaths: string[] = [] + + // Handle file uploads + if (chatmsg.fileUploads) { + const uploads = parseJsonBody(chatmsg.fileUploads) + if (Array.isArray(uploads)) { + uploads.forEach((file: any) => { + if (file.type === 'stored-file') { + filePaths.push(path.join(storagePath, chatmsg.chatflowid, chatmsg.chatId, file.name)) + } + }) + } + } + + // Create message object + const msg: any = { + content: chatmsg.content, + role: chatmsg.role === 'apiMessage' ? 'bot' : 'user', + time: chatmsg.createdDate + } + + // Add optional properties + if (filePaths.length) msg.filePaths = filePaths + if (chatmsg.sourceDocuments) msg.sourceDocuments = parseJsonBody(chatmsg.sourceDocuments) + if (chatmsg.usedTools) msg.usedTools = parseJsonBody(chatmsg.usedTools) + if (chatmsg.fileAnnotations) msg.fileAnnotations = parseJsonBody(chatmsg.fileAnnotations) + if ((chatmsg as any).feedback) msg.feedback = (chatmsg as any).feedback.content + if (chatmsg.agentReasoning) msg.agentReasoning = parseJsonBody(chatmsg.agentReasoning) + + // Handle artifacts + if (chatmsg.artifacts) { + const artifacts = parseJsonBody(chatmsg.artifacts) + msg.artifacts = artifacts + if (Array.isArray(artifacts)) { + artifacts.forEach((artifact: any) => { + if (artifact.type === 'png' || artifact.type === 'jpeg') { + const baseURL = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}` + artifact.data = `${baseURL}/api/v1/get-upload-file?chatflowId=${chatmsg.chatflowid}&chatId=${ + chatmsg.chatId + }&fileName=${artifact.data.replace('FILE-STORAGE::', '')}` + } + }) + } + } + + // Group messages by chat session + if (!exportObj[chatPK]) { + exportObj[chatPK] = { + id: chatmsg.chatId, + source: getChatType(chatmsg.chatType as ChatType), + sessionId: chatmsg.sessionId ?? null, + memoryType: chatmsg.memoryType ?? null, + email: (chatmsg as any).leadEmail ?? null, + messages: [msg] + } + } else { + exportObj[chatPK].messages.push(msg) + } + } + + // Convert to array and reverse message order within each conversation + const exportMessages = Object.values(exportObj).map((conversation: any) => ({ + ...conversation, + messages: conversation.messages.reverse() + })) + + return exportMessages + } catch (error) { + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: exportImportService.exportChatflowMessages - ${getErrorMessage(error)}` + ) + } +} + +// Helper function to get chat primary key +const getChatPK = (chatmsg: ChatMessage): string => { + const chatId = chatmsg.chatId + const memoryType = chatmsg.memoryType + const sessionId = chatmsg.sessionId + + if (memoryType && sessionId) { + return `${chatId}_${memoryType}_${sessionId}` + } + return chatId +} + +// Helper function to get chat type display name +const getChatType = (chatType?: ChatType): string => { + if (!chatType) return 'Unknown' + + switch (chatType) { + case ChatType.EVALUATION: + return 'Evaluation' + case ChatType.INTERNAL: + return 'UI' + case ChatType.EXTERNAL: + return 'API/Embed' + } +} + export default { convertExportInput, exportData, - importData + importData, + exportChatflowMessages } diff --git a/packages/server/src/utils/constants.ts b/packages/server/src/utils/constants.ts index 0d9caf94940..7b94bcabe23 100644 --- a/packages/server/src/utils/constants.ts +++ b/packages/server/src/utils/constants.ts @@ -23,7 +23,6 @@ export const WHITELIST_URLS = [ '/api/v1/ping', '/api/v1/version', '/api/v1/attachments', - '/api/v1/metrics', '/api/v1/nvidia-nim', '/api/v1/auth/resolve', '/api/v1/auth/login', diff --git a/packages/server/src/utils/logger.ts b/packages/server/src/utils/logger.ts index a4b060a7d0a..71e8e309c5a 100644 --- a/packages/server/src/utils/logger.ts +++ b/packages/server/src/utils/logger.ts @@ -1,6 +1,5 @@ import * as path from 'path' import * as fs from 'fs' -import { hostname } from 'node:os' import config from './config' // should be replaced by node-config or similar import { createLogger, transports, format } from 'winston' import { NextFunction, Request, Response } from 'express' @@ -54,21 +53,21 @@ if (process.env.STORAGE_TYPE === 's3') { s3ServerStream = new S3StreamLogger({ bucket: s3Bucket, folder: 'logs/server', - name_format: `server-%Y-%m-%d-%H-%M-%S-%L-${hostname()}.log`, + name_format: `server-%Y-%m-%d-%H-%M-%S-%L.log`, config: s3Config }) s3ErrorStream = new S3StreamLogger({ bucket: s3Bucket, folder: 'logs/error', - name_format: `server-error-%Y-%m-%d-%H-%M-%S-%L-${hostname()}.log`, + name_format: `server-error-%Y-%m-%d-%H-%M-%S-%L.log`, config: s3Config }) s3ServerReqStream = new S3StreamLogger({ bucket: s3Bucket, folder: 'logs/requests', - name_format: `server-requests-%Y-%m-%d-%H-%M-%S-%L-${hostname()}.log.jsonl`, + name_format: `server-requests-%Y-%m-%d-%H-%M-%S-%L.log.jsonl`, config: s3Config }) } diff --git a/packages/server/src/utils/rateLimit.ts b/packages/server/src/utils/rateLimit.ts index d4dd168a654..16ba7718096 100644 --- a/packages/server/src/utils/rateLimit.ts +++ b/packages/server/src/utils/rateLimit.ts @@ -134,6 +134,14 @@ export class RateLimiterManager { } } + public getRateLimiterById(id: string): (req: Request, res: Response, next: NextFunction) => void { + return (req: Request, res: Response, next: NextFunction) => { + if (!this.rateLimiters[id]) return next() + const idRateLimiter = this.rateLimiters[id] + return idRateLimiter(req, res, next) + } + } + public async updateRateLimiter(chatFlow: IChatFlow, isInitialized?: boolean): Promise { if (!chatFlow.apiConfig) return const apiConfig = JSON.parse(chatFlow.apiConfig) diff --git a/packages/ui/src/api/chatmessage.js b/packages/ui/src/api/chatmessage.js index aa8edfc88b8..02ffe915133 100644 --- a/packages/ui/src/api/chatmessage.js +++ b/packages/ui/src/api/chatmessage.js @@ -6,7 +6,6 @@ const getAllChatmessageFromChatflow = (id, params = {}) => client.get(`/chatmessage/${id}`, { params: { order: 'DESC', feedback: true, ...params } }) const getChatmessageFromPK = (id, params = {}) => client.get(`/chatmessage/${id}`, { params: { order: 'ASC', feedback: true, ...params } }) const deleteChatmessage = (id, params = {}) => client.delete(`/chatmessage/${id}`, { params: { ...params } }) -const getStoragePath = () => client.get(`/get-upload-path`) const abortMessage = (chatflowid, chatid) => client.put(`/chatmessage/abort/${chatflowid}/${chatid}`) export default { @@ -14,6 +13,5 @@ export default { getAllChatmessageFromChatflow, getChatmessageFromPK, deleteChatmessage, - getStoragePath, abortMessage } diff --git a/packages/ui/src/api/exportimport.js b/packages/ui/src/api/exportimport.js index 7cab1a13997..0f360757715 100644 --- a/packages/ui/src/api/exportimport.js +++ b/packages/ui/src/api/exportimport.js @@ -2,8 +2,10 @@ import client from './client' const exportData = (body) => client.post('/export-import/export', body) const importData = (body) => client.post('/export-import/import', body) +const exportChatflowMessages = (body) => client.post('/export-import/chatflow-messages', body) export default { exportData, - importData + importData, + exportChatflowMessages } diff --git a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx index 40ed8c14f3c..03697b2fcb1 100644 --- a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx +++ b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx @@ -57,11 +57,12 @@ import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions' // API import chatmessageApi from '@/api/chatmessage' import feedbackApi from '@/api/feedback' +import exportImportApi from '@/api/exportimport' import useApi from '@/hooks/useApi' import useConfirm from '@/hooks/useConfirm' // Utils -import { getOS, isValidURL, removeDuplicateURL } from '@/utils/genericHelper' +import { isValidURL, removeDuplicateURL } from '@/utils/genericHelper' import useNotifier from '@/utils/useNotifier' import { baseURL } from '@/store/constant' @@ -204,8 +205,6 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { const getChatmessageApi = useApi(chatmessageApi.getAllChatmessageFromChatflow) const getChatmessageFromPKApi = useApi(chatmessageApi.getChatmessageFromPK) const getStatsApi = useApi(feedbackApi.getStatsFromChatflow) - const getStoragePathFromServer = useApi(chatmessageApi.getStoragePath) - let storagePath = '' /* Table Pagination */ const [currentPage, setCurrentPage] = useState(1) @@ -352,94 +351,55 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { } const exportMessages = async () => { - if (!storagePath && getStoragePathFromServer.data) { - storagePath = getStoragePathFromServer.data.storagePath - } - const obj = {} - let fileSeparator = '/' - if ('windows' === getOS()) { - fileSeparator = '\\' - } - - const resp = await chatmessageApi.getAllChatmessageFromChatflow(dialogProps.chatflow.id, { - chatType: chatTypeFilter.length ? chatTypeFilter : undefined, - feedbackType: feedbackTypeFilter.length ? feedbackTypeFilter : undefined, - startDate: startDate, - endDate: endDate, - order: 'DESC' - }) - - const allChatlogs = resp.data ?? [] - for (let i = 0; i < allChatlogs.length; i += 1) { - const chatmsg = allChatlogs[i] - const chatPK = getChatPK(chatmsg) - let filePaths = [] - if (chatmsg.fileUploads && Array.isArray(chatmsg.fileUploads)) { - chatmsg.fileUploads.forEach((file) => { - if (file.type === 'stored-file') { - filePaths.push( - `${storagePath}${fileSeparator}${chatmsg.chatflowid}${fileSeparator}${chatmsg.chatId}${fileSeparator}${file.name}` - ) - } - }) - } - const msg = { - content: chatmsg.content, - role: chatmsg.role === 'apiMessage' ? 'bot' : 'user', - time: chatmsg.createdDate - } - if (filePaths.length) msg.filePaths = filePaths - if (chatmsg.sourceDocuments) msg.sourceDocuments = chatmsg.sourceDocuments - if (chatmsg.usedTools) msg.usedTools = chatmsg.usedTools - if (chatmsg.fileAnnotations) msg.fileAnnotations = chatmsg.fileAnnotations - if (chatmsg.feedback) msg.feedback = chatmsg.feedback?.content - if (chatmsg.agentReasoning) msg.agentReasoning = chatmsg.agentReasoning - if (chatmsg.artifacts) { - msg.artifacts = chatmsg.artifacts - msg.artifacts.forEach((artifact) => { - if (artifact.type === 'png' || artifact.type === 'jpeg') { - artifact.data = `${baseURL}/api/v1/get-upload-file?chatflowId=${chatmsg.chatflowid}&chatId=${ - chatmsg.chatId - }&fileName=${artifact.data.replace('FILE-STORAGE::', '')}` - } - }) - } - if (!Object.prototype.hasOwnProperty.call(obj, chatPK)) { - obj[chatPK] = { - id: chatmsg.chatId, - source: getChatType(chatmsg.chatType), - sessionId: chatmsg.sessionId ?? null, - memoryType: chatmsg.memoryType ?? null, - email: chatmsg.leadEmail ?? null, - messages: [msg] - } - } else if (Object.prototype.hasOwnProperty.call(obj, chatPK)) { - obj[chatPK].messages = [...obj[chatPK].messages, msg] - } - } - - const exportMessages = [] - for (const key in obj) { - exportMessages.push({ - ...obj[key] + try { + const response = await exportImportApi.exportChatflowMessages({ + chatflowId: dialogProps.chatflow.id, + chatType: chatTypeFilter.length ? chatTypeFilter : undefined, + feedbackType: feedbackTypeFilter.length ? feedbackTypeFilter : undefined, + startDate: startDate, + endDate: endDate }) - } - for (let i = 0; i < exportMessages.length; i += 1) { - exportMessages[i].messages = exportMessages[i].messages.reverse() - } + const exportMessages = response.data + const dataStr = JSON.stringify(exportMessages, null, 2) + const blob = new Blob([dataStr], { type: 'application/json' }) + const dataUri = URL.createObjectURL(blob) - const dataStr = JSON.stringify(exportMessages, null, 2) - //const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr) - const blob = new Blob([dataStr], { type: 'application/json' }) - const dataUri = URL.createObjectURL(blob) + const exportFileDefaultName = `${dialogProps.chatflow.id}-Message.json` - const exportFileDefaultName = `${dialogProps.chatflow.id}-Message.json` + let linkElement = document.createElement('a') + linkElement.setAttribute('href', dataUri) + linkElement.setAttribute('download', exportFileDefaultName) + linkElement.click() - let linkElement = document.createElement('a') - linkElement.setAttribute('href', dataUri) - linkElement.setAttribute('download', exportFileDefaultName) - linkElement.click() + enqueueSnackbar({ + message: 'Messages exported successfully', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + } catch (error) { + console.error('Error exporting messages:', error) + enqueueSnackbar({ + message: 'Failed to export messages', + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + } } const clearChat = async (chatmsg) => { @@ -755,8 +715,6 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { useEffect(() => { if (getChatmessageApi.data) { - getStoragePathFromServer.request() - const chatPK = processChatLogs(getChatmessageApi.data) setSelectedMessageIndex(0) if (chatPK) {