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) {