diff --git a/packages/server/src/Interface.ts b/packages/server/src/Interface.ts index 0c5fa5a4..91fe2a93 100644 --- a/packages/server/src/Interface.ts +++ b/packages/server/src/Interface.ts @@ -6,6 +6,11 @@ export enum chatType { INTERNAL = 'INTERNAL', EXTERNAL = 'EXTERNAL' } + +export enum ChatMessageRatingType { + THUMBS_UP = 'THUMBS_UP', + THUMBS_DOWN = 'THUMBS_DOWN' +} /** * Databases */ @@ -39,6 +44,16 @@ export interface IChatMessage { createdDate: Date } +export interface IChatMessageFeedback { + id: string + content?: string + chatflowid: string + chatId: string + messageId: string + rating: ChatMessageRatingType + createdDate: Date +} + export interface ITool { id: string name: string diff --git a/packages/server/src/database/entities/ChatMessageFeedback.ts b/packages/server/src/database/entities/ChatMessageFeedback.ts new file mode 100644 index 00000000..811f3104 --- /dev/null +++ b/packages/server/src/database/entities/ChatMessageFeedback.ts @@ -0,0 +1,30 @@ +/* eslint-disable */ +import { Entity, Column, CreateDateColumn, PrimaryGeneratedColumn, Index, Unique } from 'typeorm' +import { IChatMessageFeedback, ChatMessageRatingType } from '../../Interface' + +@Entity() +@Unique(['messageId']) +export class ChatMessageFeedback implements IChatMessageFeedback { + @PrimaryGeneratedColumn('uuid') + id: string + + @Index() + @Column() + chatflowid: string + + @Index() + @Column() + chatId: string + + @Column() + messageId: string + + @Column({ nullable: true }) + rating: ChatMessageRatingType + + @Column({ nullable: true, type: 'text' }) + content?: string + + @CreateDateColumn() + createdDate: Date +} diff --git a/packages/server/src/database/entities/index.ts b/packages/server/src/database/entities/index.ts index af5c559f..6a0e2d22 100644 --- a/packages/server/src/database/entities/index.ts +++ b/packages/server/src/database/entities/index.ts @@ -1,5 +1,6 @@ import { ChatFlow } from './ChatFlow' import { ChatMessage } from './ChatMessage' +import { ChatMessageFeedback } from './ChatMessageFeedback' import { Credential } from './Credential' import { Tool } from './Tool' import { Assistant } from './Assistant' @@ -8,6 +9,7 @@ import { Variable } from './Variable' export const entities = { ChatFlow, ChatMessage, + ChatMessageFeedback, Credential, Tool, Assistant, diff --git a/packages/server/src/database/migrations/mysql/1707213626553-AddFeedback.ts b/packages/server/src/database/migrations/mysql/1707213626553-AddFeedback.ts new file mode 100644 index 00000000..f2c86734 --- /dev/null +++ b/packages/server/src/database/migrations/mysql/1707213626553-AddFeedback.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddFeedback1707213626553 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS \`chat_message_feedback\` ( + \`id\` varchar(36) NOT NULL, + \`chatflowid\` varchar(255) NOT NULL, + \`content\` text, + \`chatId\` varchar(255) NOT NULL, + \`messageId\` varchar(255) NOT NULL, + \`rating\` varchar(255) NOT NULL, + \`createdDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE chat_message_feedback`) + } +} diff --git a/packages/server/src/database/migrations/mysql/index.ts b/packages/server/src/database/migrations/mysql/index.ts index 549742a1..a4baf424 100644 --- a/packages/server/src/database/migrations/mysql/index.ts +++ b/packages/server/src/database/migrations/mysql/index.ts @@ -13,6 +13,7 @@ import { AddFileAnnotationsToChatMessage1700271021237 } from './1700271021237-Ad import { AddFileUploadsToChatMessage1701788586491 } from './1701788586491-AddFileUploadsToChatMessage' import { AddVariableEntity1699325775451 } from './1702200925471-AddVariableEntity' import { AddSpeechToText1706364937060 } from './1706364937060-AddSpeechToText' +import { AddFeedback1707213626553 } from './1707213626553-AddFeedback' export const mysqlMigrations = [ Init1693840429259, @@ -29,5 +30,6 @@ export const mysqlMigrations = [ AddFileAnnotationsToChatMessage1700271021237, AddFileUploadsToChatMessage1701788586491, AddVariableEntity1699325775451, - AddSpeechToText1706364937060 + AddSpeechToText1706364937060, + AddFeedback1707213626553 ] diff --git a/packages/server/src/database/migrations/postgres/1707213601923-AddFeedback.ts b/packages/server/src/database/migrations/postgres/1707213601923-AddFeedback.ts new file mode 100644 index 00000000..779de42e --- /dev/null +++ b/packages/server/src/database/migrations/postgres/1707213601923-AddFeedback.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddFeedback1707213601923 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS chat_message_feedback ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + "chatflowid" varchar NOT NULL, + "content" text, + "chatId" varchar NOT NULL, + "messageId" varchar NOT NULL, + "rating" varchar NOT NULL, + "createdDate" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "PK_98419043dd704f54-9830ab78f8" PRIMARY KEY (id) + );` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE chat_message_feedback`) + } +} diff --git a/packages/server/src/database/migrations/postgres/index.ts b/packages/server/src/database/migrations/postgres/index.ts index bd631903..fbfac45c 100644 --- a/packages/server/src/database/migrations/postgres/index.ts +++ b/packages/server/src/database/migrations/postgres/index.ts @@ -13,6 +13,7 @@ import { AddFileAnnotationsToChatMessage1700271021237 } from './1700271021237-Ad import { AddFileUploadsToChatMessage1701788586491 } from './1701788586491-AddFileUploadsToChatMessage' import { AddVariableEntity1699325775451 } from './1702200925471-AddVariableEntity' import { AddSpeechToText1706364937060 } from './1706364937060-AddSpeechToText' +import { AddFeedback1707213601923 } from './1707213601923-AddFeedback' export const postgresMigrations = [ Init1693891895163, @@ -29,5 +30,6 @@ export const postgresMigrations = [ AddFileAnnotationsToChatMessage1700271021237, AddFileUploadsToChatMessage1701788586491, AddVariableEntity1699325775451, - AddSpeechToText1706364937060 + AddSpeechToText1706364937060, + AddFeedback1707213601923 ] diff --git a/packages/server/src/database/migrations/sqlite/1707213619308-AddFeedback.ts b/packages/server/src/database/migrations/sqlite/1707213619308-AddFeedback.ts new file mode 100644 index 00000000..b9002697 --- /dev/null +++ b/packages/server/src/database/migrations/sqlite/1707213619308-AddFeedback.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddFeedback1707213619308 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "chat_message_feedback" ("id" varchar PRIMARY KEY NOT NULL, "chatflowid" varchar NOT NULL, "chatId" varchar NOT NULL, "messageId" varchar NOT NULL, "rating" varchar NOT NULL, "content" text, "createdDate" datetime NOT NULL DEFAULT (datetime('now')));` + ) + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_e574527322272fd838f4f0f3d3" ON "chat_message_feedback" ("chatflowid") ;`) + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_e574527322272fd838f4f0f3d3" ON "chat_message_feedback" ("chatId") ;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "chat_message_feedback";`) + } +} diff --git a/packages/server/src/database/migrations/sqlite/index.ts b/packages/server/src/database/migrations/sqlite/index.ts index a50b0792..0b4f3e05 100644 --- a/packages/server/src/database/migrations/sqlite/index.ts +++ b/packages/server/src/database/migrations/sqlite/index.ts @@ -13,6 +13,7 @@ import { AddFileAnnotationsToChatMessage1700271021237 } from './1700271021237-Ad import { AddFileUploadsToChatMessage1701788586491 } from './1701788586491-AddFileUploadsToChatMessage' import { AddVariableEntity1699325775451 } from './1702200925471-AddVariableEntity' import { AddSpeechToText1706364937060 } from './1706364937060-AddSpeechToText' +import { AddFeedback1707213619308 } from './1707213619308-AddFeedback' export const sqliteMigrations = [ Init1693835579790, @@ -29,5 +30,6 @@ export const sqliteMigrations = [ AddFileAnnotationsToChatMessage1700271021237, AddFileUploadsToChatMessage1701788586491, AddVariableEntity1699325775451, - AddSpeechToText1706364937060 + AddSpeechToText1706364937060, + AddFeedback1707213619308 ] diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c1e68d09..a976e6a1 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -11,7 +11,7 @@ import logger from './utils/logger' import { expressRequestLogger } from './utils/logger' import { v4 as uuidv4 } from 'uuid' import OpenAI from 'openai' -import { DataSource, FindOptionsWhere, MoreThanOrEqual, LessThanOrEqual } from 'typeorm' +import { DataSource, FindOptionsWhere, MoreThanOrEqual, LessThanOrEqual, Between } from 'typeorm' import { IChatFlow, IncomingInput, @@ -21,8 +21,10 @@ import { ICredentialReturnResponse, chatType, IChatMessage, + IChatMessageFeedback, IDepthQueue, INodeDirectedGraph, + ChatMessageRatingType, IUploadFileSizeAndTypes } from './Interface' import { @@ -57,6 +59,7 @@ import { getDataSource } from './DataSource' import { NodesPool } from './NodesPool' import { ChatFlow } from './database/entities/ChatFlow' import { ChatMessage } from './database/entities/ChatMessage' +import { ChatMessageFeedback } from './database/entities/ChatMessageFeedback' import { Credential } from './database/entities/Credential' import { Tool } from './database/entities/Tool' import { Assistant } from './database/entities/Assistant' @@ -184,6 +187,7 @@ export class App { '/api/v1/chatflows-streaming', '/api/v1/chatflows-uploads', '/api/v1/openai-assistants-file', + '/api/v1/feedback', '/api/v1/get-upload-file', '/api/v1/ip' ] @@ -556,6 +560,7 @@ export class App { const messageId = req.query?.messageId as string | undefined const startDate = req.query?.startDate as string | undefined const endDate = req.query?.endDate as string | undefined + const feedback = req.query?.feedback as boolean | undefined let chatTypeFilter = req.query?.chatType as chatType | undefined if (chatTypeFilter) { @@ -582,14 +587,35 @@ export class App { sessionId, startDate, endDate, - messageId + messageId, + feedback ) return res.json(chatmessages) }) // Get internal chatmessages from chatflowid this.app.get('/api/v1/internal-chatmessage/:id', async (req: Request, res: Response) => { - const chatmessages = await this.getChatMessage(req.params.id, chatType.INTERNAL) + const sortOrder = req.query?.order as string | undefined + const chatId = req.query?.chatId as string | undefined + const memoryType = req.query?.memoryType as string | undefined + const sessionId = req.query?.sessionId as string | undefined + const messageId = req.query?.messageId as string | undefined + const startDate = req.query?.startDate as string | undefined + const endDate = req.query?.endDate as string | undefined + const feedback = req.query?.feedback as boolean | undefined + + const chatmessages = await this.getChatMessage( + req.params.id, + chatType.INTERNAL, + sortOrder, + chatId, + memoryType, + sessionId, + startDate, + endDate, + messageId, + feedback + ) return res.json(chatmessages) }) @@ -640,6 +666,10 @@ export class App { if (sessionId) deleteOptions.sessionId = sessionId if (chatType) deleteOptions.chatType = chatType + // remove all related feedback records + const feedbackDeleteOptions: FindOptionsWhere = { chatId } + await this.AppDataSource.getRepository(ChatMessageFeedback).delete(feedbackDeleteOptions) + // Delete all uploads corresponding to this chatflow/chatId if (chatId) { try { @@ -654,6 +684,90 @@ export class App { return res.json(results) }) + // ---------------------------------------- + // Chat Message Feedback + // ---------------------------------------- + + // Get all chatmessage feedback from chatflowid + this.app.get('/api/v1/feedback/:id', async (req: Request, res: Response) => { + const chatflowid = req.params.id + const chatId = req.query?.chatId as string | undefined + const sortOrder = req.query?.order as string | undefined + const startDate = req.query?.startDate as string | undefined + const endDate = req.query?.endDate as string | undefined + + const feedback = await this.getChatMessageFeedback(chatflowid, chatId, sortOrder, startDate, endDate) + + return res.json(feedback) + }) + + // Add chatmessage feedback for chatflowid + this.app.post('/api/v1/feedback/:id', async (req: Request, res: Response) => { + const body = req.body + const results = await this.addChatMessageFeedback(body) + return res.json(results) + }) + + // Update chatmessage feedback for id + this.app.put('/api/v1/feedback/:id', async (req: Request, res: Response) => { + const id = req.params.id + const body = req.body + await this.updateChatMessageFeedback(id, body) + return res.json({ status: 'OK' }) + }) + + // ---------------------------------------- + // stats + // ---------------------------------------- + // + // get stats for showing in chatflow + this.app.get('/api/v1/stats/:id', async (req: Request, res: Response) => { + const chatflowid = req.params.id + let chatTypeFilter = req.query?.chatType as chatType | undefined + const startDate = req.query?.startDate as string | undefined + const endDate = req.query?.endDate as string | undefined + + if (chatTypeFilter) { + try { + const chatTypeFilterArray = JSON.parse(chatTypeFilter) + if (chatTypeFilterArray.includes(chatType.EXTERNAL) && chatTypeFilterArray.includes(chatType.INTERNAL)) { + chatTypeFilter = undefined + } else if (chatTypeFilterArray.includes(chatType.EXTERNAL)) { + chatTypeFilter = chatType.EXTERNAL + } else if (chatTypeFilterArray.includes(chatType.INTERNAL)) { + chatTypeFilter = chatType.INTERNAL + } + } catch (e) { + return res.status(500).send(e) + } + } + + const chatmessages = (await this.getChatMessage( + chatflowid, + chatTypeFilter, + undefined, + undefined, + undefined, + undefined, + startDate, + endDate, + '', + true + )) as Array + const totalMessages = chatmessages.length + + const totalFeedback = chatmessages.filter((message) => message?.feedback).length + const positiveFeedback = chatmessages.filter((message) => message?.feedback?.rating === 'THUMBS_UP').length + + const results = { + totalMessages, + totalFeedback, + positiveFeedback + } + + res.json(results) + }) + // ---------------------------------------- // Credentials // ---------------------------------------- @@ -1629,6 +1743,7 @@ export class App { * @param {string} sessionId * @param {string} startDate * @param {string} endDate + * @param {boolean} feedback */ async getChatMessage( chatflowid: string, @@ -1639,7 +1754,8 @@ export class App { sessionId?: string, startDate?: string, endDate?: string, - messageId?: string + messageId?: string, + feedback?: boolean ): Promise { const setDateToStartOrEndOfDay = (dateTimeStr: string, setHours: 'start' | 'end') => { const date = new Date(dateTimeStr) @@ -1656,6 +1772,40 @@ export class App { let toDate if (endDate) toDate = setDateToStartOrEndOfDay(endDate, 'end') + if (feedback) { + const query = this.AppDataSource.getRepository(ChatMessage).createQueryBuilder('chat_message') + + // do the join with chat message feedback based on messageId for each chat message in the chatflow + query + .leftJoinAndMapOne('chat_message.feedback', ChatMessageFeedback, 'feedback', 'feedback.messageId = chat_message.id') + .where('chat_message.chatflowid = :chatflowid', { chatflowid }) + + // based on which parameters are available add `andWhere` clauses to the query + if (chatType) { + query.andWhere('chat_message.chatType = :chatType', { chatType }) + } + if (chatId) { + query.andWhere('chat_message.chatId = :chatId', { chatId }) + } + if (memoryType) { + query.andWhere('chat_message.memoryType = :memoryType', { memoryType }) + } + if (sessionId) { + query.andWhere('chat_message.sessionId = :sessionId', { sessionId }) + } + + // set date range + query.andWhere('chat_message.createdDate BETWEEN :fromDate AND :toDate', { + fromDate: fromDate ?? new Date().setMonth(new Date().getMonth() - 1), + toDate: toDate ?? new Date() + }) + // sort + query.orderBy('chat_message.createdDate', sortOrder === 'DESC' ? 'DESC' : 'ASC') + + const messages = await query.getMany() + return messages + } + return await this.AppDataSource.getRepository(ChatMessage).find({ where: { chatflowid, @@ -1687,6 +1837,62 @@ export class App { return await this.AppDataSource.getRepository(ChatMessage).save(chatmessage) } + /** + * Method that get chat messages. + * @param {string} chatflowid + * @param {string} sortOrder + * @param {string} chatId + * @param {string} startDate + * @param {string} endDate + */ + async getChatMessageFeedback( + chatflowid: string, + chatId?: string, + sortOrder: string = 'ASC', + startDate?: string, + endDate?: string + ): Promise { + let fromDate + if (startDate) fromDate = new Date(startDate) + + let toDate + if (endDate) toDate = new Date(endDate) + return await this.AppDataSource.getRepository(ChatMessageFeedback).find({ + where: { + chatflowid, + chatId, + createdDate: toDate && fromDate ? Between(fromDate, toDate) : undefined + }, + order: { + createdDate: sortOrder === 'DESC' ? 'DESC' : 'ASC' + } + }) + } + + /** + * Method that add chat message feedback. + * @param {Partial} chatMessageFeedback + */ + async addChatMessageFeedback(chatMessageFeedback: Partial): Promise { + const newChatMessageFeedback = new ChatMessageFeedback() + Object.assign(newChatMessageFeedback, chatMessageFeedback) + + const feedback = this.AppDataSource.getRepository(ChatMessageFeedback).create(newChatMessageFeedback) + return await this.AppDataSource.getRepository(ChatMessageFeedback).save(feedback) + } + + /** + * Method that updates chat message feedback. + * @param {string} id + * @param {Partial} chatMessageFeedback + */ + async updateChatMessageFeedback(id: string, chatMessageFeedback: Partial) { + const newChatMessageFeedback = new ChatMessageFeedback() + Object.assign(newChatMessageFeedback, chatMessageFeedback) + + await this.AppDataSource.getRepository(ChatMessageFeedback).update({ id }, chatMessageFeedback) + } + async upsertVector(req: Request, res: Response, isInternal: boolean = false) { try { const chatflowid = req.params.id diff --git a/packages/ui/src/api/chatmessage.js b/packages/ui/src/api/chatmessage.js index f1651247..8760ce07 100644 --- a/packages/ui/src/api/chatmessage.js +++ b/packages/ui/src/api/chatmessage.js @@ -1,8 +1,10 @@ import client from './client' -const getInternalChatmessageFromChatflow = (id) => client.get(`/internal-chatmessage/${id}`) -const getAllChatmessageFromChatflow = (id, params = {}) => client.get(`/chatmessage/${id}`, { params: { order: 'DESC', ...params } }) -const getChatmessageFromPK = (id, params = {}) => client.get(`/chatmessage/${id}`, { params: { order: 'ASC', ...params } }) +const getInternalChatmessageFromChatflow = (id, params = {}) => + client.get(`/internal-chatmessage/${id}`, { params: { feedback: true, ...params } }) +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`) diff --git a/packages/ui/src/api/chatmessagefeedback.js b/packages/ui/src/api/chatmessagefeedback.js new file mode 100644 index 00000000..1916cfed --- /dev/null +++ b/packages/ui/src/api/chatmessagefeedback.js @@ -0,0 +1,9 @@ +import client from './client' + +const addFeedback = (id, body) => client.post(`/feedback/${id}`, body) +const updateFeedback = (id, body) => client.put(`/feedback/${id}`, body) + +export default { + addFeedback, + updateFeedback +} diff --git a/packages/ui/src/api/feedback.js b/packages/ui/src/api/feedback.js new file mode 100644 index 00000000..4c2becf8 --- /dev/null +++ b/packages/ui/src/api/feedback.js @@ -0,0 +1,7 @@ +import client from './client' + +const getStatsFromChatflow = (id, params) => client.get(`/stats/${id}`, { params: { ...params } }) + +export default { + getStatsFromChatflow +} diff --git a/packages/ui/src/menu-items/settings.js b/packages/ui/src/menu-items/settings.js index bc7789bd..8c7d08b8 100644 --- a/packages/ui/src/menu-items/settings.js +++ b/packages/ui/src/menu-items/settings.js @@ -8,7 +8,8 @@ import { IconMessage, IconPictureInPictureOff, IconLink, - IconMicrophone + IconMicrophone, + IconThumbUp } from '@tabler/icons' // constant @@ -21,7 +22,8 @@ const icons = { IconMessage, IconPictureInPictureOff, IconLink, - IconMicrophone + IconMicrophone, + IconThumbUp } // ==============================|| SETTINGS MENU ITEMS ||============================== // @@ -45,6 +47,13 @@ const settings = { url: '', icon: icons.IconMessage }, + { + id: 'chatFeedback', + title: 'Chat Feedback', + type: 'item', + url: '', + icon: icons.IconThumbUp + }, { id: 'allowedDomains', title: 'Allowed Domains', diff --git a/packages/ui/src/ui-component/button/CopyToClipboardButton.jsx b/packages/ui/src/ui-component/button/CopyToClipboardButton.jsx new file mode 100644 index 00000000..ee9cc752 --- /dev/null +++ b/packages/ui/src/ui-component/button/CopyToClipboardButton.jsx @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types' +import { useSelector } from 'react-redux' +import { IconButton } from '@mui/material' +import { IconClipboard } from '@tabler/icons' + +const CopyToClipboardButton = (props) => { + const customization = useSelector((state) => state.customization) + + return ( + + + + ) +} + +CopyToClipboardButton.propTypes = { + isDisabled: PropTypes.bool, + isLoading: PropTypes.bool, + onClick: PropTypes.func +} + +export default CopyToClipboardButton diff --git a/packages/ui/src/ui-component/button/ThumbsDownButton.jsx b/packages/ui/src/ui-component/button/ThumbsDownButton.jsx new file mode 100644 index 00000000..6ee9e09a --- /dev/null +++ b/packages/ui/src/ui-component/button/ThumbsDownButton.jsx @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types' +import { useSelector } from 'react-redux' +import { IconButton } from '@mui/material' +import { IconThumbDown } from '@tabler/icons' + +const ThumbsDownButton = (props) => { + const customization = useSelector((state) => state.customization) + return ( + + + + ) +} + +ThumbsDownButton.propTypes = { + isDisabled: PropTypes.bool, + isLoading: PropTypes.bool, + onClick: PropTypes.func, + rating: PropTypes.string +} + +export default ThumbsDownButton diff --git a/packages/ui/src/ui-component/button/ThumbsUpButton.jsx b/packages/ui/src/ui-component/button/ThumbsUpButton.jsx new file mode 100644 index 00000000..c1b5106e --- /dev/null +++ b/packages/ui/src/ui-component/button/ThumbsUpButton.jsx @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types' +import { useSelector } from 'react-redux' +import { IconButton } from '@mui/material' +import { IconThumbUp } from '@tabler/icons' + +const ThumbsUpButton = (props) => { + const customization = useSelector((state) => state.customization) + return ( + + + + ) +} + +ThumbsUpButton.propTypes = { + isDisabled: PropTypes.bool, + isLoading: PropTypes.bool, + onClick: PropTypes.func, + rating: PropTypes.string +} + +export default ThumbsUpButton diff --git a/packages/ui/src/ui-component/cards/StatsCard.jsx b/packages/ui/src/ui-component/cards/StatsCard.jsx new file mode 100644 index 00000000..57e8205d --- /dev/null +++ b/packages/ui/src/ui-component/cards/StatsCard.jsx @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types' + +import { useSelector } from 'react-redux' +import Card from '@mui/material/Card' +import CardContent from '@mui/material/CardContent' +import Typography from '@mui/material/Typography' + +const StatsCard = ({ title, stat }) => { + const customization = useSelector((state) => state.customization) + return ( + + + + {title} + + + {stat} + + + + ) +} + +StatsCard.propTypes = { + title: PropTypes.string, + stat: PropTypes.string +} + +export default StatsCard diff --git a/packages/ui/src/ui-component/dialog/ChatFeedbackContentDialog.jsx b/packages/ui/src/ui-component/dialog/ChatFeedbackContentDialog.jsx new file mode 100644 index 00000000..6b34aeca --- /dev/null +++ b/packages/ui/src/ui-component/dialog/ChatFeedbackContentDialog.jsx @@ -0,0 +1,76 @@ +import { useCallback, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { useDispatch } from 'react-redux' + +// material-ui +import { Button, Dialog, DialogContent, DialogTitle, DialogActions, Box, OutlinedInput } from '@mui/material' +import { useState } from 'react' + +// Project import +import { StyledButton } from '@/ui-component/button/StyledButton' + +// store +import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions' + +const ChatFeedbackContentDialog = ({ show, onCancel, onConfirm }) => { + const portalElement = document.getElementById('portal') + const dispatch = useDispatch() + + const [feedbackContent, setFeedbackContent] = useState('') + + const onChange = useCallback((e) => setFeedbackContent(e.target.value), [setFeedbackContent]) + + const onSave = () => { + onConfirm(feedbackContent) + } + + useEffect(() => { + if (show) dispatch({ type: SHOW_CANVAS_DIALOG }) + else dispatch({ type: HIDE_CANVAS_DIALOG }) + return () => { + dispatch({ type: HIDE_CANVAS_DIALOG }) + setFeedbackContent('') + } + }, [show, dispatch]) + + const component = show ? ( + + + Provide additional feedback + + + + + + + + + + Submit Feedback + + + + ) : null + + return createPortal(component, portalElement) +} + +export default ChatFeedbackContentDialog diff --git a/packages/ui/src/ui-component/dialog/ChatFeedbackDialog.jsx b/packages/ui/src/ui-component/dialog/ChatFeedbackDialog.jsx new file mode 100644 index 00000000..54bb4408 --- /dev/null +++ b/packages/ui/src/ui-component/dialog/ChatFeedbackDialog.jsx @@ -0,0 +1,142 @@ +import { createPortal } from 'react-dom' +import { useDispatch } from 'react-redux' +import { useState, useEffect } from 'react' +import PropTypes from 'prop-types' + +// material-ui +import { Button, Dialog, DialogContent, DialogTitle, DialogActions, Box } from '@mui/material' +import { IconX } from '@tabler/icons' + +// Project import +import { StyledButton } from '@/ui-component/button/StyledButton' +import { SwitchInput } from '@/ui-component/switch/Switch' + +// store +import { + enqueueSnackbar as enqueueSnackbarAction, + closeSnackbar as closeSnackbarAction, + SET_CHATFLOW, + HIDE_CANVAS_DIALOG, + SHOW_CANVAS_DIALOG +} from '@/store/actions' +import useNotifier from '@/utils/useNotifier' + +// API +import chatflowsApi from '@/api/chatflows' + +const ChatFeedbackDialog = ({ show, dialogProps, onCancel, onConfirm }) => { + const portalElement = document.getElementById('portal') + const dispatch = useDispatch() + + useNotifier() + + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) + + const [chatFeedbackStatus, setChatFeedbackStatus] = useState(false) + const [chatbotConfig, setChatbotConfig] = useState({}) + + const handleChange = (value) => { + setChatFeedbackStatus(value) + } + + const onSave = async () => { + try { + let value = { + chatFeedback: { + status: chatFeedbackStatus + } + } + chatbotConfig.chatFeedback = value.chatFeedback + const saveResp = await chatflowsApi.updateChatflow(dialogProps.chatflow.id, { + chatbotConfig: JSON.stringify(chatbotConfig) + }) + if (saveResp.data) { + enqueueSnackbar({ + message: 'Chat Feedback Settings Saved', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + dispatch({ type: SET_CHATFLOW, chatflow: saveResp.data }) + } + onConfirm() + } catch (error) { + const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` + enqueueSnackbar({ + message: `Failed to save Chat Feedback Settings: ${errorData}`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + } + } + + useEffect(() => { + if (dialogProps.chatflow && dialogProps.chatflow.chatbotConfig) { + let chatbotConfig = JSON.parse(dialogProps.chatflow.chatbotConfig) + setChatbotConfig(chatbotConfig || {}) + if (chatbotConfig.chatFeedback) { + setChatFeedbackStatus(chatbotConfig.chatFeedback.status) + } + } + + return () => {} + }, [dialogProps]) + + useEffect(() => { + if (show) dispatch({ type: SHOW_CANVAS_DIALOG }) + else dispatch({ type: HIDE_CANVAS_DIALOG }) + return () => dispatch({ type: HIDE_CANVAS_DIALOG }) + }, [show, dispatch]) + + const component = show ? ( + + + {dialogProps.title || 'Chat Feedback'} + + + + + + + + + + Save + + + + ) : null + + return createPortal(component, portalElement) +} + +ChatFeedbackDialog.propTypes = { + show: PropTypes.bool, + dialogProps: PropTypes.object, + onCancel: PropTypes.func, + onConfirm: PropTypes.func +} + +export default ChatFeedbackDialog diff --git a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx index 8a110bb9..80d0b45f 100644 --- a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx +++ b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx @@ -39,12 +39,15 @@ import { CodeBlock } from '@/ui-component/markdown/CodeBlock' import SourceDocDialog from '@/ui-component/dialog/SourceDocDialog' import { MultiDropdown } from '@/ui-component/dropdown/MultiDropdown' import { StyledButton } from '@/ui-component/button/StyledButton' +import StatsCard from '@/ui-component/cards/StatsCard' +import Feedback from '@/ui-component/extended/Feedback' // store import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions' // API import chatmessageApi from '@/api/chatmessage' +import feedbackApi from '@/api/feedback' import useApi from '@/hooks/useApi' import useConfirm from '@/hooks/useConfirm' @@ -91,6 +94,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { const [chatlogs, setChatLogs] = useState([]) const [allChatlogs, setAllChatLogs] = useState([]) const [chatMessages, setChatMessages] = useState([]) + const [stats, setStats] = useState([]) const [selectedMessageIndex, setSelectedMessageIndex] = useState(0) const [sourceDialogOpen, setSourceDialogOpen] = useState(false) const [sourceDialogProps, setSourceDialogProps] = useState({}) @@ -100,6 +104,7 @@ 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 = '' @@ -110,6 +115,11 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { endDate: endDate, chatType: chatTypeFilter.length ? chatTypeFilter : undefined }) + getStatsApi.request(dialogProps.chatflow.id, { + startDate: date, + endDate: endDate, + chatType: chatTypeFilter.length ? chatTypeFilter : undefined + }) } const onEndDateSelected = (date) => { @@ -119,6 +129,11 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { startDate: startDate, chatType: chatTypeFilter.length ? chatTypeFilter : undefined }) + getStatsApi.request(dialogProps.chatflow.id, { + endDate: date, + startDate: startDate, + chatType: chatTypeFilter.length ? chatTypeFilter : undefined + }) } const onChatTypeSelected = (chatTypes) => { @@ -128,6 +143,11 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { startDate: startDate, endDate: endDate }) + getStatsApi.request(dialogProps.chatflow.id, { + chatType: chatTypes.length ? chatTypes : undefined, + startDate: startDate, + endDate: endDate + }) } const exportMessages = async () => { @@ -162,6 +182,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 (chatmsg.feedback) msg.feedback = chatmsg.feedback?.content if (!Object.prototype.hasOwnProperty.call(obj, chatPK)) { obj[chatPK] = { @@ -238,6 +259,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { } }) getChatmessageApi.request(chatflowid) + getStatsApi.request(chatflowid) // update stats } catch (error) { const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` enqueueSnackbar({ @@ -405,9 +427,16 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [getChatmessageApi.data]) + useEffect(() => { + if (getStatsApi.data) { + setStats(getStatsApi.data) + } + }, [getStatsApi.data]) + useEffect(() => { if (dialogProps.chatflow) { getChatmessageApi.request(dialogProps.chatflow.id) + getStatsApi.request(dialogProps.chatflow.id) } return () => { @@ -418,6 +447,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { setSelectedMessageIndex(0) setStartDate(new Date().setMonth(new Date().getMonth() - 1)) setEndDate(new Date()) + setStats([]) } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -449,7 +479,16 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { <> -
+
From Date {
+
+ + + +
{chatlogs && chatlogs.length == 0 && ( @@ -812,6 +868,12 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { })}
)} + {message.type === 'apiMessage' && message.feedback ? ( + + ) : null}
) diff --git a/packages/ui/src/ui-component/extended/Feedback.jsx b/packages/ui/src/ui-component/extended/Feedback.jsx new file mode 100644 index 00000000..5a943ec5 --- /dev/null +++ b/packages/ui/src/ui-component/extended/Feedback.jsx @@ -0,0 +1,71 @@ +import { Alert, IconButton } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import PropTypes from 'prop-types' + +const ThumbsUpIcon = () => { + return ( + + + + + ) +} + +const ThumbsDownIcon = () => { + return ( + + + + + ) +} + +const Feedback = ({ content, rating }) => { + const theme = useTheme() + + return ( +
+ {content ? ( + : } + severity={rating === 'THUMBS_UP' ? 'success' : 'error'} + style={{ marginBottom: 14 }} + variant='outlined' + > + {content ? {content} : null} + + ) : ( + + {rating === 'THUMBS_UP' ? : } + + )} +
+ ) +} + +Feedback.propTypes = { + rating: PropTypes.oneOf(['THUMBS_UP', 'THUMBS_DOWN']), + content: PropTypes.string +} + +export default Feedback diff --git a/packages/ui/src/ui-component/switch/Switch.jsx b/packages/ui/src/ui-component/switch/Switch.jsx index 16a923f1..50ace119 100644 --- a/packages/ui/src/ui-component/switch/Switch.jsx +++ b/packages/ui/src/ui-component/switch/Switch.jsx @@ -1,13 +1,21 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import PropTypes from 'prop-types' -import { FormControl, Switch } from '@mui/material' +import { FormControl, Switch, Typography } from '@mui/material' -export const SwitchInput = ({ value, onChange, disabled = false }) => { +export const SwitchInput = ({ label, value, onChange, disabled = false }) => { const [myValue, setMyValue] = useState(!!value ?? false) + useEffect(() => { + setMyValue(value) + }, [value]) + return ( <> - + + {label && {label}} { } SwitchInput.propTypes = { + label: PropTypes.string, value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), onChange: PropTypes.func, disabled: PropTypes.bool diff --git a/packages/ui/src/views/canvas/CanvasHeader.jsx b/packages/ui/src/views/canvas/CanvasHeader.jsx index e6775ebf..cfaf3d7e 100644 --- a/packages/ui/src/views/canvas/CanvasHeader.jsx +++ b/packages/ui/src/views/canvas/CanvasHeader.jsx @@ -18,6 +18,7 @@ import AnalyseFlowDialog from '@/ui-component/dialog/AnalyseFlowDialog' import ViewMessagesDialog from '@/ui-component/dialog/ViewMessagesDialog' import StarterPromptsDialog from '@/ui-component/dialog/StarterPromptsDialog' import SpeechToTextDialog from '@/ui-component/dialog/SpeechToTextDialog' +import ChatFeedbackDialog from '@/ui-component/dialog/ChatFeedbackDialog' import AllowedDomainsDialog from '@/ui-component/dialog/AllowedDomainsDialog' // API @@ -54,6 +55,8 @@ const CanvasHeader = ({ chatflow, handleSaveFlow, handleDeleteFlow, handleLoadFl const [conversationStartersDialogProps, setConversationStartersDialogProps] = useState({}) const [viewMessagesDialogOpen, setViewMessagesDialogOpen] = useState(false) const [viewMessagesDialogProps, setViewMessagesDialogProps] = useState({}) + const [chatFeedbackDialogOpen, setChatFeedbackDialogOpen] = useState(false) + const [chatFeedbackDialogProps, setChatFeedbackDialogProps] = useState({}) const [allowedDomainsDialogOpen, setAllowedDomainsDialogOpen] = useState(false) const [allowedDomainsDialogProps, setAllowedDomainsDialogProps] = useState({}) @@ -71,6 +74,12 @@ const CanvasHeader = ({ chatflow, handleSaveFlow, handleDeleteFlow, handleLoadFl chatflow: chatflow }) setConversationStartersDialogOpen(true) + } else if (setting === 'chatFeedback') { + setChatFeedbackDialogProps({ + title: `Chat Feedback - ${chatflow.name}`, + chatflow: chatflow + }) + setChatFeedbackDialogOpen(true) } else if (setting === 'allowedDomains') { setAllowedDomainsDialogProps({ title: 'Allowed Domains - ' + chatflow.name, @@ -414,6 +423,12 @@ const CanvasHeader = ({ chatflow, handleSaveFlow, handleDeleteFlow, handleLoadFl onConfirm={() => setConversationStartersDialogOpen(false)} onCancel={() => setConversationStartersDialogOpen(false)} /> + setChatFeedbackDialogOpen(false)} + onCancel={() => setChatFeedbackDialogOpen(false)} + /> { + let allMessages = [...cloneDeep(prevMessages)] + if (allMessages[allMessages.length - 1].type === 'apiMessage') { + allMessages[allMessages.length - 1].id = data?.chatMessageId + } + return allMessages + }) + if (!chatId) setChatId(data.chatId) if (input === '' && data.question) { @@ -412,10 +429,12 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews ...prevMessages, { message: text, + id: data?.chatMessageId, sourceDocuments: data?.sourceDocuments, usedTools: data?.usedTools, fileAnnotations: data?.fileAnnotations, - type: 'apiMessage' + type: 'apiMessage', + feedback: null } ]) } @@ -474,7 +493,9 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews setChatId(chatId) const loadedMessages = getChatmessageApi.data.map((message) => { const obj = { + id: message.id, message: message.content, + feedback: message.feedback, type: message.role } if (message.sourceDocuments) obj.sourceDocuments = JSON.parse(message.sourceDocuments) @@ -527,6 +548,9 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews }) setStarterPrompts(inputFields) } + if (config.chatFeedback) { + setChatFeedbackStatus(config.chatFeedback.status) + } } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -604,6 +628,83 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews // eslint-disable-next-line }, [previews]) + const copyMessageToClipboard = async (text) => { + try { + await navigator.clipboard.writeText(text || '') + } catch (error) { + console.error('Error copying to clipboard:', error) + } + } + + const onThumbsUpClick = async (messageId) => { + const body = { + chatflowid, + chatId, + messageId, + rating: 'THUMBS_UP', + content: '' + } + const result = await chatmessagefeedbackApi.addFeedback(chatflowid, body) + if (result.data) { + const data = result.data + let id = '' + if (data && data.id) id = data.id + setMessages((prevMessages) => { + const allMessages = [...cloneDeep(prevMessages)] + return allMessages.map((message) => { + if (message.id === messageId) { + message.feedback = { + rating: 'THUMBS_UP' + } + } + return message + }) + }) + setFeedbackId(id) + setShowFeedbackContentDialog(true) + } + } + + const onThumbsDownClick = async (messageId) => { + const body = { + chatflowid, + chatId, + messageId, + rating: 'THUMBS_DOWN', + content: '' + } + const result = await chatmessagefeedbackApi.addFeedback(chatflowid, body) + if (result.data) { + const data = result.data + let id = '' + if (data && data.id) id = data.id + setMessages((prevMessages) => { + const allMessages = [...cloneDeep(prevMessages)] + return allMessages.map((message) => { + if (message.id === messageId) { + message.feedback = { + rating: 'THUMBS_DOWN' + } + } + return message + }) + }) + setFeedbackId(id) + setShowFeedbackContentDialog(true) + } + } + + const submitFeedbackContent = async (text) => { + const body = { + content: text + } + const result = await chatmessagefeedbackApi.updateFeedback(feedbackId, body) + if (result.data) { + setFeedbackId('') + setShowFeedbackContentDialog(false) + } + } + return (
{isDragActive && ( @@ -747,6 +848,31 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews {message.message}
+ {message.type === 'apiMessage' && message.id && chatFeedbackStatus ? ( + <> + + copyMessageToClipboard(message.message)} /> + {!message.feedback || + message.feedback.rating === '' || + message.feedback.rating === 'THUMBS_UP' ? ( + onThumbsUpClick(message.id)} + /> + ) : null} + {!message.feedback || + message.feedback.rating === '' || + message.feedback.rating === 'THUMBS_DOWN' ? ( + onThumbsDownClick(message.id)} + /> + ) : null} + + + ) : null} {message.fileAnnotations && (
{message.fileAnnotations.map((fileAnnotation, index) => { @@ -993,6 +1119,11 @@ export const ChatMessage = ({ open, chatflowid, isDialog, previews, setPreviews )}
setSourceDialogOpen(false)} /> + setShowFeedbackContentDialog(false)} + onConfirm={submitFeedbackContent} + /> ) }