diff --git a/packages/touchpoint-ui/src/App.tsx b/packages/touchpoint-ui/src/App.tsx index 0d8e1381..a05c4565 100644 --- a/packages/touchpoint-ui/src/App.tsx +++ b/packages/touchpoint-ui/src/App.tsx @@ -16,17 +16,21 @@ import { type Response, type Config, type BotResponse, - type BotMessage, } from "@nlxai/chat-core"; import { clsx } from "clsx"; import { findLastIndex } from "ramda"; import { LaunchButton } from "./components/ui/LaunchButton"; -import ChatHeader from "./components/ChatHeader"; +import { ChatHeader } from "./components/ChatHeader"; import { ChatSettings } from "./components/ChatSettings"; -import { MessageChoices, ChatMessages } from "./components/ChatMessages"; +import { ChatMessages } from "./components/ChatMessages"; import ChatInput from "./components/ChatInput"; -import { type ColorMode, type WindowSize, type LogoUrl } from "./types"; +import { + type ColorMode, + type WindowSize, + type LogoUrl, + type ChoiceMessage, +} from "./types"; import { Context } from "./context"; export interface Props { @@ -61,7 +65,7 @@ const App = forwardRef((props, ref) => { const [isExpanded, setIsExpanded] = useState(isDev); - const [settingsOpen, setSettingsOpen] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); const expand = useCallback(() => { setIsExpanded(true); @@ -130,24 +134,20 @@ const App = forwardRef((props, ref) => { return { index, response }; }, [responses]); - const choiceMessage = useMemo<{ - message: BotMessage; - responseIndex: number; - messageIndex: number; - } | null>(() => { + const choiceMessage = useMemo(() => { if (lastBotResponse == null) { - return null; + return; } const choiceMessageIndex = findLastIndex((message) => { return message.choices.length > 0; }, lastBotResponse.response.payload.messages); if (choiceMessageIndex === -1) { - return null; + return; } const choiceMessage = lastBotResponse.response.payload.messages[choiceMessageIndex]; if (choiceMessage == null) { - return null; + return; } return { message: choiceMessage, @@ -156,33 +156,56 @@ const App = forwardRef((props, ref) => { }; }, [lastBotResponse]); + const [uploadedFiles, setUploadedFiles] = useState>({}); + if (handler == null) { return null; } return ( - + {isExpanded ? (
{windowSize === "half" ? ( -
+
) : null}
- {settingsOpen ? ( + { + setIsSettingsOpen((prev) => !prev); + }} + collapse={() => { + setIsExpanded(false); + }} + reset={() => { + handler.reset({ clearResponses: true }); + handler.sendWelcomeIntent(); + }} + /> + {isSettingsOpen ? ( { - setSettingsOpen(false); + setIsSettingsOpen(false); }} colorMode={colorMode} windowSize={windowSize} @@ -192,42 +215,37 @@ const App = forwardRef((props, ref) => { /> ) : ( <> - { - setSettingsOpen(true); - }} - collapse={() => { - setIsExpanded(false); - }} - reset={() => { - handler.reset({ clearResponses: true }); - handler.sendWelcomeIntent(); - }} - /> - - {choiceMessage != null ? ( - - ) : null} - + { + setUploadedFiles((prev) => ({ ...prev, [uploadId]: file })); + }} /> )}
) : ( -
+
{ diff --git a/packages/touchpoint-ui/src/components/ChatHeader.tsx b/packages/touchpoint-ui/src/components/ChatHeader.tsx index b070ad6f..8ffd395e 100644 --- a/packages/touchpoint-ui/src/components/ChatHeader.tsx +++ b/packages/touchpoint-ui/src/components/ChatHeader.tsx @@ -13,7 +13,8 @@ interface ChatHeaderProps { logoUrl?: LogoUrl; collapse: () => void; reset: () => void; - openSettings?: () => void; + toggleSettings?: () => void; + isSettingsOpen: boolean; } export const ChatHeader: FC = ({ @@ -21,7 +22,8 @@ export const ChatHeader: FC = ({ colorMode, collapse, logoUrl, - openSettings, + toggleSettings, + isSettingsOpen, reset, }) => { const isHalf = windowSize === "half"; @@ -31,7 +33,7 @@ export const ChatHeader: FC = ({
= ({ }} Icon={Undo} /> - {openSettings != null ? ( + {toggleSettings != null ? ( { - openSettings(); - }} + type={isSettingsOpen ? "activated" : iconButtonType} + onClick={toggleSettings} /> ) : null} { collapse(); }} @@ -75,5 +78,3 @@ export const ChatHeader: FC = ({
); }; - -export default ChatHeader; diff --git a/packages/touchpoint-ui/src/components/ChatInput.tsx b/packages/touchpoint-ui/src/components/ChatInput.tsx index f3dfccef..00284a4f 100644 --- a/packages/touchpoint-ui/src/components/ChatInput.tsx +++ b/packages/touchpoint-ui/src/components/ChatInput.tsx @@ -13,11 +13,15 @@ import { clsx } from "clsx"; import { IconButton } from "./ui/IconButton"; import { ArrowForward, Attachment, Delete, Check, Error } from "./ui/Icons"; +import { type ChoiceMessage } from "../types"; +import { MessageChoices } from "./ChatMessages"; interface ChatInputProps { className?: string; handler: ConversationHandler; uploadUrl?: UploadUrl; + onFileUpload: (val: { uploadId: string; file: File }) => void; + choiceMessage?: ChoiceMessage; } interface FileInfo { @@ -28,7 +32,13 @@ interface FileInfo { const MAX_INPUT_FILE_SIZE_IN_MB = 8; -const ChatInput: FC = ({ className, handler, uploadUrl }) => { +const ChatInput: FC = ({ + className, + choiceMessage, + handler, + uploadUrl, + onFileUpload, +}) => { // Text state const [isTextAreaInFocus, setIsTextAreaInFocus] = useState(false); const [inputValue, setInputValue] = useState(""); @@ -54,7 +64,14 @@ const ChatInput: FC = ({ className, handler, uploadUrl }) => { if (isInputEmpty) { return; } - handler.sendText(inputValue); + if (uploadUrl != null && fileInfo != null) { + handler.sendStructured({ + uploadIds: [uploadUrl.uploadId], + utterance: inputValue, + }); + } else { + handler.sendText(inputValue); + } setInputValue(""); setFileInfo(null); }; @@ -96,6 +113,7 @@ const ChatInput: FC = ({ className, handler, uploadUrl }) => { }) .then(() => { setUploadErrorMessage(null); + onFileUpload({ uploadId: uploadUrl.uploadId, file }); }) .catch(() => { setGenericUploadError(); @@ -103,114 +121,128 @@ const ChatInput: FC = ({ className, handler, uploadUrl }) => { }; return ( -
-
- {uploadErrorMessage != null && ( -
- - {uploadErrorMessage} -
- )} - {fileInfo && ( - <> -
-

- {uploadErrorMessage != null ? ( - - ) : ( - - )} - {fileInfo.name} -

+
+ {choiceMessage != null ? ( + + ) : null} + {choiceMessage?.message.selectedChoiceId != null ? null : ( +
+ {uploadErrorMessage != null && ( +
+ + {uploadErrorMessage} +
+ )} + {fileInfo && ( + <> +
+

+ {uploadErrorMessage != null ? ( + + ) : ( + + )} + {fileInfo.name} +

+ { + setFileInfo(null); + setUploadErrorMessage(null); + if (fileInputRef.current != null) { + fileInputRef.current.value = ""; + } + } + : undefined + } + type="ghost" + /> +
+
+ + )} +
+ {isUploadEnabled ? ( + <> + + + + ) : ( { - setFileInfo(null); - setUploadErrorMessage(null); - if (fileInputRef.current != null) { - fileInputRef.current.value = ""; - } - } - : undefined - } + Icon={Attachment} + label="Upload file" type="ghost" /> -
-
- - )} -
- {isUploadEnabled ? ( - <> - - - - ) : ( + )} + { + setIsTextAreaInFocus(true); + }} + onBlur={() => { + setIsTextAreaInFocus(false); + }} + value={inputValue} + onChange={(e) => { + setInputValue(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submit(); + } + }} + ref={textInputRef} + /> - )} - { - setIsTextAreaInFocus(true); - }} - onBlur={() => { - setIsTextAreaInFocus(false); - }} - value={inputValue} - onChange={(e) => { - setInputValue(e.target.value); - }} - onKeyDown={(e) => { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - submit(); + label="Send message" + onClick={ + isInputEmpty + ? undefined + : () => { + submit(); + } } - }} - ref={textInputRef} - /> - { - submit(); - } - } - type="main" - Icon={ArrowForward} - /> + type="main" + Icon={ArrowForward} + /> +
-
+ )}
); }; diff --git a/packages/touchpoint-ui/src/components/ChatMessages.tsx b/packages/touchpoint-ui/src/components/ChatMessages.tsx index b123a7d0..4b98a7a6 100644 --- a/packages/touchpoint-ui/src/components/ChatMessages.tsx +++ b/packages/touchpoint-ui/src/components/ChatMessages.tsx @@ -15,6 +15,8 @@ import { ArrowForward } from "./ui/Icons"; export interface ChatMessagesProps { handler: ConversationHandler; responses: Response[]; + uploadedFiles: Record; + className?: string; } export const MessageChoices: FC<{ @@ -22,19 +24,15 @@ export const MessageChoices: FC<{ message: BotMessage; responseIndex: number; messageIndex: number; -}> = ({ handler, message, responseIndex, messageIndex }) => { + className?: string; +}> = ({ handler, message, responseIndex, messageIndex, className }) => { return message.choices.length > 0 ? ( -
-
    +
    +
      {message.choices.map((choice, key) => message.selectedChoiceId == null || choice.choiceId === message.selectedChoiceId ? ( -
    • +
    • = ({ text }) => { +const UserMessage: FC<{ text: string; files?: File[] }> = ({ text, files }) => { return ( -
      -
      {text}
      -
      + <> +
      +
      {text}
      +
      + {files != null ? ( +
      + {files.map((file, index) => ( + // TODO: style, add file name as alt text + + ))} +
      + ) : null} + ); }; -export const ChatMessages: FC = ({ responses }) => { +export const ChatMessages: FC = ({ + responses, + uploadedFiles, + className, +}) => { const isWaiting = responses[responses.length - 1]?.type === "user"; const containerRef = useRef(null); @@ -77,12 +93,17 @@ export const ChatMessages: FC = ({ responses }) => { if (containerNode == null) { return; } - containerNode.scrollTop = containerNode.scrollHeight; - }, [responses.length]); + setTimeout(() => { + containerNode.scrollTop = containerNode.scrollHeight; + }); + }, [responses]); return (
      {responses.map((response, responseIndex) => { @@ -92,6 +113,20 @@ export const ChatMessages: FC = ({ responses }) => { return ( ); + } else if ( + response.payload.type === "structured" && + response.payload.utterance != null && + response.payload.uploadIds != null + ) { + return ( + uploadedFiles[uploadId]) + .filter((file) => file != null)} + /> + ); } else { return null; } @@ -99,10 +134,7 @@ export const ChatMessages: FC = ({ responses }) => { // Failure if (response.type === "failure") { return ( -

      +

      {response.payload.text}

      ); @@ -114,10 +146,7 @@ export const ChatMessages: FC = ({ responses }) => {
      {response.payload.messages.map((message, messageIndex) => { return ( -
      +
      void; @@ -14,6 +14,7 @@ interface ChatSettingsProps { windowSize: WindowSize; setColorModeOverride: Dispatch>; setWindowSizeOverride: Dispatch>; + className?: string; } export const ChatSettings: FC = ({ @@ -23,71 +24,69 @@ export const ChatSettings: FC = ({ windowSize, setColorModeOverride, setWindowSizeOverride, + className, }) => { return ( -
      -
      - + { + handler.reset(); + onClose(); + }} + /> + { + // TODO: avoid hard-coding default intent by exposing from the SDK + handler.sendIntent("NLX.Escalation"); + onClose(); + }} + /> +
      + { + setColorModeOverride("dark"); + }} + /> + { + setColorModeOverride("light"); + }} />
      -
      +
      { - handler.reset(); + setWindowSizeOverride("half"); }} /> { - // TODO: avoid hard-coding default intent by exposing from the SDK - handler.sendIntent("NLX.Escalation"); + setWindowSizeOverride("full"); }} /> -
      - { - setColorModeOverride("dark"); - }} - /> - { - setColorModeOverride("light"); - }} - /> -
      -
      - { - setWindowSizeOverride("half"); - }} - /> - { - setWindowSizeOverride("full"); - }} - /> -
      ); diff --git a/packages/touchpoint-ui/src/components/ui/Loader.tsx b/packages/touchpoint-ui/src/components/ui/Loader.tsx index 043190ac..2c55796b 100644 --- a/packages/touchpoint-ui/src/components/ui/Loader.tsx +++ b/packages/touchpoint-ui/src/components/ui/Loader.tsx @@ -1,13 +1,28 @@ /* eslint-disable jsdoc/require-jsdoc */ import { clsx } from "clsx"; import { useEffect, useRef, useState, type FC } from "react"; +// import vid from "./loader-assets/loader-dark.mp4"; export interface LoaderProps { label: string; } -const r = 28; -const rc = 15; +const r = 30; +const rc = 17; + +const ease = ( + t: number, + [x1, y1]: [number, number], + [x2, y2]: [number, number], +): number => { + // TODO: use control points + return ( + (1 - t) ** 3 * 0 + + 3 * (1 - t) ** 2 * t * y1 + + 3 * (1 - t) * t ** 2 * y2 + + t ** 3 + ); +}; // A pair of circles a dynamic distance apart, with a connecting goop if they are close enough const Pair: FC<{ d: number }> = ({ d }) => { @@ -41,6 +56,35 @@ const Pair: FC<{ d: number }> = ({ d }) => { ); }; +const getDistance = (t: number, quarter: number): number => { + const pt1: [number, number] = [0, 0.2]; + const pt2: [number, number] = [0.7, 1]; + const easeD = ease(t, pt1, pt2); + const easeD2 = ease(1 - t, pt1, pt2); + + return quarter === 0 + ? easeD + : quarter === 1 + ? easeD2 + : quarter === 2 + ? easeD + : easeD2; +}; + +const getRotation = (t: number, quarter: number): number => { + const pt1: [number, number] = [0.9, 0]; + const pt2: [number, number] = [0.1, 1]; + const easeR = ease(t, pt1, pt2); + const easeR2 = ease(1 - t, pt1, pt2); + return quarter === 0 + ? 45 * easeR + : quarter === 1 + ? 45 + 45 * (1 - easeR2) + : quarter === 2 + ? 90 + 45 * easeR + : 135 + 45 * (1 - easeR2); +}; + export const Loader: FC = ({ label }) => { const [time, setTime] = useState<{ start: number; current: number } | null>( null, @@ -64,9 +108,17 @@ export const Loader: FC = ({ label }) => { return null; } const diff = time.current - time.start; - const phase = diff / 225; - const dFactor = 0.5 + 0.5 * Math.sin(phase); - const dropShadowRadius = dFactor > 0.2 ? 0 : (4 * (0.2 - dFactor)) / 0.2; + + const adjustedTime = diff / 500; + const unit = Math.floor(adjustedTime); + const quarter = unit % 4; + const t = adjustedTime - unit; + + const dFactor = getDistance(t, quarter); + const rotation = getRotation(t, quarter); + + const dropShadowRadius = dFactor > 0.2 ? 0 : (8 * (0.2 - dFactor)) / 0.2; + return (
      @@ -79,9 +131,7 @@ export const Loader: FC = ({ label }) => { filter: `drop-shadow(0 0 ${dropShadowRadius}px rgb(var(--accent)))`, }} > - + diff --git a/packages/touchpoint-ui/src/context.ts b/packages/touchpoint-ui/src/context.ts index 382da5fb..457c3a08 100644 --- a/packages/touchpoint-ui/src/context.ts +++ b/packages/touchpoint-ui/src/context.ts @@ -1,17 +1,12 @@ /* eslint-disable jsdoc/require-jsdoc */ import { type ConversationHandler } from "@nlxai/chat-core"; import { createContext, useContext } from "react"; -import { type ColorMode, type WindowSize } from "./types"; export interface ContextValue { - windowSize: WindowSize; - colorMode: ColorMode; handler: ConversationHandler | null; } export const Context = createContext({ - colorMode: "dark", - windowSize: "half", handler: null, }); diff --git a/packages/touchpoint-ui/src/design-system.tsx b/packages/touchpoint-ui/src/design-system.tsx index 24e88155..90ca085e 100644 --- a/packages/touchpoint-ui/src/design-system.tsx +++ b/packages/touchpoint-ui/src/design-system.tsx @@ -200,9 +200,7 @@ const Container: FC<{ children: ReactNode; mode: ColorMode }> = ({ mode, }) => { return ( - +
      ; + +/** + * Choice message with metadata + */ +export interface ChoiceMessage { + /** + * Message contents + */ + message: BotMessage; + /** + * Index in the response transcript history + */ + responseIndex: number; + /** + * Message index in the current response + */ + messageIndex: number; +} diff --git a/packages/touchpoint-ui/tailwind.config.js b/packages/touchpoint-ui/tailwind.config.js index 14e9eaf8..04c92da2 100644 --- a/packages/touchpoint-ui/tailwind.config.js +++ b/packages/touchpoint-ui/tailwind.config.js @@ -3,7 +3,13 @@ module.exports = { content: ["./src/**/*.{ts,tsx}"], prefix: "", theme: { + fontFamily: { + sans: ["var(--font-family)"], + }, extend: { + backdropBlur: { + overlay: "48px", + }, maxWidth: { content: "608px", },