From 40ceb927c59b621509a2d551de299b73a92fbca1 Mon Sep 17 00:00:00 2001 From: Aric Lasry Date: Wed, 15 Nov 2023 19:10:52 +0100 Subject: [PATCH] Working end to end but a lot of things to review / clean up. --- .../conversation/ContentFragment.tsx | 4 +- .../assistant/conversation/InputBar.tsx | 13 +- front/lib/api/assistant/conversation.ts | 24 ++- front/lib/api/assistant/types.ts | 4 + front/lib/models/assistant/conversation.ts | 36 +++++ .../conversations/[cId]/content_fragments.ts | 8 + .../w/[wId]/assistant/conversations/index.ts | 7 + .../[cId]/content_fragment/index.ts | 143 ++++++++++++++++++ .../w/[wId]/assistant/conversations/index.ts | 8 + front/pages/w/[wId]/assistant/[cId]/index.tsx | 46 +++++- front/pages/w/[wId]/assistant/new.tsx | 26 +++- front/types/assistant/conversation.ts | 9 ++ 12 files changed, 317 insertions(+), 11 deletions(-) create mode 100644 front/pages/api/w/[wId]/assistant/conversations/[cId]/content_fragment/index.ts diff --git a/front/components/assistant/conversation/ContentFragment.tsx b/front/components/assistant/conversation/ContentFragment.tsx index 67be5ea7406df..0e7e1b0d1b9cc 100644 --- a/front/components/assistant/conversation/ContentFragment.tsx +++ b/front/components/assistant/conversation/ContentFragment.tsx @@ -37,8 +37,8 @@ export function ContentFragment({ user={user} conversationId={conversation.sId} messageId={message.sId} - pictureUrl={""} - name={"sisi"} + pictureUrl={message.context.profilePictureUrl} + name={message.context.fullName} enableEmojis={false} reactions={[]} > diff --git a/front/components/assistant/conversation/InputBar.tsx b/front/components/assistant/conversation/InputBar.tsx index 13cc1e854b2b9..f4e4af5382afb 100644 --- a/front/components/assistant/conversation/InputBar.tsx +++ b/front/components/assistant/conversation/InputBar.tsx @@ -29,7 +29,6 @@ import { mutate } from "swr"; import { AssistantPicker } from "@app/components/assistant/AssistantPicker"; import { GenerationContext } from "@app/components/assistant/conversation/GenerationContextProvider"; import { SendNotificationsContext } from "@app/components/sparkle/Notification"; -import { PostContentFragmentRequestBody } from "@app/lib/api/assistant/types"; import { compareAgentsForSort } from "@app/lib/assistant"; import { useAgentConfigurations } from "@app/lib/swr"; import { classNames, subFilter } from "@app/lib/utils"; @@ -381,8 +380,14 @@ export function AssistantInputBar({ content = content.trim(); content = content.replace(/\u200B/g, ""); - let contentFragment: PostContentFragmentRequestBody | undefined = - undefined; + let contentFragment: + | { + title: string; + content: string; + url: string | null; + contentType: string; + } + | undefined = undefined; if (contentFragmentBody && contentFragmentFilename) { contentFragment = { title: contentFragmentFilename, @@ -496,6 +501,8 @@ export function AssistantInputBar({ ref={fileInputRef} style={{ display: "none" }} onChange={async (e) => { + // focus on the input text after the file selection interaction is over + inputRef.current?.focus(); const file = e?.target?.files?.[0]; if (!file) return; await handleFileUpload(file); diff --git a/front/lib/api/assistant/conversation.ts b/front/lib/api/assistant/conversation.ts index 94ff2de29f1ee..27e0eaacad497 100644 --- a/front/lib/api/assistant/conversation.ts +++ b/front/lib/api/assistant/conversation.ts @@ -41,6 +41,7 @@ import logger from "@app/logger/logger"; import { AgentMessageType, ContentFragmentContentType, + ContentFragmentContextType, ContentFragmentType, ConversationType, ConversationVisibility, @@ -339,6 +340,13 @@ function renderContentFragment({ content: contentFragment.content, url: contentFragment.url, contentType: contentFragment.contentType, + context:{ + timezone: contentFragment.userContextTimezone, + profilePictureUrl: contentFragment.userContextProfilePictureUrl, + fullName: contentFragment.userContextFullName, + email: contentFragment.userContextEmail, + username: contentFragment.userContextUsername, + } }; } @@ -1675,12 +1683,14 @@ export async function postNewContentFragment( content, url, contentType, + context, }: { conversation: ConversationType; title: string; content: string; url: string | null; contentType: ContentFragmentContentType; + context: ContentFragmentContextType; } ): Promise { const owner = auth.workspace(); @@ -1694,7 +1704,19 @@ export async function postNewContentFragment( await getConversationRankVersionLock(conversation, t); const contentFragmentRow = await ContentFragment.create( - { content, title, url, contentType }, + { + content, + title, + url, + contentType, + userId: auth.user()?.id, + userContextProfilePictureUrl: context.profilePictureUrl, + userContextEmail: context.email, + userContextFullName: context.fullName, + userContextTimezone: context.timezone, + userContextUsername: context.username, + + }, { transaction: t } ); const nextMessageRank = diff --git a/front/lib/api/assistant/types.ts b/front/lib/api/assistant/types.ts index aa70081b1cab0..f6bb8ba5c65c5 100644 --- a/front/lib/api/assistant/types.ts +++ b/front/lib/api/assistant/types.ts @@ -8,6 +8,10 @@ export const PostContentFragmentRequestBodySchema = t.type({ t.literal("slack_thread_content"), t.literal("file_attachment"), ]), + context: t.type({ + timezone: t.string, + profilePictureUrl: t.union([t.string, t.null]), + }), }); export type PostContentFragmentRequestBody = t.TypeOf< diff --git a/front/lib/models/assistant/conversation.ts b/front/lib/models/assistant/conversation.ts index 0f7f8534075f7..4306b7c3c3aa7 100644 --- a/front/lib/models/assistant/conversation.ts +++ b/front/lib/models/assistant/conversation.ts @@ -351,6 +351,15 @@ export class ContentFragment extends Model< declare content: string; declare url: string | null; declare contentType: ContentFragmentContentType; + + declare userContextUsername: string|null; + declare userContextTimezone: string|null; + declare userContextFullName: string | null; + declare userContextEmail: string | null; + declare userContextProfilePictureUrl: string | null; + + + declare userId: ForeignKey | null; } ContentFragment.init( @@ -386,6 +395,26 @@ ContentFragment.init( type: DataTypes.STRING, allowNull: false, }, + userContextProfilePictureUrl: { + type: DataTypes.STRING, + allowNull: true, + }, + userContextUsername: { + type: DataTypes.STRING, + allowNull: true, + }, + userContextTimezone: { + type: DataTypes.STRING, + allowNull: true, + }, + userContextFullName: { + type: DataTypes.STRING, + allowNull: true, + }, + userContextEmail: { + type: DataTypes.STRING, + allowNull: true, + }, }, { modelName: "content_fragment", @@ -393,6 +422,13 @@ ContentFragment.init( } ); +User.hasMany(ContentFragment, { + foreignKey: { name: "userId", allowNull: true }, // null = message is not associated with a user +}); +ContentFragment.belongsTo(User, { + foreignKey: { name: "userId", allowNull: true }, +}); + export class Message extends Model< InferAttributes, InferCreationAttributes diff --git a/front/pages/api/v1/w/[wId]/assistant/conversations/[cId]/content_fragments.ts b/front/pages/api/v1/w/[wId]/assistant/conversations/[cId]/content_fragments.ts index a4c7efb789594..0d0bc9a64eb8c 100644 --- a/front/pages/api/v1/w/[wId]/assistant/conversations/[cId]/content_fragments.ts +++ b/front/pages/api/v1/w/[wId]/assistant/conversations/[cId]/content_fragments.ts @@ -88,6 +88,14 @@ async function handler( content, url, contentType, + context: { + // @todo(aric) PR time: do we want to put the user context here when it's coming from the API (basically from Slack today?) + username: null, + timezone: null, + fullName: null, + email: null, + profilePictureUrl: null, + } }); res.status(200).json({ contentFragment }); diff --git a/front/pages/api/v1/w/[wId]/assistant/conversations/index.ts b/front/pages/api/v1/w/[wId]/assistant/conversations/index.ts index 7064ce9e340b6..73f08ae0dd531 100644 --- a/front/pages/api/v1/w/[wId]/assistant/conversations/index.ts +++ b/front/pages/api/v1/w/[wId]/assistant/conversations/index.ts @@ -124,6 +124,13 @@ async function handler( content: contentFragment.content, url: contentFragment.url, contentType: contentFragment.contentType, + context:{ + username: null, + timezone:null, + fullName: null, + email: null, + profilePictureUrl: contentFragment.context.profilePictureUrl, + } }); newContentFragment = cf; diff --git a/front/pages/api/w/[wId]/assistant/conversations/[cId]/content_fragment/index.ts b/front/pages/api/w/[wId]/assistant/conversations/[cId]/content_fragment/index.ts new file mode 100644 index 0000000000000..5351afe1efc2c --- /dev/null +++ b/front/pages/api/w/[wId]/assistant/conversations/[cId]/content_fragment/index.ts @@ -0,0 +1,143 @@ +import { isLeft } from "fp-ts/lib/Either"; +import * as t from "io-ts"; +import * as reporter from "io-ts-reporters"; +import { NextApiRequest, NextApiResponse } from "next"; + +import { getConversation, postNewContentFragment } from "@app/lib/api/assistant/conversation"; +import { PostContentFragmentRequestBodySchema } from "@app/lib/api/assistant/types"; +import { Authenticator, getSession } from "@app/lib/auth"; +import { ReturnedAPIErrorType } from "@app/lib/error"; +import { apiError, withLogging } from "@app/logger/withlogging"; +import { ContentFragmentType } from "@app/types/assistant/conversation"; + +export const PostMessagesRequestBodySchema = t.type({ + content: t.string, + mentions: t.array( + t.union([ + t.type({ configurationId: t.string }), + t.type({ + provider: t.string, + providerId: t.string, + }), + ]) + ), + context: t.type({ + timezone: t.string, + profilePictureUrl: t.union([t.string, t.null]), + }), +}); + +async function handler( + req: NextApiRequest, + res: NextApiResponse<{ contentFragment: ContentFragmentType; } | ReturnedAPIErrorType> +): Promise { + const session = await getSession(req, res); + const auth = await Authenticator.fromSession( + session, + req.query.wId as string + ); + + const owner = auth.workspace(); + if (!owner) { + return apiError(req, res, { + status_code: 404, + api_error: { + type: "workspace_not_found", + message: "The workspace you're trying to modify was not found.", + }, + }); + } + + const user = auth.user(); + if (!user) { + return apiError(req, res, { + status_code: 404, + api_error: { + type: "workspace_user_not_found", + message: "Could not find the user of the current session.", + }, + }); + } + + if (!auth.isUser()) { + return apiError(req, res, { + status_code: 403, + api_error: { + type: "workspace_auth_error", + message: + "Only users of the current workspace can update chat sessions.", + }, + }); + } + 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 conversation = await getConversation(auth, conversationId); + if (!conversation) { + return apiError(req, res, { + status_code: 404, + api_error: { + type: "conversation_not_found", + message: "Conversation not found.", + }, + }); + } + + switch (req.method) { + case "POST": + const bodyValidation = PostContentFragmentRequestBodySchema.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 contentFragmentPayload = bodyValidation.right; + + const contentFragment = await postNewContentFragment(auth, { + conversation, + title: contentFragmentPayload.title, + content: contentFragmentPayload.content, + url: contentFragmentPayload.url, + contentType: contentFragmentPayload.contentType, + context: { + timezone: contentFragmentPayload.context.timezone, + username: user.username, + fullName: user.fullName, + email: user.email, + profilePictureUrl: contentFragmentPayload.context.profilePictureUrl, + + }, + }); + + + res.status(200).json({ contentFragment }); + return; + + default: + return apiError(req, res, { + status_code: 405, + api_error: { + type: "method_not_supported_error", + message: "The method passed is not supported, POST is expected.", + }, + }); + } +} + +export default withLogging(handler); diff --git a/front/pages/api/w/[wId]/assistant/conversations/index.ts b/front/pages/api/w/[wId]/assistant/conversations/index.ts index 46724c53e6dcf..df6a6a85f72d5 100644 --- a/front/pages/api/w/[wId]/assistant/conversations/index.ts +++ b/front/pages/api/w/[wId]/assistant/conversations/index.ts @@ -141,6 +141,14 @@ async function handler( content: contentFragment.content, url: contentFragment.url, contentType: contentFragment.contentType, + context: { + timezone: contentFragment.context.timezone, + username: user.username, + fullName: user.fullName, + email: user.email, + profilePictureUrl: contentFragment.context.profilePictureUrl, + + }, }); newContentFragment = cf; diff --git a/front/pages/w/[wId]/assistant/[cId]/index.tsx b/front/pages/w/[wId]/assistant/[cId]/index.tsx index b6938097b8c2c..74741fd85add1 100644 --- a/front/pages/w/[wId]/assistant/[cId]/index.tsx +++ b/front/pages/w/[wId]/assistant/[cId]/index.tsx @@ -83,7 +83,51 @@ export default function AssistantConversation({ conversationId, workspaceId: owner.sId, }); - const handleSubmit = async (input: string, mentions: MentionType[]) => { + const handleSubmit = async ( + input: string, + mentions: MentionType[], + contentFragment?: { + title: string; + content: string; + url: string; + contentType: "slack_thread_content" | "file_attachment"; + } + ) => { + // Create a new content fragment. + if (contentFragment) { + const mcfRes = await fetch( + `/api/w/${owner.sId}/assistant/conversations/${conversationId}/content_fragment`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title: contentFragment.title, + content: contentFragment.content, + url: contentFragment.url, + contentType: contentFragment.contentType, + context: { + timezone: + Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + profilePictureUrl: user.image, + }, + }), + } + ); + + if (!mcfRes.ok) { + const data = await mcfRes.json(); + console.error("Error creating content fragment", data); + sendNotification({ + title: "Your file could not be uploaded", + description: data.error.message || "Please try again or contact us.", + type: "error", + }); + return; + } + } + // Create a new user message. const mRes = await fetch( `/api/w/${owner.sId}/assistant/conversations/${conversationId}/messages`, diff --git a/front/pages/w/[wId]/assistant/new.tsx b/front/pages/w/[wId]/assistant/new.tsx index ee4f28f39b3bc..bfc5246b0a317 100644 --- a/front/pages/w/[wId]/assistant/new.tsx +++ b/front/pages/w/[wId]/assistant/new.tsx @@ -24,7 +24,6 @@ import { import { AssistantSidebarMenu } from "@app/components/assistant/conversation/SidebarMenu"; import AppLayout from "@app/components/sparkle/AppLayout"; import { SendNotificationsContext } from "@app/components/sparkle/Notification"; -import { PostContentFragmentRequestBody } from "@app/lib/api/assistant/types"; import { compareAgentsForSort } from "@app/lib/assistant"; import { Authenticator, getSession, getUserFromSession } from "@app/lib/auth"; import { useSubmitFunction } from "@app/lib/client/utils"; @@ -103,7 +102,12 @@ export default function AssistantNew({ async ( input: string, mentions: MentionType[], - contentFragment?: PostContentFragmentRequestBody + contentFragment?: { + title: string; + content: string; + url: string; + contentType: "slack_thread_content" | "file_attachment"; + } ) => { const body: t.TypeOf = { title: null, @@ -116,7 +120,16 @@ export default function AssistantNew({ }, mentions, }, - contentFragment: contentFragment, + contentFragment: contentFragment + ? { + ...contentFragment, + context: { + timezone: + Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + profilePictureUrl: user.image, + }, + } + : undefined, }; // Create new conversation and post the initial message at the same time. @@ -388,7 +401,12 @@ function StartHelperConversationButton({ handleSubmit: ( input: string, mentions: MentionType[], - contentFragment?: PostContentFragmentRequestBody + contentFragment?: { + title: string; + content: string; + url: string; + contentType: "slack_thread_content" | "file_attachment"; + } ) => Promise; variant?: "primary" | "secondary"; size?: "sm" | "xs"; diff --git a/front/types/assistant/conversation.ts b/front/types/assistant/conversation.ts index 8693b61b97a3c..3a392fc623ca1 100644 --- a/front/types/assistant/conversation.ts +++ b/front/types/assistant/conversation.ts @@ -124,6 +124,14 @@ export function isAgentMessageType( /** * Content Fragments */ +export type ContentFragmentContextType = { + username: string|null; + timezone: string|null; + fullName: string | null; + email: string | null; + profilePictureUrl: string | null; +}; + export type ContentFragmentContentType = | "slack_thread_content" | "file_attachment"; @@ -140,6 +148,7 @@ export type ContentFragmentType = { content: string; url: string | null; contentType: ContentFragmentContentType; + context: ContentFragmentContextType; }; export function isContentFragmentType(