Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[AC-3752] feat: add function call to carousel adapter interface #356

Merged
merged 4 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 }));
liamcho marked this conversation as resolved.
Show resolved Hide resolved
}

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);
liamcho marked this conversation as resolved.
Show resolved Hide resolved
}

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 @@ -208,7 +215,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 @@ -256,6 +263,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 @@ -108,6 +108,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;
liamcho marked this conversation as resolved.
Show resolved Hide resolved
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