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

Handle uploads #165

Merged
merged 13 commits into from
Oct 25, 2024
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
*/
uploadIds?: 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="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</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
92 changes: 91 additions & 1 deletion packages/chat-widget/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { marked, type MarkedExtension } from "marked";
import {
type FC,
type ReactNode,
type ChangeEvent,
createRef,
useEffect,
useCallback,
Expand All @@ -18,17 +19,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 +357,75 @@ const isInputDisabled = (responses: Response[]): boolean => {
return new URLSearchParams(payload).get("nlx:input-disabled") === "true";
};

const findActiveUpload = (responses: Response[]): UploadUrl | null => {
const lastBotResponse = findLast(
jakub-nlx marked this conversation as resolved.
Show resolved Hide resolved
(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") {
return (
<C.UploadSuccess>
<CheckIcon />
</C.UploadSuccess>
);
}

const handleChange = async (
ev: ChangeEvent<HTMLInputElement>,
): Promise<void> => {
const file = ev.target.files?.[0];
if (file == null) {
return;
}
setStatus("uploading");
try {
await fetch(props.upload.url, {
method: "PUT",
headers: {
"Content-Type": "image/jpeg",
},
body: new Blob([file], { type: file.type }),
peterszerzo marked this conversation as resolved.
Show resolved Hide resolved
});
setStatus("uploaded");
} catch (_err) {
// TODO: add error handling
setStatus("empty");
}
};

return (
<C.UploadIconLabel>
<input
type="file"
onChange={(ev) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleChange(ev);
}}
/>
<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 +620,19 @@ 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({
uploadIds: [activeUpload.uploadId],
utterance: chat.inputValue,
});
chat.setInputValue("");
return;
}
chat.conversationHandler.sendText(chat.inputValue);
chat.setInputValue("");
});
Expand Down Expand Up @@ -652,6 +736,12 @@ export const Widget = forwardRef<WidgetRef, Props>(function Widget(props, ref) {
}}
/>
<C.BottomButtonsContainer>
{activeUpload == null ? null : (
<ImageUpload
key={activeUpload.uploadId}
upload={activeUpload}
/>
)}
<C.IconButton
disabled={Boolean(submit === false)}
onClick={() => {
Expand Down
61 changes: 61 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,64 @@ export const IconButton = styled.button<{
${hoverBg}
`;

export const UploadIconLabel = styled.label<{
/** @hidden @internal */
children: React.ReactNode;
}>`
height: 35px;
width: 35px;
border-radius: 18px;
flex: none;
padding: 8px;
font-size: ${constants.fontSize}px;
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<{
/** @hidden @internal */
children: React.ReactNode;
}>`
height: 35px;
width: 35px;
flex: none;
padding: 8px;
font-size: ${constants.fontSize}px;
color: green;
`;

export const BottomButtonsContainer = styled.div<{
/**
*
Expand All @@ -368,6 +426,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 +523,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