diff --git a/src/components/CustomMessage.tsx b/src/components/CustomMessage.tsx index 59ab084ab..205b12884 100644 --- a/src/components/CustomMessage.tsx +++ b/src/components/CustomMessage.tsx @@ -10,13 +10,14 @@ import CurrentUserMessage from './CurrentUserMessage'; import CustomMessageBody from './CustomMessageBody'; import CustomTypingIndicatorBubble from './CustomTypingIndicatorBubble'; import FileMessage from './FileMessage'; +import { CarouselMessage } from './messages/CarouselMessage'; import FormMessage from './messages/FormMessage'; -import { ShopItemsMessage } from './messages/ShopItemsMessage'; import ParsedBotMessageBody from './ParsedBotMessageBody'; import UserMessageWithBodyInput from './UserMessageWithBodyInput'; import { useConstantState } from '../context/ConstantContext'; import { useWidgetSession } from '../context/WidgetSettingContext'; import { TypingBubble } from '../foundation/components/TypingBubble'; +import { WidgetCarouselItem } from '../types'; import { getSourceFromMetadata, parseTextMessage, Token } from '../utils'; import { messageExtension } from '../utils/messageExtension'; import { isSentBy } from '../utils/messages'; @@ -34,6 +35,7 @@ export default function CustomMessage(props: Props) { const { replacementTextList, enableEmojiFeedback, botStudioEditProps = {} } = useConstantState(); const { userId: currentUserId } = useWidgetSession(); const { botInfo } = botStudioEditProps; + const getCarouselItems = useCarouselItems(message); const botUserId = botUser?.userId; const botProfileUrl = botInfo?.profileUrl ?? botUser?.profileUrl ?? ''; @@ -107,14 +109,20 @@ export default function CustomMessage(props: Props) { const textMessageBody = ; - // commerce carousel message - if (messageExtension.commerceShopItems.isValid(message)) { + // carousel message + const carouselItems = getCarouselItems(); + if (carouselItems.length > 0) { return ( } textBody={textMessageBody} /> + } + textBody={textMessageBody} + /> } createdAt={message.createdAt} messageFeedback={renderFeedbackButtons()} @@ -148,3 +156,27 @@ export default function CustomMessage(props: Props) { return <>; } + +function useCarouselItems(message: BaseMessage) { + const { tools } = useConstantState(); + return () => { + if (messageExtension.commerceShopItems.isValid(message)) { + return messageExtension.commerceShopItems.getValidItems(message); + } + + const functionCalls = messageExtension.functionCalls.getAdapterParams(message); + if (functionCalls.length > 0 && tools.functionCall.carouselAdapter) { + try { + return functionCalls + .map((fn) => tools.functionCall.carouselAdapter?.(fn)) + .flat() + .filter((it): it is WidgetCarouselItem => !!it); + } catch (err) { + console.warn('Failed to run carousel adapter:', err); + return []; + } + } + + return []; + }; +} diff --git a/src/components/messages/ShopItemsMessage.tsx b/src/components/messages/CarouselMessage.tsx similarity index 89% rename from src/components/messages/ShopItemsMessage.tsx rename to src/components/messages/CarouselMessage.tsx index dc96c6f3f..d313b905d 100644 --- a/src/components/messages/ShopItemsMessage.tsx +++ b/src/components/messages/CarouselMessage.tsx @@ -1,12 +1,11 @@ -import { UserMessage } from '@sendbird/chat/message'; import { ReactNode } from 'react'; import styled, { useTheme } from 'styled-components'; import { useConstantState } from '../../context/ConstantContext'; import ChevronLeft from '../../icons/chevron-left.svg'; import ChevronRight from '../../icons/chevron-right.svg'; +import { WidgetCarouselItem } from '../../types'; import { openURL } from '../../utils'; -import { messageExtension } from '../../utils/messageExtension'; import { SnapCarousel } from '../ui/SnapCarousel'; const listPadding = 16; @@ -70,20 +69,19 @@ const Button = styled.button<{ direction: 'left' | 'right' }>(({ theme, directio })); type Props = { - message: UserMessage; + streaming: boolean; textBody: ReactNode; streamingBody: ReactNode; + items: WidgetCarouselItem[]; }; -export const ShopItemsMessage = ({ message, textBody, streamingBody }: Props) => { +export const CarouselMessage = ({ streaming, textBody, streamingBody, items }: Props) => { const theme = useTheme(); const { isMobileView } = useConstantState(); - const items = messageExtension.commerceShopItems.getValidItems(message); - const isStreaming = messageExtension.isStreaming(message); - const shouldRenderCarouselBody = isStreaming || items.length > 0; + const shouldRenderCarouselBody = streaming || items.length > 0; const shouldRenderButtons = !isMobileView && items.length >= 2; const renderCarouselBody = () => { - if (isStreaming) return streamingBody; + if (streaming) return streamingBody; return ( ; + tools: { + functionCall: { + carouselAdapter({ response }) { + if (isMealsResponse(response)) { + return response.meals.map((it) => ({ title: it.strMeal, url: '', featured_image: it.strMealThumb })); + } -type ConfigureSession = (sdk: SendbirdChat | SendbirdGroupChat | SendbirdOpenChat) => SessionHandler; + return []; + }, + }, + }, +} satisfies Partial; -type MessageData = { - suggested_replies?: string[]; -}; +// TODO: Remove this function when the Demo is finished +function isMealsResponse(response: unknown): response is { meals: { strMeal: string; strMealThumb: string }[] } { + return !!response && typeof response === 'object' && 'meals' in response && Array.isArray(response.meals); +} -type FirstMessageItem = { - data: MessageData; - message: string; -}; +type ConfigureSession = (sdk: SendbirdChat | SendbirdGroupChat | SendbirdOpenChat) => SessionHandler; type MatchString = string; type ReplaceString = string; @@ -123,7 +130,7 @@ export interface OnWidgetOpenStateChangeParams { value: boolean; } -export interface Constant extends ConstantFeatureFlags { +export interface Constant extends ConstantFeatureFlags, ConstantAIFeatures { /** * @public * @description User nickname to be used in the widget. @@ -213,7 +220,7 @@ export interface Constant extends ConstantFeatureFlags { * @private * @description First message data to be sent when the widget is opened. */ - firstMessageData: FirstMessageItem[]; + firstMessageData: { data: { suggested_replies?: string[] }; message: string }[]; /** * @private * @description Custom API host. @@ -261,6 +268,18 @@ export interface Constant extends ConstantFeatureFlags { onWidgetOpenStateChange?: (params: OnWidgetOpenStateChangeParams) => void; } +interface ConstantAIFeatures { + /** + * @public + * @description tools to be used in the widget. + * */ + tools: { + functionCall: { + carouselAdapter?: FunctionCallAdapter; + }; + }; +} + interface ConstantFeatureFlags { /** * @public diff --git a/src/context/ConstantContext.tsx b/src/context/ConstantContext.tsx index 17141f553..046377e50 100644 --- a/src/context/ConstantContext.tsx +++ b/src/context/ConstantContext.tsx @@ -109,6 +109,12 @@ export const ConstantStateProvider = (props: PropsWithChildren {props.children} diff --git a/src/types.ts b/src/types.ts index 0fd1c669a..2cb372a53 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,10 +7,8 @@ export interface SendbirdChatAICallbacks { onWidgetSettingFailure?: (error: Error) => void; } -export interface FunctionCallRequestInfo { - headers: { - 'Api-Token': string; - }; +export interface FunctionCallRequest { + headers: object; method: string; query_params: object; request_body: object; @@ -19,7 +17,23 @@ export interface FunctionCallRequestInfo { export interface FunctionCallData { name: string; - request: FunctionCallRequestInfo; + request: FunctionCallRequest; response_text: string; status_code: number; } + +export interface WidgetCarouselItem { + title: string; + url: string; + featured_image: string; +} + +export interface FunctionCallAdapterParams { + name: string; + request: FunctionCallRequest; + response: unknown; +} + +export interface FunctionCallAdapter { + (params: FunctionCallAdapterParams): T; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 1089273ed..af5650674 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,7 +1,7 @@ import type SendbirdChat from '@sendbird/chat'; import { BaseMessage } from '@sendbird/chat/message'; -import { parseMessageDataSafely } from './messages'; +import { jsonParseSafely } from './messages'; import { Source } from '../components/SourceContainer'; import { widgetServiceName } from '../const'; @@ -89,7 +89,7 @@ function isDelimiterIndex(index: number, inputString: string, delimiter: string) } export function getSourceFromMetadata(message: BaseMessage) { - const data: MessageMetaData = parseMessageDataSafely(message.data); + const data: MessageMetaData = jsonParseSafely(message.data); const sources: Source[] = Array.isArray(data['metadatas']) ? data['metadatas']?.filter((source) => source.source_type !== 'file') : []; diff --git a/src/utils/messageExtension.ts b/src/utils/messageExtension.ts index 1b79d709f..ba350e927 100644 --- a/src/utils/messageExtension.ts +++ b/src/utils/messageExtension.ts @@ -1,17 +1,12 @@ -import { BaseMessage, UserMessage } from '@sendbird/chat/message'; +import { BaseMessage } from '@sendbird/chat/message'; import { extractUrls } from './index'; -import { parseMessageDataSafely } from './messages'; - -export interface CommerceShopItem { - title: string; - url: string; - featured_image: string; -} +import { jsonParseSafely } from './messages'; +import { FunctionCallAdapterParams, FunctionCallData, WidgetCarouselItem } from '../types'; export const messageExtension = { isStreaming(message: BaseMessage) { - const data = parseMessageDataSafely(message.data); + const data = jsonParseSafely(message.data); if (typeof data === 'object') { return Boolean(data['stream']); } else { @@ -20,29 +15,47 @@ export const messageExtension = { }, isBotWelcomeMsg(message: BaseMessage, botId: string | null) { if ((message.isUserMessage() || message.isFileMessage()) && message.sender.userId === botId) { - const data = parseMessageDataSafely(message.data); + const data = jsonParseSafely(message.data); // Note: respond_mesg_id and stream is only set when the bot message is a response to a user message. return !data?.respond_mesg_id && !data?.stream; } return false; }, + isInputDisabled(message: BaseMessage | null) { + return !!message?.extendedMessagePayload?.disable_chat_input; + }, commerceShopItems: { - isValid(message: UserMessage): boolean { + isValid(message: BaseMessage): boolean { return ((message.extendedMessagePayload?.commerce_shop_items ?? []) as unknown[]).length > 0; }, - getItems(message: UserMessage): CommerceShopItem[] { - return (message.extendedMessagePayload?.commerce_shop_items ?? []) as CommerceShopItem[]; + getItems(message: BaseMessage): WidgetCarouselItem[] { + return (message.extendedMessagePayload?.commerce_shop_items ?? []) as WidgetCarouselItem[]; }, - getValidItems(message: UserMessage): CommerceShopItem[] { + getValidItems(message: BaseMessage): WidgetCarouselItem[] { + if (!message.isUserMessage()) return []; const urls = extractUrls(message.message); return this.getItems(message) .filter((it) => urls.includes(it.url)) .sort((a, b) => urls.indexOf(a.url) - urls.indexOf(b.url)); }, }, - isInputDisabled(message: BaseMessage | null) { - return !!message?.extendedMessagePayload?.disable_chat_input; + functionCalls: { + parse(message: BaseMessage) { + const data = jsonParseSafely(message.data); + return data.function_calls ?? []; + }, + isFunctionCall(obj: unknown): obj is FunctionCallData { + return !!obj && typeof obj === 'object' && 'name' in obj && 'request' in obj && 'response_text' in obj; + }, + getAdapterParams(message: BaseMessage): FunctionCallAdapterParams[] { + const functionCalls = this.parse(message); + return functionCalls.filter(this.isFunctionCall).map((fn: FunctionCallData) => ({ + name: fn.name, + request: fn.request, + response: jsonParseSafely(fn.response_text), + })); + }, }, }; diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 94be19e76..d51a213ee 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -33,7 +33,7 @@ export function isSentBy(message: BaseMessage, userId?: string | null) { return getSenderUserIdFromMessage(message) === userId; } -export function parseMessageDataSafely(messageData: string) { +export function jsonParseSafely(messageData: string) { try { return JSON.parse(messageData === '' ? '{}' : messageData); } catch (error) {