|
|
@@ -6,6 +6,7 @@ import React, {
|
|
|
useMemo,
|
|
|
useCallback,
|
|
|
Fragment,
|
|
|
+ RefObject,
|
|
|
} from "react";
|
|
|
|
|
|
import SendWhiteIcon from "../icons/send-white.svg";
|
|
|
@@ -15,6 +16,7 @@ import ExportIcon from "../icons/share.svg";
|
|
|
import ReturnIcon from "../icons/return.svg";
|
|
|
import CopyIcon from "../icons/copy.svg";
|
|
|
import LoadingIcon from "../icons/three-dots.svg";
|
|
|
+import LoadingButtonIcon from "../icons/loading.svg";
|
|
|
import PromptIcon from "../icons/prompt.svg";
|
|
|
import MaskIcon from "../icons/mask.svg";
|
|
|
import MaxIcon from "../icons/max.svg";
|
|
|
@@ -27,6 +29,7 @@ import PinIcon from "../icons/pin.svg";
|
|
|
import EditIcon from "../icons/rename.svg";
|
|
|
import ConfirmIcon from "../icons/confirm.svg";
|
|
|
import CancelIcon from "../icons/cancel.svg";
|
|
|
+import ImageIcon from "../icons/image.svg";
|
|
|
|
|
|
import LightIcon from "../icons/light.svg";
|
|
|
import DarkIcon from "../icons/dark.svg";
|
|
|
@@ -53,6 +56,10 @@ import {
|
|
|
selectOrCopy,
|
|
|
autoGrowTextArea,
|
|
|
useMobileScreen,
|
|
|
+ getMessageTextContent,
|
|
|
+ getMessageImages,
|
|
|
+ isVisionModel,
|
|
|
+ compressImage,
|
|
|
} from "../utils";
|
|
|
|
|
|
import dynamic from "next/dynamic";
|
|
|
@@ -89,6 +96,7 @@ import { prettyObject } from "../utils/format";
|
|
|
import { ExportMessageModal } from "./exporter";
|
|
|
import { getClientConfig } from "../config/client";
|
|
|
import { useAllModels } from "../utils/hooks";
|
|
|
+import { MultimodalContent } from "../client/api";
|
|
|
|
|
|
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
|
|
loading: () => <LoadingIcon />,
|
|
|
@@ -375,11 +383,13 @@ function ChatAction(props: {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-function useScrollToBottom() {
|
|
|
+function useScrollToBottom(
|
|
|
+ scrollRef: RefObject<HTMLDivElement>,
|
|
|
+ detach: boolean = false,
|
|
|
+) {
|
|
|
// for auto-scroll
|
|
|
- const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
- const [autoScroll, setAutoScroll] = useState(true);
|
|
|
|
|
|
+ const [autoScroll, setAutoScroll] = useState(true);
|
|
|
function scrollDomToBottom() {
|
|
|
const dom = scrollRef.current;
|
|
|
if (dom) {
|
|
|
@@ -392,7 +402,7 @@ function useScrollToBottom() {
|
|
|
|
|
|
// auto scroll
|
|
|
useEffect(() => {
|
|
|
- if (autoScroll) {
|
|
|
+ if (autoScroll && !detach) {
|
|
|
scrollDomToBottom();
|
|
|
}
|
|
|
});
|
|
|
@@ -406,10 +416,14 @@ function useScrollToBottom() {
|
|
|
}
|
|
|
|
|
|
export function ChatActions(props: {
|
|
|
+ uploadImage: () => void;
|
|
|
+ setAttachImages: (images: string[]) => void;
|
|
|
+ setUploading: (uploading: boolean) => void;
|
|
|
showPromptModal: () => void;
|
|
|
scrollToBottom: () => void;
|
|
|
showPromptHints: () => void;
|
|
|
hitBottom: boolean;
|
|
|
+ uploading: boolean;
|
|
|
}) {
|
|
|
const config = useAppConfig();
|
|
|
const navigate = useNavigate();
|
|
|
@@ -437,8 +451,16 @@ export function ChatActions(props: {
|
|
|
[allModels],
|
|
|
);
|
|
|
const [showModelSelector, setShowModelSelector] = useState(false);
|
|
|
+ const [showUploadImage, setShowUploadImage] = useState(false);
|
|
|
|
|
|
useEffect(() => {
|
|
|
+ const show = isVisionModel(currentModel);
|
|
|
+ setShowUploadImage(show);
|
|
|
+ if (!show) {
|
|
|
+ props.setAttachImages([]);
|
|
|
+ props.setUploading(false);
|
|
|
+ }
|
|
|
+
|
|
|
// if current model is not available
|
|
|
// switch to first available model
|
|
|
const isUnavaliableModel = !models.some((m) => m.name === currentModel);
|
|
|
@@ -475,6 +497,13 @@ export function ChatActions(props: {
|
|
|
/>
|
|
|
)}
|
|
|
|
|
|
+ {showUploadImage && (
|
|
|
+ <ChatAction
|
|
|
+ onClick={props.uploadImage}
|
|
|
+ text={Locale.Chat.InputActions.UploadImage}
|
|
|
+ icon={props.uploading ? <LoadingButtonIcon /> : <ImageIcon />}
|
|
|
+ />
|
|
|
+ )}
|
|
|
<ChatAction
|
|
|
onClick={nextTheme}
|
|
|
text={Locale.Chat.InputActions.Theme[theme]}
|
|
|
@@ -610,6 +639,14 @@ export function EditMessageModal(props: { onClose: () => void }) {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+export function DeleteImageButton(props: { deleteImage: () => void }) {
|
|
|
+ return (
|
|
|
+ <div className={styles["delete-image"]} onClick={props.deleteImage}>
|
|
|
+ <DeleteIcon />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
function _Chat() {
|
|
|
type RenderMessage = ChatMessage & { preview?: boolean };
|
|
|
|
|
|
@@ -624,10 +661,22 @@ function _Chat() {
|
|
|
const [userInput, setUserInput] = useState("");
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
const { submitKey, shouldSubmit } = useSubmitHandler();
|
|
|
- const { scrollRef, setAutoScroll, scrollDomToBottom } = useScrollToBottom();
|
|
|
+ const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
+ const isScrolledToBottom = scrollRef?.current
|
|
|
+ ? Math.abs(
|
|
|
+ scrollRef.current.scrollHeight -
|
|
|
+ (scrollRef.current.scrollTop + scrollRef.current.clientHeight),
|
|
|
+ ) <= 1
|
|
|
+ : false;
|
|
|
+ const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(
|
|
|
+ scrollRef,
|
|
|
+ isScrolledToBottom,
|
|
|
+ );
|
|
|
const [hitBottom, setHitBottom] = useState(true);
|
|
|
const isMobileScreen = useMobileScreen();
|
|
|
const navigate = useNavigate();
|
|
|
+ const [attachImages, setAttachImages] = useState<string[]>([]);
|
|
|
+ const [uploading, setUploading] = useState(false);
|
|
|
|
|
|
// prompt hints
|
|
|
const promptStore = usePromptStore();
|
|
|
@@ -705,7 +754,10 @@ function _Chat() {
|
|
|
return;
|
|
|
}
|
|
|
setIsLoading(true);
|
|
|
- chatStore.onUserInput(userInput).then(() => setIsLoading(false));
|
|
|
+ chatStore
|
|
|
+ .onUserInput(userInput, attachImages)
|
|
|
+ .then(() => setIsLoading(false));
|
|
|
+ setAttachImages([]);
|
|
|
localStorage.setItem(LAST_INPUT_KEY, userInput);
|
|
|
setUserInput("");
|
|
|
setPromptHints([]);
|
|
|
@@ -783,9 +835,9 @@ function _Chat() {
|
|
|
};
|
|
|
const onRightClick = (e: any, message: ChatMessage) => {
|
|
|
// copy to clipboard
|
|
|
- if (selectOrCopy(e.currentTarget, message.content)) {
|
|
|
+ if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) {
|
|
|
if (userInput.length === 0) {
|
|
|
- setUserInput(message.content);
|
|
|
+ setUserInput(getMessageTextContent(message));
|
|
|
}
|
|
|
|
|
|
e.preventDefault();
|
|
|
@@ -853,7 +905,9 @@ function _Chat() {
|
|
|
|
|
|
// resend the message
|
|
|
setIsLoading(true);
|
|
|
- chatStore.onUserInput(userMessage.content).then(() => setIsLoading(false));
|
|
|
+ const textContent = getMessageTextContent(userMessage);
|
|
|
+ const images = getMessageImages(userMessage);
|
|
|
+ chatStore.onUserInput(textContent, images).then(() => setIsLoading(false));
|
|
|
inputRef.current?.focus();
|
|
|
};
|
|
|
|
|
|
@@ -962,7 +1016,6 @@ function _Chat() {
|
|
|
setHitBottom(isHitBottom);
|
|
|
setAutoScroll(isHitBottom);
|
|
|
};
|
|
|
-
|
|
|
function scrollToBottom() {
|
|
|
setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
|
|
|
scrollDomToBottom();
|
|
|
@@ -1047,6 +1100,92 @@ function _Chat() {
|
|
|
};
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
}, []);
|
|
|
+
|
|
|
+ const handlePaste = useCallback(
|
|
|
+ async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
|
+ const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
|
|
+ if(!isVisionModel(currentModel)){return;}
|
|
|
+ const items = (event.clipboardData || window.clipboardData).items;
|
|
|
+ for (const item of items) {
|
|
|
+ if (item.kind === "file" && item.type.startsWith("image/")) {
|
|
|
+ event.preventDefault();
|
|
|
+ const file = item.getAsFile();
|
|
|
+ if (file) {
|
|
|
+ const images: string[] = [];
|
|
|
+ images.push(...attachImages);
|
|
|
+ images.push(
|
|
|
+ ...(await new Promise<string[]>((res, rej) => {
|
|
|
+ setUploading(true);
|
|
|
+ const imagesData: string[] = [];
|
|
|
+ compressImage(file, 256 * 1024)
|
|
|
+ .then((dataUrl) => {
|
|
|
+ imagesData.push(dataUrl);
|
|
|
+ setUploading(false);
|
|
|
+ res(imagesData);
|
|
|
+ })
|
|
|
+ .catch((e) => {
|
|
|
+ setUploading(false);
|
|
|
+ rej(e);
|
|
|
+ });
|
|
|
+ })),
|
|
|
+ );
|
|
|
+ const imagesLength = images.length;
|
|
|
+
|
|
|
+ if (imagesLength > 3) {
|
|
|
+ images.splice(3, imagesLength - 3);
|
|
|
+ }
|
|
|
+ setAttachImages(images);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [attachImages, chatStore],
|
|
|
+ );
|
|
|
+
|
|
|
+ async function uploadImage() {
|
|
|
+ const images: string[] = [];
|
|
|
+ images.push(...attachImages);
|
|
|
+
|
|
|
+ images.push(
|
|
|
+ ...(await new Promise<string[]>((res, rej) => {
|
|
|
+ const fileInput = document.createElement("input");
|
|
|
+ fileInput.type = "file";
|
|
|
+ fileInput.accept =
|
|
|
+ "image/png, image/jpeg, image/webp, image/heic, image/heif";
|
|
|
+ fileInput.multiple = true;
|
|
|
+ fileInput.onchange = (event: any) => {
|
|
|
+ setUploading(true);
|
|
|
+ const files = event.target.files;
|
|
|
+ const imagesData: string[] = [];
|
|
|
+ for (let i = 0; i < files.length; i++) {
|
|
|
+ const file = event.target.files[i];
|
|
|
+ compressImage(file, 256 * 1024)
|
|
|
+ .then((dataUrl) => {
|
|
|
+ imagesData.push(dataUrl);
|
|
|
+ if (
|
|
|
+ imagesData.length === 3 ||
|
|
|
+ imagesData.length === files.length
|
|
|
+ ) {
|
|
|
+ setUploading(false);
|
|
|
+ res(imagesData);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .catch((e) => {
|
|
|
+ setUploading(false);
|
|
|
+ rej(e);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+ fileInput.click();
|
|
|
+ })),
|
|
|
+ );
|
|
|
+
|
|
|
+ const imagesLength = images.length;
|
|
|
+ if (imagesLength > 3) {
|
|
|
+ images.splice(3, imagesLength - 3);
|
|
|
+ }
|
|
|
+ setAttachImages(images);
|
|
|
+ }
|
|
|
|
|
|
return (
|
|
|
<div className={styles.chat} key={session.id}>
|
|
|
@@ -1154,15 +1293,29 @@ function _Chat() {
|
|
|
onClick={async () => {
|
|
|
const newMessage = await showPrompt(
|
|
|
Locale.Chat.Actions.Edit,
|
|
|
- message.content,
|
|
|
+ getMessageTextContent(message),
|
|
|
10,
|
|
|
);
|
|
|
+ let newContent: string | MultimodalContent[] =
|
|
|
+ newMessage;
|
|
|
+ const images = getMessageImages(message);
|
|
|
+ if (images.length > 0) {
|
|
|
+ newContent = [{ type: "text", text: newMessage }];
|
|
|
+ for (let i = 0; i < images.length; i++) {
|
|
|
+ newContent.push({
|
|
|
+ type: "image_url",
|
|
|
+ image_url: {
|
|
|
+ url: images[i],
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
chatStore.updateCurrentSession((session) => {
|
|
|
const m = session.mask.context
|
|
|
.concat(session.messages)
|
|
|
.find((m) => m.id === message.id);
|
|
|
if (m) {
|
|
|
- m.content = newMessage;
|
|
|
+ m.content = newContent;
|
|
|
}
|
|
|
});
|
|
|
}}
|
|
|
@@ -1217,7 +1370,11 @@ function _Chat() {
|
|
|
<ChatAction
|
|
|
text={Locale.Chat.Actions.Copy}
|
|
|
icon={<CopyIcon />}
|
|
|
- onClick={() => copyToClipboard(message.content)}
|
|
|
+ onClick={() =>
|
|
|
+ copyToClipboard(
|
|
|
+ getMessageTextContent(message),
|
|
|
+ )
|
|
|
+ }
|
|
|
/>
|
|
|
</>
|
|
|
)}
|
|
|
@@ -1232,7 +1389,7 @@ function _Chat() {
|
|
|
)}
|
|
|
<div className={styles["chat-message-item"]}>
|
|
|
<Markdown
|
|
|
- content={message.content}
|
|
|
+ content={getMessageTextContent(message)}
|
|
|
loading={
|
|
|
(message.preview || message.streaming) &&
|
|
|
message.content.length === 0 &&
|
|
|
@@ -1241,12 +1398,42 @@ function _Chat() {
|
|
|
onContextMenu={(e) => onRightClick(e, message)}
|
|
|
onDoubleClickCapture={() => {
|
|
|
if (!isMobileScreen) return;
|
|
|
- setUserInput(message.content);
|
|
|
+ setUserInput(getMessageTextContent(message));
|
|
|
}}
|
|
|
fontSize={fontSize}
|
|
|
parentRef={scrollRef}
|
|
|
defaultShow={i >= messages.length - 6}
|
|
|
/>
|
|
|
+ {getMessageImages(message).length == 1 && (
|
|
|
+ <img
|
|
|
+ className={styles["chat-message-item-image"]}
|
|
|
+ src={getMessageImages(message)[0]}
|
|
|
+ alt=""
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {getMessageImages(message).length > 1 && (
|
|
|
+ <div
|
|
|
+ className={styles["chat-message-item-images"]}
|
|
|
+ style={
|
|
|
+ {
|
|
|
+ "--image-count": getMessageImages(message).length,
|
|
|
+ } as React.CSSProperties
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {getMessageImages(message).map((image, index) => {
|
|
|
+ return (
|
|
|
+ <img
|
|
|
+ className={
|
|
|
+ styles["chat-message-item-image-multi"]
|
|
|
+ }
|
|
|
+ key={index}
|
|
|
+ src={image}
|
|
|
+ alt=""
|
|
|
+ />
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
|
|
|
<div className={styles["chat-message-action-date"]}>
|
|
|
@@ -1266,9 +1453,13 @@ function _Chat() {
|
|
|
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
|
|
|
|
|
|
<ChatActions
|
|
|
+ uploadImage={uploadImage}
|
|
|
+ setAttachImages={setAttachImages}
|
|
|
+ setUploading={setUploading}
|
|
|
showPromptModal={() => setShowPromptModal(true)}
|
|
|
scrollToBottom={scrollToBottom}
|
|
|
hitBottom={hitBottom}
|
|
|
+ uploading={uploading}
|
|
|
showPromptHints={() => {
|
|
|
// Click again to close
|
|
|
if (promptHints.length > 0) {
|
|
|
@@ -1281,8 +1472,16 @@ function _Chat() {
|
|
|
onSearch("");
|
|
|
}}
|
|
|
/>
|
|
|
- <div className={styles["chat-input-panel-inner"]}>
|
|
|
+ <label
|
|
|
+ className={`${styles["chat-input-panel-inner"]} ${
|
|
|
+ attachImages.length != 0
|
|
|
+ ? styles["chat-input-panel-inner-attach"]
|
|
|
+ : ""
|
|
|
+ }`}
|
|
|
+ htmlFor="chat-input"
|
|
|
+ >
|
|
|
<textarea
|
|
|
+ id="chat-input"
|
|
|
ref={inputRef}
|
|
|
className={styles["chat-input"]}
|
|
|
placeholder={Locale.Chat.Input(submitKey)}
|
|
|
@@ -1291,12 +1490,36 @@ function _Chat() {
|
|
|
onKeyDown={onInputKeyDown}
|
|
|
onFocus={scrollToBottom}
|
|
|
onClick={scrollToBottom}
|
|
|
+ onPaste={handlePaste}
|
|
|
rows={inputRows}
|
|
|
autoFocus={autoFocus}
|
|
|
style={{
|
|
|
fontSize: config.fontSize,
|
|
|
}}
|
|
|
/>
|
|
|
+ {attachImages.length != 0 && (
|
|
|
+ <div className={styles["attach-images"]}>
|
|
|
+ {attachImages.map((image, index) => {
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={index}
|
|
|
+ className={styles["attach-image"]}
|
|
|
+ style={{ backgroundImage: `url("${image}")` }}
|
|
|
+ >
|
|
|
+ <div className={styles["attach-image-mask"]}>
|
|
|
+ <DeleteImageButton
|
|
|
+ deleteImage={() => {
|
|
|
+ setAttachImages(
|
|
|
+ attachImages.filter((_, i) => i !== index),
|
|
|
+ );
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
<IconButton
|
|
|
icon={<SendWhiteIcon />}
|
|
|
text={Locale.Chat.Send}
|
|
|
@@ -1304,7 +1527,7 @@ function _Chat() {
|
|
|
type="primary"
|
|
|
onClick={() => doSubmit(userInput)}
|
|
|
/>
|
|
|
- </div>
|
|
|
+ </label>
|
|
|
</div>
|
|
|
|
|
|
{showExport && (
|