Skip to content

Commit

Permalink
[AC-3752] feat: add function call to carousel adapter interface (#356)
Browse files Browse the repository at this point in the history
## Changes
- Added adapter interface for function call data to carousel

ticket: [AC-3752]

## Additional Notes
- 

## Checklist
Before requesting a code review, please check the following:
- [x] **[Required]** CI has passed all checks.
- [x] **[Required]** A self-review has been conducted to ensure there
are no minor mistakes.
- [x] **[Required]** Unnecessary comments/debugging code have been
removed.
- [x] **[Required]** All requirements specified in the ticket have been
accurately implemented.
- [x] Ensure the ticket has been updated with the sprint, status, and
story points.


[AC-3752]:
https://sendbird.atlassian.net/browse/AC-3752?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
  • Loading branch information
bang9 authored Sep 11, 2024
1 parent 5ca145c commit d989951
Show file tree
Hide file tree
Showing 8 changed files with 130 additions and 48 deletions.
40 changes: 36 additions & 4 deletions src/components/CustomMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 ?? '';
Expand Down Expand Up @@ -107,14 +109,20 @@ export default function CustomMessage(props: Props) {

const textMessageBody = <ParsedBotMessageBody text={message.message} tokens={tokens} sources={sources} />;

// commerce carousel message
if (messageExtension.commerceShopItems.isValid(message)) {
// carousel message
const carouselItems = getCarouselItems();
if (carouselItems.length > 0) {
return (
<BotMessageWithBodyInput
wideContainer
{...props}
bodyComponent={
<ShopItemsMessage message={message} streamingBody={<TypingBubble />} textBody={textMessageBody} />
<CarouselMessage
streaming={messageExtension.isStreaming(message)}
items={carouselItems}
streamingBody={<TypingBubble />}
textBody={textMessageBody}
/>
}
createdAt={message.createdAt}
messageFeedback={renderFeedbackButtons()}
Expand Down Expand Up @@ -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 [];
};
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 (
<SnapCarousel
Expand Down
43 changes: 31 additions & 12 deletions src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { StringSet } from '@uikit/ui/Label/stringSet';
import type { ToggleButtonProps } from './components/widget/WidgetToggleButton';
import { BotStyle } from './context/WidgetSettingContext';
import RefreshIcon from './icons/ic-refresh.svg';
import { SendbirdChatAICallbacks } from './types';
import { FunctionCallAdapter, SendbirdChatAICallbacks, WidgetCarouselItem } from './types';
import { noop } from './utils';

// Most of browsers use a 32-bit signed integer as the maximum value for z-index
Expand Down Expand Up @@ -68,18 +68,25 @@ export const DEFAULT_CONSTANT = {
messageInputControls: {
blockWhileBotResponding: 10000,
},
} satisfies Partial<Constant>;
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<Constant>;

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;

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<WidgetCarouselItem[]>;
};
};
}

interface ConstantFeatureFlags {
/**
* @public
Expand Down
6 changes: 6 additions & 0 deletions src/context/ConstantContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ export const ConstantStateProvider = (props: PropsWithChildren<ConstantContextPr
...props.customRefreshComponent?.style,
},
},
tools: {
functionCall: {
...initialState.tools.functionCall,
...props.tools?.functionCall,
},
},
}}
>
{props.children}
Expand Down
24 changes: 19 additions & 5 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<T> {
(params: FunctionCallAdapterParams): T;
}
4 changes: 2 additions & 2 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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')
: [];
Expand Down
45 changes: 29 additions & 16 deletions src/utils/messageExtension.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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),
}));
},
},
};

Expand Down
2 changes: 1 addition & 1 deletion src/utils/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down

0 comments on commit d989951

Please sign in to comment.