Skip to content

Commit

Permalink
Handle uploads
Browse files Browse the repository at this point in the history
  • Loading branch information
peterszerzo committed Oct 25, 2024
1 parent d4c3848 commit ee6822f
Show file tree
Hide file tree
Showing 5 changed files with 188 additions and 5 deletions.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions packages/chat-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 17 additions & 1 deletion packages/chat-widget/src/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,30 @@ export const ChatIcon = (): ReactNode => (
</svg>
);

// eslint-disable-next-line jsdoc/require-returns
/** @hidden @internal */
export const CheckIcon = (): ReactNode => (
<svg viewBox="0 0 24 24" stroke="none" fill="currentColor">
<path d="M2.01 21 23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
);

// eslint-disable-next-line jsdoc/require-returns
/** @hidden @internal */
export const SendIcon = (): ReactNode => (
<svg viewBox="0 0 360 360" stroke="none" fill="currentColor">
<svg viewBox="0 0 24 24" stroke="none" fill="currentColor">
<path d="M2.01 21 23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
);

// eslint-disable-next-line jsdoc/require-returns
/** @hidden @internal */
export const AddPhotoIcon = (): ReactNode => (
<svg viewBox="0 0 24 24" stroke="none" fill="currentColor">
<path d="M19 7v2.99s-1.99.01-2 0V7h-3s.01-1.99 0-2h3V2h2v3h3v2zm-3 4V8h-3V5H5c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-8zM5 19l3-4 2 3 3-4 4 5z" />
</svg>
);

// eslint-disable-next-line jsdoc/require-returns
/** @hidden @internal */
export const DownloadIcon = (): ReactNode => (
Expand Down
78 changes: 77 additions & 1 deletion packages/chat-widget/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,21 @@ 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 {
CloseIcon,
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,
Expand Down Expand Up @@ -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<ImageUploadProps> = (props) => {
const [status, setStatus] = useState<UploadStatus>("empty");

if (status === "uploading") {
return <C.PendingMessageDots />;
}

if (status === "uploaded") {
<C.UploadSuccess>
<CheckIcon />
</C.UploadSuccess>;
}

return (
<C.UploadIconLabel>
<input
type="file"
onChange={async (ev) => {
const file = ev.target.files?.[0];
if (file == null) {
return;
}
setStatus("uploading");
const formData = new FormData();
formData.append("file", file);
try {
await fetch(props.upload.url, {
method: "PUT",
headers: {
"Content-Type": "image/jpeg",
},
body: formData,
});
setStatus("uploaded");
} catch (err) {
console.error(err);
}
}}
/>
<AddPhotoIcon />
</C.UploadIconLabel>
);
};

/**
* Hook to get the ConversationHandler for the widget.
* This may be called before the Widget has been created.
Expand Down Expand Up @@ -546,9 +611,17 @@ export const Widget = forwardRef<WidgetRef, Props>(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("");
});
Expand Down Expand Up @@ -652,6 +725,9 @@ export const Widget = forwardRef<WidgetRef, Props>(function Widget(props, ref) {
}}
/>
<C.BottomButtonsContainer>
{activeUpload == null ? null : (
<ImageUpload upload={activeUpload} />
)}
<C.IconButton
disabled={Boolean(submit === false)}
onClick={() => {
Expand Down
65 changes: 65 additions & 0 deletions packages/chat-widget/src/ui/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
/**
*
Expand All @@ -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<{
Expand Down Expand Up @@ -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<PinBubbleProps> = (
Expand Down

0 comments on commit ee6822f

Please sign in to comment.