diff --git a/package-lock.json b/package-lock.json index c21bf873..0cd2583d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19206,9 +19206,9 @@ } }, "node_modules/vite/node_modules/rollup": { - "version": "3.29.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", - "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", + "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", "dev": true, "bin": { "rollup": "dist/bin/rollup" diff --git a/packages/chat-core/src/index.ts b/packages/chat-core/src/index.ts index 6cff1555..bac45269 100644 --- a/packages/chat-core/src/index.ts +++ b/packages/chat-core/src/index.ts @@ -127,6 +127,10 @@ export interface BotResponseMetadata { * Whether the current conversation has been marked as incomprehension. */ incomprehension?: boolean; + /** + * Upload URL's + */ + uploadUrls: UploadUrl[]; /** * Whether the client should poll for more bot responses. */ @@ -176,6 +180,20 @@ export interface BotMessage { selectedChoiceId?: string; } +/** + * The upload destination for handling conversing with files + */ +export interface UploadUrl { + /** + * The URL of the upload + */ + url: string; + /** + * The ID of the upload + */ + uploadId: string; +} + /** * A choices to show to the user. */ @@ -401,6 +419,14 @@ export interface StructuredRequest { * The slots to populate */ slots?: SlotsRecordOrArray; + /** + * Upload ID + */ + uploadId?: string; + /** + * Upload utterance + */ + utterance?: string; /** * @hidden * This is used internally to indicate that the client is polling the bot for more data. diff --git a/packages/chat-widget/src/icons.tsx b/packages/chat-widget/src/icons.tsx index 3c62c79e..d1450c30 100644 --- a/packages/chat-widget/src/icons.tsx +++ b/packages/chat-widget/src/icons.tsx @@ -24,14 +24,30 @@ export const ChatIcon = (): ReactNode => ( ); +// eslint-disable-next-line jsdoc/require-returns +/** @hidden @internal */ +export const CheckIcon = (): ReactNode => ( + + + +); + // eslint-disable-next-line jsdoc/require-returns /** @hidden @internal */ export const SendIcon = (): ReactNode => ( - + ); +// eslint-disable-next-line jsdoc/require-returns +/** @hidden @internal */ +export const AddPhotoIcon = (): ReactNode => ( + + + +); + // eslint-disable-next-line jsdoc/require-returns /** @hidden @internal */ export const DownloadIcon = (): ReactNode => ( diff --git a/packages/chat-widget/src/index.tsx b/packages/chat-widget/src/index.tsx index fcc03eb2..9ada2ee1 100644 --- a/packages/chat-widget/src/index.tsx +++ b/packages/chat-widget/src/index.tsx @@ -18,7 +18,9 @@ import { ThemeProvider } from "@emotion/react"; import { useChat, type ChatHook } from "@nlxai/chat-react"; import { type Response, + type BotResponse, type ConversationHandler, + type UploadUrl, getCurrentExpirationTimestamp, } from "@nlxai/chat-core"; import { @@ -26,9 +28,11 @@ import { MinimizeIcon, ChatIcon, SendIcon, + CheckIcon, + AddPhotoIcon, ErrorOutlineIcon, } from "./icons"; -import { last, equals } from "ramda"; +import { last, equals, findLast } from "ramda"; import * as constants from "./ui/constants"; import { type Props, @@ -352,6 +356,67 @@ const isInputDisabled = (responses: Response[]): boolean => { return new URLSearchParams(payload).get("nlx:input-disabled") === "true"; }; +const findActiveUpload = (responses: Response[]): UploadUrl | null => { + const lastBotResponse = findLast( + (response): response is BotResponse => response.type === "bot", + responses, + ); + if (lastBotResponse == null) { + return null; + } + return lastBotResponse.payload.metadata?.uploadUrls?.[0] ?? null; +}; + +interface ImageUploadProps { + upload: UploadUrl; +} + +type UploadStatus = "empty" | "uploading" | "uploaded"; + +const ImageUpload: FC = (props) => { + const [status, setStatus] = useState("empty"); + + if (status === "uploading") { + return ; + } + + if (status === "uploaded") { + + + ; + } + + return ( + + + ); +}; + /** * Hook to get the ConversationHandler for the widget. * This may be called before the Widget has been created. @@ -546,9 +611,17 @@ export const Widget = forwardRef(function Widget(props, ref) { scrollToBottom(); }, [chat.responses]); + const activeUpload = findActiveUpload(chat.responses); + const submit = chat.inputValue.replace(/ /gi, "") !== "" && (() => { + if (activeUpload !== null) { + chat.conversationHandler.sendStructured({ + uploadId: activeUpload.uploadId, + utterance: chat.inputValue, + }); + } chat.conversationHandler.sendText(chat.inputValue); chat.setInputValue(""); }); @@ -652,6 +725,9 @@ export const Widget = forwardRef(function Widget(props, ref) { }} /> + {activeUpload == null ? null : ( + + )} { diff --git a/packages/chat-widget/src/ui/components.tsx b/packages/chat-widget/src/ui/components.tsx index 3fdc9aa4..47e9784c 100644 --- a/packages/chat-widget/src/ui/components.tsx +++ b/packages/chat-widget/src/ui/components.tsx @@ -358,6 +358,68 @@ export const IconButton = styled.button<{ ${hoverBg} `; +export const UploadIconLabel = styled.label<{ + /** @hidden @internal */ + disabled?: boolean; +}>` + height: 35px; + width: 35px; + border-radius: 18px; + flex: none; + padding: 8px; + font-size: ${constants.fontSize}px; + ${(props) => + props.disabled === true + ? ` + opacity: 0.6; + ` + : ` + `} + border: 0; + box-shadow: none; + color: ${(props) => props.theme.primaryColor}; + background: none; + position: relative; + cursor: pointer; + + :focus { + outline: none; + ${(props) => focusShadow(props.theme)} + } + + :disabled { + cursor: auto; + } + + svg { + fill: ${(props) => props.theme.primaryColor}; + } + + input { + /** Screen-reader-only implementation */ + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + + ${hoverBg} +`; + +export const UploadSuccess = styled.div<{}>` + height: 35px; + width: 35px; + flex: none; + padding: 8px; + font-size: ${constants.fontSize}px; + color: green; +`; + export const BottomButtonsContainer = styled.div<{ /** * @@ -368,6 +430,8 @@ export const BottomButtonsContainer = styled.div<{ top: 50%; right: ${(props) => `${props.theme.spacing}px`}; transform: translate3d(0, -50%, 0); + display: flex; + align-items: center; `; export const Input = styled.input<{ @@ -463,6 +527,7 @@ interface PinBubbleProps { */ onClick: () => void; } + // eslint-disable-next-line jsdoc/require-returns, jsdoc/require-param /** @hidden @internal */ export const PinBubble: React.FunctionComponent = (