diff --git a/front/components/assistant/conversation/AgentMessage.tsx b/front/components/assistant/conversation/AgentMessage.tsx index c28bb5db614d..c0039666e23e 100644 --- a/front/components/assistant/conversation/AgentMessage.tsx +++ b/front/components/assistant/conversation/AgentMessage.tsx @@ -1,5 +1,5 @@ import type { - ConversationMessageEmojiSelectorProps, + ConversationMessageFeedbackSelectorProps, ConversationMessageSizeType, } from "@dust-tt/sparkle"; import { @@ -13,6 +13,7 @@ import { ConversationMessage, DocumentDuplicateIcon, EyeIcon, + FeedbackSelector, Markdown, Popover, } from "@dust-tt/sparkle"; @@ -83,7 +84,7 @@ interface AgentMessageProps { isInModal: boolean; isLastMessage: boolean; message: AgentMessageType; - messageEmoji?: ConversationMessageEmojiSelectorProps; + messageFeedback: ConversationMessageFeedbackSelectorProps; owner: WorkspaceType; user: UserType; size: ConversationMessageSizeType; @@ -100,7 +101,7 @@ export function AgentMessage({ isInModal, isLastMessage, message, - messageEmoji, + messageFeedback, owner, user, size, @@ -368,6 +369,7 @@ export function AgentMessage({ icon={ArrowPathIcon} disabled={isRetryHandlerProcessing || shouldStream} />, + , ]; // References logic. @@ -464,7 +466,6 @@ export function AgentMessage({ name={`@${agentConfiguration.name}`} buttons={buttons} avatarBusy={agentMessageToRender.status === "created"} - messageEmoji={messageEmoji} renderName={() => { return (
diff --git a/front/components/assistant/conversation/ConversationViewer.tsx b/front/components/assistant/conversation/ConversationViewer.tsx index 43e70920f455..eef5c2ea09aa 100644 --- a/front/components/assistant/conversation/ConversationViewer.tsx +++ b/front/components/assistant/conversation/ConversationViewer.tsx @@ -28,9 +28,9 @@ import { } from "@app/lib/client/conversation/event_handlers"; import { useConversation, + useConversationFeedbacks, useConversationMessages, useConversationParticipants, - useConversationReactions, useConversations, } from "@app/lib/swr/conversations"; import { classNames } from "@app/lib/utils"; @@ -39,7 +39,6 @@ const DEFAULT_PAGE_LIMIT = 50; interface ConversationViewerProps { conversationId: string; - hideReactions?: boolean; isFading?: boolean; isInModal?: boolean; onStickyMentionsChange?: (mentions: AgentMention[]) => void; @@ -62,7 +61,6 @@ const ConversationViewer = React.forwardRef< conversationId, onStickyMentionsChange, isInModal = false, - hideReactions = false, isFading = false, }, ref @@ -95,11 +93,6 @@ const ConversationViewer = React.forwardRef< limit: DEFAULT_PAGE_LIMIT, }); - const { reactions } = useConversationReactions({ - workspaceId: owner.sId, - conversationId, - }); - const { mutateConversationParticipants } = useConversationParticipants({ conversationId, workspaceId: owner.sId, @@ -192,6 +185,11 @@ const ConversationViewer = React.forwardRef< const { ref: viewRef, inView: isTopOfListVisible } = useInView(); + const { feedbacks } = useConversationFeedbacks({ + conversationId: conversationId ?? "", + workspaceId: owner.sId, + }); + // On page load or when new data is loaded, check if the top of the list // is visible and there is more data to load. If so, set the current // highest message ID and increment the page number to load more data. @@ -346,10 +344,9 @@ const ConversationViewer = React.forwardRef< messages={typedGroup} isLastMessageGroup={isLastGroup} conversationId={conversationId} - hideReactions={hideReactions} + feedbacks={feedbacks} isInModal={isInModal} owner={owner} - reactions={reactions} prevFirstMessageId={prevFirstMessageId} prevFirstMessageRef={prevFirstMessageRef} user={user} diff --git a/front/components/assistant/conversation/MessageGroup.tsx b/front/components/assistant/conversation/MessageGroup.tsx index 9f8684948ce4..0c0eade31096 100644 --- a/front/components/assistant/conversation/MessageGroup.tsx +++ b/front/components/assistant/conversation/MessageGroup.tsx @@ -1,5 +1,4 @@ import type { - ConversationMessageReactions, FetchConversationMessagesResponse, MessageWithContentFragmentsType, UserType, @@ -8,15 +7,15 @@ import type { import React, { useEffect, useRef } from "react"; import MessageItem from "@app/components/assistant/conversation/MessageItem"; +import type { AgentMessageFeedbackType } from "@app/lib/api/assistant/feedback"; interface MessageGroupProps { messages: MessageWithContentFragmentsType[]; isLastMessageGroup: boolean; conversationId: string; - hideReactions: boolean; + feedbacks: AgentMessageFeedbackType[]; isInModal: boolean; owner: WorkspaceType; - reactions: ConversationMessageReactions; prevFirstMessageId: string | null; prevFirstMessageRef: React.RefObject; user: UserType; @@ -33,10 +32,9 @@ export default function MessageGroup({ messages, isLastMessageGroup, conversationId, - hideReactions, + feedbacks, isInModal, owner, - reactions, prevFirstMessageId, prevFirstMessageRef, user, @@ -66,11 +64,12 @@ export default function MessageGroup({ feedback.messageId === message.sId + )} isInModal={isInModal} message={message} owner={owner} - reactions={reactions} ref={ message.sId === prevFirstMessageId ? prevFirstMessageRef : undefined } diff --git a/front/components/assistant/conversation/MessageItem.tsx b/front/components/assistant/conversation/MessageItem.tsx index 40043a5bc7f6..181255a09fce 100644 --- a/front/components/assistant/conversation/MessageItem.tsx +++ b/front/components/assistant/conversation/MessageItem.tsx @@ -1,7 +1,10 @@ -import type { CitationType } from "@dust-tt/sparkle"; +import type { + CitationType, + ConversationMessageFeedbackSelectorProps, +} from "@dust-tt/sparkle"; import { Citation, ZoomableImageCitationWrapper } from "@dust-tt/sparkle"; +import { useSendNotification } from "@dust-tt/sparkle"; import type { - ConversationMessageReactions, MessageWithContentFragmentsType, UserType, WorkspaceType, @@ -12,16 +15,16 @@ import { useSWRConfig } from "swr"; import { AgentMessage } from "@app/components/assistant/conversation/AgentMessage"; import { UserMessage } from "@app/components/assistant/conversation/UserMessage"; +import type { AgentMessageFeedbackType } from "@app/lib/api/assistant/feedback"; import { useSubmitFunction } from "@app/lib/client/utils"; interface MessageItemProps { conversationId: string; - hideReactions: boolean; + messageFeedback: AgentMessageFeedbackType | undefined; isInModal: boolean; isLastMessage: boolean; message: MessageWithContentFragmentsType; owner: WorkspaceType; - reactions: ConversationMessageReactions; user: UserType; } @@ -29,45 +32,54 @@ const MessageItem = React.forwardRef( function MessageItem( { conversationId, - hideReactions, + messageFeedback, isInModal, isLastMessage, message, owner, - reactions, user, }: MessageItemProps, ref ) { const { sId, type } = message; + const sendNotification = useSendNotification(); - const convoReactions = reactions.find((r) => r.messageId === sId); - const messageReactions = convoReactions?.reactions || []; const { mutate } = useSWRConfig(); - const { submit: onSubmitEmoji, isSubmitting: isSubmittingEmoji } = + const { submit: onSubmitThumb, isSubmitting: isSubmittingThumb } = useSubmitFunction( async ({ - emoji, + thumb, isToRemove, + feedbackContent, }: { - emoji: string; + thumb: string; isToRemove: boolean; + feedbackContent?: string | null; }) => { const res = await fetch( - `/api/w/${owner.sId}/assistant/conversations/${conversationId}/messages/${message.sId}/reactions`, + `/api/w/${owner.sId}/assistant/conversations/${conversationId}/messages/${message.sId}/feedbacks`, { method: isToRemove ? "DELETE" : "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - reaction: emoji, + thumbDirection: thumb, + feedbackContent, }), } ); if (res.ok) { + if (feedbackContent && !isToRemove) { + sendNotification({ + title: "Feedback submitted", + description: + "Your comment has been submitted successfully to the Builder of this assistant. Thank you!", + type: "success", + }); + } await mutate( - `/api/w/${owner.sId}/assistant/conversations/${conversationId}/reactions` + `/api/w/${owner.sId}/assistant/conversations/${conversationId}/feedbacks` ); } } @@ -77,17 +89,17 @@ const MessageItem = React.forwardRef( return null; } - const messageEmoji = hideReactions - ? undefined - : { - reactions: messageReactions.map((reaction) => ({ - emoji: reaction.emoji, - hasReacted: reaction.users.some((u) => u.userId === user.id), - count: reaction.users.length, - })), - onSubmitEmoji, - isSubmittingEmoji, - }; + const messageFeedbackWithSubmit: ConversationMessageFeedbackSelectorProps = + { + feedback: messageFeedback + ? { + thumb: messageFeedback.thumbDirection, + feedbackContent: messageFeedback.content, + } + : null, + onSubmitThumb, + isSubmittingThumb, + }; switch (type) { case "user_message": @@ -152,7 +164,7 @@ const MessageItem = React.forwardRef( isInModal={isInModal} isLastMessage={isLastMessage} message={message} - messageEmoji={messageEmoji} + messageFeedback={messageFeedbackWithSubmit} owner={owner} user={user} size={isInModal ? "compact" : "normal"} diff --git a/front/components/assistant/conversation/UserMessage.tsx b/front/components/assistant/conversation/UserMessage.tsx index 9498f2729c35..c435304e7168 100644 --- a/front/components/assistant/conversation/UserMessage.tsx +++ b/front/components/assistant/conversation/UserMessage.tsx @@ -1,7 +1,4 @@ -import type { - ConversationMessageEmojiSelectorProps, - ConversationMessageSizeType, -} from "@dust-tt/sparkle"; +import type { ConversationMessageSizeType } from "@dust-tt/sparkle"; import { ConversationMessage, Markdown } from "@dust-tt/sparkle"; import type { UserMessageType, WorkspaceType } from "@dust-tt/types"; import { useMemo } from "react"; @@ -23,7 +20,6 @@ interface UserMessageProps { conversationId: string; isLastMessage: boolean; message: UserMessageType; - messageEmoji?: ConversationMessageEmojiSelectorProps; owner: WorkspaceType; size: ConversationMessageSizeType; } @@ -33,7 +29,6 @@ export function UserMessage({ conversationId, isLastMessage, message, - messageEmoji, owner, size, }: UserMessageProps) { @@ -54,7 +49,6 @@ export function UserMessage({
{name}
} type="user" citations={citations} diff --git a/front/components/assistant_builder/AssistantBuilderPreviewDrawer.tsx b/front/components/assistant_builder/AssistantBuilderPreviewDrawer.tsx index a5fa8c623ceb..9bf4519e16de 100644 --- a/front/components/assistant_builder/AssistantBuilderPreviewDrawer.tsx +++ b/front/components/assistant_builder/AssistantBuilderPreviewDrawer.tsx @@ -152,7 +152,6 @@ export default function AssistantBuilderRightPanel({ conversationId={conversation.sId} onStickyMentionsChange={setStickyMentions} isInModal - hideReactions isFading={isFading} key={conversation.sId} /> diff --git a/front/lib/api/assistant/conversation/destroy.ts b/front/lib/api/assistant/conversation/destroy.ts index fe04cd73329f..10b4bc618605 100644 --- a/front/lib/api/assistant/conversation/destroy.ts +++ b/front/lib/api/assistant/conversation/destroy.ts @@ -11,6 +11,7 @@ import { AgentWebsearchAction } from "@app/lib/models/assistant/actions/websearc import type { Conversation } from "@app/lib/models/assistant/conversation"; import { AgentMessage, + AgentMessageFeedback, ConversationParticipant, Mention, Message, @@ -154,6 +155,9 @@ export async function destroyConversation( await UserMessage.destroy({ where: { id: userMessageIds }, }); + await AgentMessageFeedback.destroy({ + where: { agentMessageId: agentMessageIds }, + }); await AgentMessage.destroy({ where: { id: agentMessageIds }, }); diff --git a/front/lib/api/assistant/feedback.ts b/front/lib/api/assistant/feedback.ts new file mode 100644 index 000000000000..ebfb89af97e4 --- /dev/null +++ b/front/lib/api/assistant/feedback.ts @@ -0,0 +1,239 @@ +import type { + ConversationType, + ConversationWithoutContentType, + Result, +} from "@dust-tt/types"; +import type { UserType } from "@dust-tt/types"; +import { ConversationError, Err, GLOBAL_AGENTS_SID, Ok } from "@dust-tt/types"; +import { Op } from "sequelize"; + +import { canAccessConversation } from "@app/lib/api/assistant/conversation"; +import type { AgentMessageFeedbackDirection } from "@app/lib/api/assistant/conversation/feedbacks"; +import type { Authenticator } from "@app/lib/auth"; +import { AgentConfiguration } from "@app/lib/models/assistant/agent"; +import { AgentMessage } from "@app/lib/models/assistant/conversation"; +import { Message } from "@app/lib/models/assistant/conversation"; +import { AgentMessageFeedbackResource } from "@app/lib/resources/agent_message_feedback_resource"; + +/** + * We retrieve the feedbacks for a whole conversation, not just a single message. + */ + +export type AgentMessageFeedbackType = { + id: number; + messageId: string; + agentMessageId: number; + userId: number; + thumbDirection: AgentMessageFeedbackDirection; + content: string | null; +}; + +export async function getConversationFeedbacksForUser( + auth: Authenticator, + conversation: ConversationType | ConversationWithoutContentType +): Promise> { + const owner = auth.workspace(); + if (!owner) { + throw new Error("Unexpected `auth` without `workspace`."); + } + const user = auth.user(); + if (!canAccessConversation(auth, conversation) || !user) { + return new Err(new ConversationError("conversation_access_restricted")); + } + + const messages = await Message.findAll({ + where: { + conversationId: conversation.id, + agentMessageId: { + [Op.ne]: null, + }, + }, + attributes: ["sId", "agentMessageId"], + }); + + const agentMessages = await AgentMessage.findAll({ + where: { + id: { + [Op.in]: messages + .map((m) => m.agentMessageId) + .filter((id): id is number => id !== null), + }, + }, + }); + + const feedbacks = + await AgentMessageFeedbackResource.fetchByUserAndAgentMessages( + user, + agentMessages + ); + + const feedbacksByMessageId = feedbacks.map( + (feedback) => + ({ + id: feedback.id, + messageId: messages.find( + (m) => m.agentMessageId === feedback.agentMessageId + )!.sId, + agentMessageId: feedback.agentMessageId, + userId: feedback.userId, + thumbDirection: feedback.thumbDirection, + content: feedback.content, + }) as AgentMessageFeedbackType + ); + + return new Ok(feedbacksByMessageId); +} + +/** + * We create a feedback for a single message. + * As user can be null (user from Slack), we also store the user context, as we do for messages. + */ +export async function createOrUpdateMessageFeedback( + auth: Authenticator, + { + messageId, + conversation, + user, + thumbDirection, + content, + }: { + messageId: string; + conversation: ConversationType | ConversationWithoutContentType; + user: UserType; + thumbDirection: AgentMessageFeedbackDirection; + content?: string; + } +): Promise { + const owner = auth.workspace(); + if (!owner) { + throw new Error("Unexpected `auth` without `workspace`."); + } + + const message = await Message.findOne({ + where: { + sId: messageId, + conversationId: conversation.id, + }, + }); + + if (!message || !message.agentMessageId) { + return null; + } + + const agentMessage = await AgentMessage.findOne({ + where: { + id: message.agentMessageId, + }, + }); + + if (!agentMessage) { + return null; + } + + let isGlobalAgent = false; + let agentConfigurationId = agentMessage.agentConfigurationId; + if ( + Object.values(GLOBAL_AGENTS_SID).includes( + agentMessage.agentConfigurationId as GLOBAL_AGENTS_SID + ) + ) { + isGlobalAgent = true; + } + + if (!isGlobalAgent) { + const agentConfiguration = await AgentConfiguration.findOne({ + where: { + sId: agentMessage.agentConfigurationId, + }, + }); + + if (!agentConfiguration) { + return null; + } + agentConfigurationId = agentConfiguration.sId; + } + + const feedback = + await AgentMessageFeedbackResource.fetchByUserAndAgentMessage({ + user, + agentMessage, + }); + + if (feedback) { + const updatedFeedback = await feedback.updateContentAndThumbDirection( + content ?? "", + thumbDirection + ); + + return updatedFeedback.isOk(); + } else { + const newFeedback = await AgentMessageFeedbackResource.makeNew({ + workspaceId: owner.id, + agentConfigurationId: agentConfigurationId, + agentConfigurationVersion: agentMessage.agentConfigurationVersion, + agentMessageId: agentMessage.id, + userId: user.id, + thumbDirection, + content, + }); + return newFeedback !== null; + } +} + +/** + * The id of a reaction is not exposed on the API so we need to find it from the message id and the user context. + * We destroy reactions, no point in soft-deleting them. + */ +export async function deleteMessageFeedback( + auth: Authenticator, + { + messageId, + conversation, + user, + }: { + messageId: string; + conversation: ConversationType | ConversationWithoutContentType; + user: UserType; + } +): Promise { + const owner = auth.workspace(); + if (!owner) { + throw new Error("Unexpected `auth` without `workspace`."); + } + + const message = await Message.findOne({ + where: { + sId: messageId, + conversationId: conversation.id, + }, + attributes: ["agentMessageId"], + }); + + if (!message || !message.agentMessageId) { + return null; + } + + const agentMessage = await AgentMessage.findOne({ + where: { + id: message.agentMessageId, + }, + }); + + if (!agentMessage) { + return null; + } + + const feedback = + await AgentMessageFeedbackResource.fetchByUserAndAgentMessage({ + user, + agentMessage, + }); + + if (!feedback) { + return null; + } + + const deletedFeedback = await feedback.delete(auth); + + return deletedFeedback.isOk(); +} diff --git a/front/lib/models/assistant/conversation.ts b/front/lib/models/assistant/conversation.ts index e247b2261275..e93a305eff30 100644 --- a/front/lib/models/assistant/conversation.ts +++ b/front/lib/models/assistant/conversation.ts @@ -15,7 +15,6 @@ import type { import { DataTypes, Model } from "sequelize"; import type { AgentMessageFeedbackDirection } from "@app/lib/api/assistant/conversation/feedbacks"; -import { AgentConfiguration } from "@app/lib/models/assistant/agent"; import type { AgentMessageContent } from "@app/lib/models/assistant/agent_message_content"; import { User } from "@app/lib/models/user"; import { Workspace } from "@app/lib/models/workspace"; @@ -342,7 +341,8 @@ export class AgentMessageFeedback extends Model< declare updatedAt: CreationOptional; declare workspaceId: ForeignKey; - declare agentConfigurationId: ForeignKey; + declare agentConfigurationId: string; + declare agentConfigurationVersion: number; declare agentMessageId: ForeignKey; declare userId: ForeignKey; @@ -367,9 +367,13 @@ AgentMessageFeedback.init( allowNull: false, defaultValue: DataTypes.NOW, }, - workspaceId: { + agentConfigurationId: { + type: DataTypes.STRING, + allowNull: true, + }, + agentConfigurationVersion: { type: DataTypes.INTEGER, - allowNull: false, + allowNull: true, }, thumbDirection: { type: DataTypes.STRING, @@ -396,19 +400,21 @@ AgentMessageFeedback.init( { fields: ["agentConfigurationId", "agentMessageId", "userId"], unique: true, + name: "agent_message_feedbacks_agent_configuration_id_agent_message_id", }, ], } ); -AgentConfiguration.hasMany(AgentMessageFeedback, { +Workspace.hasMany(AgentMessageFeedback, { + foreignKey: { name: "workspaceId", allowNull: false }, onDelete: "RESTRICT", }); AgentMessage.hasMany(AgentMessageFeedback, { onDelete: "RESTRICT", }); User.hasMany(AgentMessageFeedback, { - onDelete: "RESTRICT", + onDelete: "SET NULL", }); export class Message extends Model< diff --git a/front/lib/resources/agent_message_feedback_resource.ts b/front/lib/resources/agent_message_feedback_resource.ts index ce1baeac3e74..540d3b6efe3f 100644 --- a/front/lib/resources/agent_message_feedback_resource.ts +++ b/front/lib/resources/agent_message_feedback_resource.ts @@ -1,17 +1,15 @@ -import type { - AgentConfigurationType, - AgentMessageType, - Result, -} from "@dust-tt/types"; +import type { AgentConfigurationType, Result, UserType } from "@dust-tt/types"; import { Err, Ok } from "@dust-tt/types"; -import type { CreationAttributes, Transaction } from "sequelize"; import type { Attributes, ModelStatic } from "sequelize"; +import type { CreationAttributes, Transaction } from "sequelize"; +import { Op } from "sequelize"; +import type { AgentMessageFeedbackDirection } from "@app/lib/api/assistant/conversation/feedbacks"; import type { Authenticator } from "@app/lib/auth"; +import type { AgentMessage } from "@app/lib/models/assistant/conversation"; import { AgentMessageFeedback } from "@app/lib/models/assistant/conversation"; import { BaseResource } from "@app/lib/resources/base_resource"; import type { ReadonlyAttributesType } from "@app/lib/resources/storage/types"; -import type { UserResource } from "@app/lib/resources/user_resource"; // Attributes are marked as read-only to reflect the stateless nature of our Resource. // This design will be moved up to BaseResource once we transition away from Sequelize. @@ -50,7 +48,7 @@ export class AgentMessageFeedbackResource extends BaseResource { const agentMessageFeedback = await AgentMessageFeedback.findAll({ where: { - agentConfigurationId: agentConfiguration.id, + agentConfigurationId: agentConfiguration.sId, }, order: [["id", "DESC"]], }); @@ -61,12 +59,12 @@ export class AgentMessageFeedbackResource extends BaseResource { const agentMessageFeedback = await AgentMessageFeedback.findOne({ where: { @@ -85,11 +83,34 @@ export class AgentMessageFeedbackResource extends BaseResource> { + static async fetchByUserAndAgentMessages( + user: UserType, + agentMessages: AgentMessage[] + ): Promise { + const agentMessageFeedback = await AgentMessageFeedback.findAll({ + where: { + userId: user.id, + agentMessageId: { + [Op.in]: agentMessages.map((m) => m.id), + }, + }, + }); + + return agentMessageFeedback.map( + (feedback) => + new AgentMessageFeedbackResource(AgentMessageFeedback, feedback.get()) + ); + } + + async updateContentAndThumbDirection( + content: string, + thumbDirection: AgentMessageFeedbackDirection + ): Promise> { try { await this.model.update( { content, + thumbDirection, }, { where: { diff --git a/front/lib/swr/conversations.ts b/front/lib/swr/conversations.ts index 317db5e7749e..824efc0b759b 100644 --- a/front/lib/swr/conversations.ts +++ b/front/lib/swr/conversations.ts @@ -9,6 +9,7 @@ import { useCallback, useMemo } from "react"; import type { Fetcher } from "swr"; import { deleteConversation } from "@app/components/assistant/conversation/lib"; +import type { AgentMessageFeedbackType } from "@app/lib/api/assistant/feedback"; import type { FetchConversationMessagesResponse } from "@app/lib/api/assistant/messages"; import { getVisualizationRetryMessage } from "@app/lib/client/visualization"; import { @@ -92,6 +93,30 @@ export function useConversationReactions({ }; } +export function useConversationFeedbacks({ + conversationId, + workspaceId, +}: { + conversationId: string; + workspaceId: string; +}) { + const conversationFeedbacksFetcher: Fetcher<{ + feedbacks: AgentMessageFeedbackType[]; + }> = fetcher; + + const { data, error, mutate } = useSWRWithDefaults( + `/api/w/${workspaceId}/assistant/conversations/${conversationId}/feedbacks`, + conversationFeedbacksFetcher + ); + + return { + feedbacks: useMemo(() => (data ? data.feedbacks : []), [data]), + isFeedbacksLoading: !error && !data, + isFeedbacksError: error, + mutateReactions: mutate, + }; +} + export function useConversationMessages({ conversationId, workspaceId, diff --git a/front/migrations/db/migration_122.sql b/front/migrations/db/migration_122.sql new file mode 100644 index 000000000000..6d9884b4a080 --- /dev/null +++ b/front/migrations/db/migration_122.sql @@ -0,0 +1,15 @@ +-- First drop the column, +ALTER TABLE "agent_message_feedbacks" DROP COLUMN "agentConfigurationId"; +ALTER TABLE "agent_message_feedbacks" ADD COLUMN "agentConfigurationId" VARCHAR(255); +ALTER TABLE "agent_message_feedbacks" ADD COLUMN "agentConfigurationVersion" INTEGER; + +ALTER TABLE "agent_message_feedbacks" ALTER COLUMN "workspaceId" SET NOT NULL; +ALTER TABLE "agent_message_feedbacks" ADD FOREIGN KEY ("workspaceId") REFERENCES "workspaces" ("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "agent_message_feedbacks" +DROP CONSTRAINT "agent_message_feedbacks_userId_fkey", +ADD CONSTRAINT "agent_message_feedbacks_userId_fkey" + FOREIGN KEY ("userId") + REFERENCES "users"("id") + ON DELETE SET NULL + ON UPDATE CASCADE; diff --git a/front/package-lock.json b/front/package-lock.json index e2b03265d0ec..c876e93bff8e 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -7,7 +7,7 @@ "dependencies": { "@auth0/nextjs-auth0": "^3.5.0", "@dust-tt/client": "file:../sdks/js", - "@dust-tt/sparkle": "^0.2.324", + "@dust-tt/sparkle": "^0.2.325", "@dust-tt/types": "file:../types", "@headlessui/react": "^1.7.7", "@heroicons/react": "^2.0.11", @@ -11487,9 +11487,10 @@ "link": true }, "node_modules/@dust-tt/sparkle": { - "version": "0.2.324", - "resolved": "https://registry.npmjs.org/@dust-tt/sparkle/-/sparkle-0.2.324.tgz", - "integrity": "sha512-lzUbYBgGDfcwcWUKLFKdKCoRvNP1xRpA70pzct4pOK7LSWKTbO8X6YbmPeTVXqdOe/pcXyu7dxqCCZ/GuK6yqw==", + "version": "0.2.325", + "resolved": "https://registry.npmjs.org/@dust-tt/sparkle/-/sparkle-0.2.325.tgz", + "integrity": "sha512-XVHdUxw74w7Mkv+P+ptlISD+huMTAqT3IpZ+xe1lK3ZgvqLKgpxH71xzdf75cCspgKh+Wswt5WTIAKgVj3Oirg==", + "license": "ISC", "dependencies": { "@emoji-mart/data": "^1.1.2", "@emoji-mart/react": "^1.1.1", diff --git a/front/package.json b/front/package.json index 1908349907f0..01932cc1c6ea 100644 --- a/front/package.json +++ b/front/package.json @@ -20,7 +20,7 @@ "dependencies": { "@auth0/nextjs-auth0": "^3.5.0", "@dust-tt/client": "file:../sdks/js", - "@dust-tt/sparkle": "^0.2.324", + "@dust-tt/sparkle": "^0.2.325", "@dust-tt/types": "file:../types", "@headlessui/react": "^1.7.7", "@heroicons/react": "^2.0.11", diff --git a/front/pages/api/w/[wId]/assistant/conversations/[cId]/feedbacks.ts b/front/pages/api/w/[wId]/assistant/conversations/[cId]/feedbacks.ts new file mode 100644 index 000000000000..f0ecbabc35db --- /dev/null +++ b/front/pages/api/w/[wId]/assistant/conversations/[cId]/feedbacks.ts @@ -0,0 +1,68 @@ +import type { WithAPIErrorResponse } from "@dust-tt/types"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import { getConversationWithoutContent } from "@app/lib/api/assistant/conversation"; +import { apiErrorForConversation } from "@app/lib/api/assistant/conversation/helper"; +import type { AgentMessageFeedbackType } from "@app/lib/api/assistant/feedback"; +import { getConversationFeedbacksForUser } from "@app/lib/api/assistant/feedback"; +import { withSessionAuthenticationForWorkspace } from "@app/lib/api/auth_wrappers"; +import type { Authenticator } from "@app/lib/auth"; +import { apiError } from "@app/logger/withlogging"; + +async function handler( + req: NextApiRequest, + res: NextApiResponse< + WithAPIErrorResponse<{ feedbacks: AgentMessageFeedbackType[] }> + >, + auth: Authenticator +): Promise { + if (!(typeof req.query.cId === "string")) { + return apiError(req, res, { + status_code: 400, + api_error: { + type: "invalid_request_error", + message: "Invalid query parameters, `cId` (string) is required.", + }, + }); + } + + const conversationId = req.query.cId; + const conversationRes = await getConversationWithoutContent( + auth, + conversationId + ); + + if (conversationRes.isErr()) { + return apiErrorForConversation(req, res, conversationRes.error); + } + + const conversation = conversationRes.value; + + switch (req.method) { + case "GET": + const feedbacksRes = await getConversationFeedbacksForUser( + auth, + conversation + ); + + if (feedbacksRes.isErr()) { + return apiErrorForConversation(req, res, feedbacksRes.error); + } + + const feedbacks = feedbacksRes.value; + + res.status(200).json({ feedbacks }); + return; + + default: + return apiError(req, res, { + status_code: 405, + api_error: { + type: "method_not_supported_error", + message: "The method passed is not supported, GET is expected.", + }, + }); + } +} + +export default withSessionAuthenticationForWorkspace(handler); diff --git a/front/pages/api/w/[wId]/assistant/conversations/[cId]/messages/[mId]/feedbacks/index.ts b/front/pages/api/w/[wId]/assistant/conversations/[cId]/messages/[mId]/feedbacks/index.ts new file mode 100644 index 000000000000..8c73c67ad54d --- /dev/null +++ b/front/pages/api/w/[wId]/assistant/conversations/[cId]/messages/[mId]/feedbacks/index.ts @@ -0,0 +1,135 @@ +import type { WithAPIErrorResponse } from "@dust-tt/types"; +import { isLeft } from "fp-ts/lib/Either"; +import * as t from "io-ts"; +import * as reporter from "io-ts-reporters"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import { getConversationWithoutContent } from "@app/lib/api/assistant/conversation"; +import type { AgentMessageFeedbackDirection } from "@app/lib/api/assistant/conversation/feedbacks"; +import { apiErrorForConversation } from "@app/lib/api/assistant/conversation/helper"; +import { + createOrUpdateMessageFeedback, + deleteMessageFeedback, +} from "@app/lib/api/assistant/feedback"; +import { withSessionAuthenticationForWorkspace } from "@app/lib/api/auth_wrappers"; +import type { Authenticator } from "@app/lib/auth"; +import { apiError } from "@app/logger/withlogging"; + +export const MessageFeedbackRequestBodySchema = t.type({ + thumbDirection: t.string, + feedbackContent: t.union([t.string, t.undefined, t.null]), +}); + +async function handler( + req: NextApiRequest, + res: NextApiResponse< + WithAPIErrorResponse<{ + success: boolean; + }> + >, + auth: Authenticator +): Promise { + const user = auth.getNonNullableUser(); + + if (!(typeof req.query.cId === "string")) { + return apiError(req, res, { + status_code: 400, + api_error: { + type: "invalid_request_error", + message: "Invalid query parameters, `cId` (string) is required.", + }, + }); + } + + const conversationId = req.query.cId; + const conversationRes = await getConversationWithoutContent( + auth, + conversationId + ); + + if (conversationRes.isErr()) { + return apiErrorForConversation(req, res, conversationRes.error); + } + + const conversation = conversationRes.value; + + if (!(typeof req.query.mId === "string")) { + return apiError(req, res, { + status_code: 400, + api_error: { + type: "invalid_request_error", + message: "Invalid query parameters, `mId` (string) is required.", + }, + }); + } + + const messageId = req.query.mId; + + switch (req.method) { + case "POST": + const bodyValidation = MessageFeedbackRequestBodySchema.decode(req.body); + if (isLeft(bodyValidation)) { + const pathError = reporter.formatValidationErrors(bodyValidation.left); + return apiError(req, res, { + status_code: 400, + api_error: { + type: "invalid_request_error", + message: `Invalid request body: ${pathError}`, + }, + }); + } + + const created = await createOrUpdateMessageFeedback(auth, { + messageId, + conversation, + user, + thumbDirection: bodyValidation.right + .thumbDirection as AgentMessageFeedbackDirection, + content: bodyValidation.right.feedbackContent || "", + }); + + if (created) { + res.status(200).json({ success: true }); + return; + } + return apiError(req, res, { + status_code: 400, + api_error: { + type: "invalid_request_error", + message: + "The message you're trying to give feedback to does not exist.", + }, + }); + + case "DELETE": + const deleted = await deleteMessageFeedback(auth, { + messageId, + conversation, + user, + }); + + if (deleted) { + res.status(200).json({ success: true }); + } + return apiError(req, res, { + status_code: 400, + api_error: { + type: "invalid_request_error", + message: + "The message you're trying to give feedback to does not exist.", + }, + }); + + default: + return apiError(req, res, { + status_code: 405, + api_error: { + type: "method_not_supported_error", + message: + "The method passed is not supported, POST or DELETE is expected.", + }, + }); + } +} + +export default withSessionAuthenticationForWorkspace(handler); diff --git a/front/poke/temporal/activities.ts b/front/poke/temporal/activities.ts index 2c1a451f0546..09322f52727f 100644 --- a/front/poke/temporal/activities.ts +++ b/front/poke/temporal/activities.ts @@ -34,6 +34,7 @@ import { } from "@app/lib/models/assistant/agent"; import { AgentMessage, + AgentMessageFeedback, Conversation, ConversationParticipant, Mention, @@ -232,6 +233,11 @@ export async function deleteConversationsActivity({ }); } + await AgentMessageFeedback.destroy({ + where: { agentMessageId: agentMessage.id }, + transaction: t, + }); + // Delete associated actions. await AgentBrowseAction.destroy({