ChatGPT-Next-Web/app/components/chat.tsx
2024-05-26 13:17:35 +08:00

1860 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useDebouncedCallback } from "use-debounce";
import React, {
useState,
useRef,
useEffect,
useMemo,
useCallback,
Fragment,
RefObject,
} from "react";
import SendWhiteIcon from "../icons/send-white.svg";
import BrainIcon from "../icons/brain.svg";
import RenameIcon from "../icons/rename.svg";
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";
import MinIcon from "../icons/min.svg";
import ResetIcon from "../icons/reload.svg";
import BreakIcon from "../icons/break.svg";
import SettingsIcon from "../icons/chat-settings.svg";
import DeleteIcon from "../icons/clear.svg";
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";
import AutoIcon from "../icons/auto.svg";
import BottomIcon from "../icons/bottom.svg";
import StopIcon from "../icons/pause.svg";
import RobotIcon from "../icons/robot.svg";
// import UploadIcon from "../icons/upload.svg";
import {
ChatMessage,
SubmitKey,
useChatStore,
BOT_HELLO,
createMessage,
useAccessStore,
Theme,
useAppConfig,
DEFAULT_TOPIC,
ModelType,
ChatSession,
} from "../store";
import {
copyToClipboard,
downloadAs,
selectOrCopy,
autoGrowTextArea,
useMobileScreen,
getMessageTextContent,
getMessageImages,
isVisionModel,
} from "../utils";
import { compressImage } from "@/app/utils/chat";
import dynamic from "next/dynamic";
import { ChatControllerPool } from "../client/controller";
import { Prompt, usePromptStore } from "../store/prompt";
import Locale from "../locales";
import { IconButton } from "./button";
import styles from "./chat.module.scss";
import {
List,
ListItem,
Modal,
ModalSelector,
Selector,
showConfirm,
showPrompt,
showToast,
} from "./ui-lib";
import { useNavigate } from "react-router-dom";
import {
CHAT_PAGE_SIZE,
LAST_INPUT_KEY,
Path,
REQUEST_TIMEOUT_MS,
UNFINISHED_INPUT,
} from "../constant";
import { Avatar } from "./emoji";
// import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
// import { useMaskStore } from "../store/mask";
import { ChatCommandPrefix, useChatCommand, useCommand } from "../command";
import { prettyObject } from "../utils/format";
import { ExportMessageModal } from "./exporter";
import { getClientConfig } from "../config/client";
import Image from "next/image";
import { useAllModels } from "../utils/hooks";
import { MultimodalContent } from "../client/api";
// import { getTokenLength } from "@/lib/utils";
import VoiceInput from "./voice-input";
import { Progress, Tooltip } from "antd";
import { white } from "kleur/colors";
// import GptPrompts from "./gpt-prompts";
// const VoiceInput = dynamic(
// () => import('@/app/components/voice-input'), { ssr: false });
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
});
export function SessionConfigModel(props: { onClose: () => void }) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
// const maskStore = useMaskStore();
const navigate = useNavigate();
return (
<div className="modal-mask">
<Modal
title={Locale.Context.Edit}
onClose={() => props.onClose()}
actions={[
<IconButton
key="reset"
icon={<ResetIcon />}
bordered
text={Locale.Chat.Config.Reset}
onClick={async () => {
if (await showConfirm(Locale.Memory.ResetConfirm)) {
chatStore.updateCurrentSession(
(session) => (session.memoryPrompt = ""),
);
}
}}
/>,
// <IconButton
// key="copy"
// icon={<CopyIcon />}
// bordered
// text={Locale.Chat.Config.SaveAs}
// onClick={() => {
// navigate(Path.Masks);
// setTimeout(() => {
// maskStore.create(session.mask);
// }, 500);
// }}
// />,
]}
>
{/*<MaskConfig*/}
{/* mask={session.mask}*/}
{/* updateMask={(updater) => {*/}
{/* const mask = { ...session.mask };*/}
{/* updater(mask);*/}
{/* chatStore.updateCurrentSession((session) => (session.mask = mask));*/}
{/* }}*/}
{/* shouldSyncFromGlobal*/}
{/* extraListItems={*/}
{/* session.mask.modelConfig.sendMemory ? (*/}
{/* <ListItem*/}
{/* className="copyable"*/}
{/* title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}*/}
{/* subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}*/}
{/* ></ListItem>*/}
{/* ) : (*/}
{/* <></>*/}
{/* )*/}
{/* }*/}
{/*></MaskConfig>*/}
</Modal>
</div>
);
}
function PromptToast(props: {
showToast?: boolean;
showModal?: boolean;
setShowModal: (_: boolean) => void;
}) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const context = session.mask.context;
return (
<div className={styles["prompt-toast"]} key="prompt-toast">
{props.showToast && (
<div
className={styles["prompt-toast-inner"] + " clickable"}
role="button"
onClick={() => props.setShowModal(true)}
>
<BrainIcon />
<span className={styles["prompt-toast-content"]}>
{Locale.Context.Toast(context.length)}
</span>
</div>
)}
{props.showModal && (
<SessionConfigModel onClose={() => props.setShowModal(false)} />
)}
</div>
);
}
function useSubmitHandler() {
const config = useAppConfig();
const submitKey = config.submitKey;
const isComposing = useRef(false);
useEffect(() => {
const onCompositionStart = () => {
isComposing.current = true;
};
const onCompositionEnd = () => {
isComposing.current = false;
};
window.addEventListener("compositionstart", onCompositionStart);
window.addEventListener("compositionend", onCompositionEnd);
return () => {
window.removeEventListener("compositionstart", onCompositionStart);
window.removeEventListener("compositionend", onCompositionEnd);
};
}, []);
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Fix Chinese input method "Enter" on Safari
if (e.keyCode == 229) return false;
if (e.key !== "Enter") return false;
if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current))
return false;
return (
(config.submitKey === SubmitKey.AltEnter && e.altKey) ||
(config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
(config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
(config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
(config.submitKey === SubmitKey.Enter &&
!e.altKey &&
!e.ctrlKey &&
!e.shiftKey &&
!e.metaKey)
);
};
return {
submitKey,
shouldSubmit,
};
}
export type RenderPompt = Pick<Prompt, "title" | "content">;
export function PromptHints(props: {
prompts: RenderPompt[];
onPromptSelect: (prompt: RenderPompt) => void;
}) {
const noPrompts = props.prompts.length === 0;
const [selectIndex, setSelectIndex] = useState(0);
const selectedRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSelectIndex(0);
}, [props.prompts.length]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (noPrompts || e.metaKey || e.altKey || e.ctrlKey) {
return;
}
// arrow up / down to select prompt
const changeIndex = (delta: number) => {
e.stopPropagation();
e.preventDefault();
const nextIndex = Math.max(
0,
Math.min(props.prompts.length - 1, selectIndex + delta),
);
setSelectIndex(nextIndex);
selectedRef.current?.scrollIntoView({
block: "center",
});
};
if (e.key === "ArrowUp") {
changeIndex(1);
} else if (e.key === "ArrowDown") {
changeIndex(-1);
} else if (e.key === "Enter") {
const selectedPrompt = props.prompts.at(selectIndex);
if (selectedPrompt) {
props.onPromptSelect(selectedPrompt);
}
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.prompts.length, selectIndex]);
if (noPrompts) return null;
return (
<div className={styles["prompt-hints"]}>
{props.prompts.map((prompt, i) => (
<div
ref={i === selectIndex ? selectedRef : null}
className={
styles["prompt-hint"] +
` ${i === selectIndex ? styles["prompt-hint-selected"] : ""}`
}
key={prompt.title + i.toString()}
onClick={() => props.onPromptSelect(prompt)}
onMouseEnter={() => setSelectIndex(i)}
>
<div className={styles["hint-title"]}>{prompt.title}</div>
<div className={styles["hint-content"]}>{prompt.content}</div>
</div>
))}
</div>
);
}
function ClearContextDivider() {
const chatStore = useChatStore();
return (
<div
className={styles["clear-context"]}
onClick={() =>
chatStore.updateCurrentSession(
(session) => (session.clearContextIndex = undefined),
)
}
>
<div className={styles["clear-context-tips"]}>{Locale.Context.Clear}</div>
<div className={styles["clear-context-revert-btn"]}>
{Locale.Context.Revert}
</div>
</div>
);
}
function ChatAction(props: {
text: string;
icon: JSX.Element;
onClick: () => void;
}) {
const iconRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState({
full: 16,
icon: 16,
});
const allModels = useAllModels().map((item) => item.displayName);
let customModelClassName = "";
if (props.text.includes("使用") || allModels.includes(props.text)) {
customModelClassName = "chat-input-action-long-weight";
}
function updateWidth() {
if (!iconRef.current || !textRef.current) return;
const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width;
const textWidth = getWidth(textRef.current);
const iconWidth = getWidth(iconRef.current);
setWidth({
full: textWidth + iconWidth,
icon: iconWidth,
});
}
useEffect(() => {
if (customModelClassName !== "") {
updateWidth();
}
}, [props.text, customModelClassName]);
return (
<div
className={`${styles["chat-input-action"]} ${styles[customModelClassName]} clickable`}
onClick={() => {
props.onClick();
setTimeout(updateWidth, 1);
}}
onMouseEnter={updateWidth}
onTouchStart={updateWidth}
style={
{
"--icon-width": `${width.icon}px`,
"--full-width": `${width.full}px`,
} as React.CSSProperties
}
>
<div ref={iconRef} className={styles["icon"]}>
{props.icon}
</div>
<div className={styles["text"]} ref={textRef}>
{props.text}
</div>
</div>
);
}
function useScrollToBottom(
scrollRef: RefObject<HTMLDivElement>,
detach: boolean = false,
) {
// for auto-scroll
const [autoScroll, setAutoScroll] = useState(true);
function scrollDomToBottom() {
const dom = scrollRef.current;
if (dom) {
requestAnimationFrame(() => {
setAutoScroll(true);
dom.scrollTo(0, dom.scrollHeight);
});
}
}
// auto scroll
useEffect(() => {
if (autoScroll && !detach) {
scrollDomToBottom();
}
});
return {
scrollRef,
autoScroll,
setAutoScroll,
scrollDomToBottom,
};
}
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();
const chatStore = useChatStore();
// switch themes
const theme = config.theme;
function nextTheme() {
const themes = [Theme.Auto, Theme.Light, Theme.Dark];
const themeIndex = themes.indexOf(theme);
const nextIndex = (themeIndex + 1) % themes.length;
const nextTheme = themes[nextIndex];
config.update((config) => (config.theme = nextTheme));
}
// stop all responses
const couldStop = ChatControllerPool.hasPending();
const stopAll = () => ChatControllerPool.stopAll();
// switch model
const currentModel = chatStore.currentSession().mask.modelConfig.model;
const allModels = useAllModels();
const models = useMemo(() => {
const filteredModels = allModels.filter((m) => m.available);
const defaultModel = filteredModels.find((m) => m.isDefault);
if (defaultModel) {
const arr = [
defaultModel,
...filteredModels.filter((m) => m !== defaultModel),
];
return arr;
} else {
return filteredModels;
}
}, [allModels]);
const [showModelSelector, setShowModelSelector] = useState(false);
const [showUploadImage, setShowUploadImage] = useState(false);
const current_day_token = localStorage.getItem("current_day_token") ?? "";
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);
if (isUnavaliableModel && models.length > 0) {
// show next model to default model if exist
let nextModel: ModelType = (
models.find((model) => model.isDefault) || models[0]
).name as ModelType;
chatStore.updateCurrentSession(
(session) => (session.mask.modelConfig.model = nextModel),
);
showToast(nextModel);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatStore, currentModel, models]);
return (
<div className={styles["chat-input-actions"]}>
{couldStop && (
<ChatAction
onClick={stopAll}
text={Locale.Chat.InputActions.Stop}
icon={<StopIcon />}
/>
)}
{!props.hitBottom && (
<ChatAction
onClick={props.scrollToBottom}
text={Locale.Chat.InputActions.ToBottom}
icon={<BottomIcon />}
/>
)}
{/*{props.hitBottom && (*/}
{/* <ChatAction*/}
{/* onClick={props.showPromptModal}*/}
{/* text={Locale.Chat.InputActions.Settings}*/}
{/* icon={<SettingsIcon />}*/}
{/* />*/}
{/*)}*/}
{/*{showUploadImage && (*/}
<ChatAction
onClick={props.uploadImage}
text={Locale.Chat.InputActions.UploadImage}
icon={props.uploading ? <LoadingButtonIcon /> : <ImageIcon />}
/>
{/*)}*/}
<ChatAction
onClick={nextTheme}
text={Locale.Chat.InputActions.Theme[theme]}
icon={
<>
{theme === Theme.Auto ? (
<AutoIcon />
) : theme === Theme.Light ? (
<LightIcon />
) : theme === Theme.Dark ? (
<DarkIcon />
) : null}
</>
}
/>
{/*<ChatAction*/}
{/* onClick={props.showPromptHints}*/}
{/* text={Locale.Chat.InputActions.Prompt}*/}
{/* icon={<PromptIcon />}*/}
{/*/>*/}
{/*<ChatAction*/}
{/* onClick={() => {*/}
{/* navigate(Path.Masks);*/}
{/* }}*/}
{/* text={Locale.Chat.InputActions.Masks}*/}
{/* icon={<MaskIcon />}*/}
{/*/>*/}
<ChatAction
text={Locale.Chat.InputActions.Clear}
icon={<BreakIcon />}
onClick={() => {
chatStore.updateCurrentSession((session) => {
if (session.clearContextIndex === session.messages.length) {
session.clearContextIndex = undefined;
} else {
session.clearContextIndex = session.messages.length;
session.memoryPrompt = ""; // will clear memory
}
});
}}
/>
<ChatAction
onClick={() => setShowModelSelector(true)}
text={currentModel}
icon={<RobotIcon />}
/>
{/*<ChatAction*/}
{/* onClick={selectImage}*/}
{/* text={Locale.Chat.InputActions.UploadImage}*/}
{/* icon={<UploadIcon />}*/}
{/*/>*/}
{/*<input*/}
{/* type="file"*/}
{/* accept=".png,.jpg,.webp,.jpeg"*/}
{/* id="chat-image-file-select-upload"*/}
{/* style={{ display: "none" }}*/}
{/* onChange={onImageSelected}*/}
{/*/>*/}
{showModelSelector && (
<ModalSelector
defaultSelectedValue={currentModel}
items={models.map((m) => ({
title: m.displayName,
subTitle: m.describe,
value: m.name,
}))}
onClose={() => setShowModelSelector(false)}
onSelection={(s) => {
if (s.length === 0) return;
chatStore.updateCurrentSession((session) => {
session.mask.modelConfig.model = s[0] as ModelType;
session.mask.syncGlobalConfig = false;
});
showToast(s[0]);
}}
/>
)}
{/*<ChatAction*/}
{/* onClick={() => false}*/}
{/* text={"使用 " + current_day_token}*/}
{/* icon={*/}
{/* <img*/}
{/* alt="😀"*/}
{/* loading="lazy"*/}
{/* width="20"*/}
{/* height="20"*/}
{/* decoding="async"*/}
{/* srcSet="/grinning-face.webp"*/}
{/* style={{ color: "transparent" }}*/}
{/* />*/}
{/* }*/}
{/*/>*/}
</div>
);
}
export function EditMessageModal(props: { onClose: () => void }) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const [messages, setMessages] = useState(session.messages.slice());
return (
<div className="modal-mask">
<Modal
title={Locale.Chat.EditMessage.Title}
onClose={props.onClose}
actions={[
<IconButton
text={Locale.UI.Cancel}
icon={<CancelIcon />}
key="cancel"
onClick={() => {
props.onClose();
}}
/>,
<IconButton
type="primary"
text={Locale.UI.Confirm}
icon={<ConfirmIcon />}
key="ok"
onClick={() => {
chatStore.updateCurrentSession(
(session) => (session.messages = messages),
);
props.onClose();
}}
/>,
]}
>
<List>
<ListItem
title={Locale.Chat.EditMessage.Topic.Title}
subTitle={Locale.Chat.EditMessage.Topic.SubTitle}
>
<input
type="text"
value={session.topic}
onInput={(e) =>
chatStore.updateCurrentSession(
(session) => (session.topic = e.currentTarget.value),
)
}
></input>
</ListItem>
</List>
{/*<ContextPrompts*/}
{/* context={messages}*/}
{/* updateContext={(updater) => {*/}
{/* const newMessages = messages.slice();*/}
{/* updater(newMessages);*/}
{/* setMessages(newMessages);*/}
{/* }}*/}
{/*/>*/}
</Modal>
</div>
);
}
export function DeleteImageButton(props: { deleteImage: () => void }) {
return (
<div className={styles["delete-image"]} onClick={props.deleteImage}>
<DeleteIcon />
</div>
);
}
function _Chat() {
type RenderMessage = ChatMessage & { preview?: boolean };
const chatStore = useChatStore();
const session = chatStore.currentSession();
const config = useAppConfig();
const fontSize = config.fontSize;
const [showExport, setShowExport] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [userInput, setUserInput] = useState("");
const [mjImageMode, setMjImageMode] = useState<string>("IMAGINE");
const [isLoading, setIsLoading] = useState(false);
const { submitKey, shouldSubmit } = useSubmitHandler();
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();
const [promptHints, setPromptHints] = useState<RenderPompt[]>([]);
const onSearch = useDebouncedCallback(
(text: string) => {
const matchedPrompts = promptStore.search(text);
setPromptHints(matchedPrompts);
},
100,
{ leading: true, trailing: true },
);
// auto grow input
const [inputRows, setInputRows] = useState(2);
const measure = useDebouncedCallback(
() => {
const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
const inputRows = Math.min(
20,
Math.max(2 + Number(!isMobileScreen), rows),
);
setInputRows(inputRows);
},
100,
{
leading: true,
trailing: true,
},
);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(measure, [userInput]);
// chat commands shortcuts
const chatCommands = useChatCommand({
new: () => chatStore.newSession(),
newm: () => navigate(Path.NewChat),
prev: () => chatStore.nextSession(-1),
next: () => chatStore.nextSession(1),
clear: () =>
chatStore.updateCurrentSession(
(session) => (session.clearContextIndex = session.messages.length),
),
del: () => chatStore.deleteSession(chatStore.currentSessionIndex),
});
// only search prompts when user input is short
const SEARCH_TEXT_LIMIT = 30;
const onInput = (text: string) => {
setUserInput(text);
const n = text.trim().length;
// clear search results
if (n === 0) {
setPromptHints([]);
} else if (text.startsWith(ChatCommandPrefix)) {
setPromptHints(chatCommands.search(text));
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
// check if need to trigger auto completion
if (text.startsWith("/")) {
let searchText = text.slice(1);
onSearch(searchText);
}
}
};
const doSubmit = (userInput: string) => {
if (userInput.trim() === "") return;
const matchCommand = chatCommands.match(userInput);
if (matchCommand.matched) {
setUserInput("");
setPromptHints([]);
matchCommand.invoke();
return;
}
setIsLoading(true);
chatStore
.onUserInput(userInput, attachImages, {
mjImageMode,
setAutoScroll,
})
.then(() => setIsLoading(false));
setAttachImages([]);
localStorage.setItem(LAST_INPUT_KEY, userInput);
setUserInput("");
setMjImageMode("IMAGINE");
setPromptHints([]);
if (!isMobileScreen) inputRef.current?.focus();
setAutoScroll(true);
};
const onPromptSelect = (prompt: RenderPompt) => {
setTimeout(() => {
setPromptHints([]);
const matchedChatCommand = chatCommands.match(prompt.content);
if (matchedChatCommand.matched) {
// if user is selecting a chat command, just trigger it
matchedChatCommand.invoke();
setUserInput("");
} else {
// or fill the prompt
setUserInput(prompt.content);
}
inputRef.current?.focus();
}, 30);
};
// stop response
const onUserStop = (messageId: string) => {
ChatControllerPool.stop(session.id, messageId);
};
useEffect(() => {
chatStore.updateCurrentSession((session) => {
const stopTiming = Date.now() - REQUEST_TIMEOUT_MS;
session.messages.forEach((m) => {
// check if should stop all stale messages
if (m.isError || new Date(m.date).getTime() < stopTiming) {
if (m.streaming) {
m.streaming = false;
}
if (m.content.length === 0) {
m.isError = true;
m.content = prettyObject({
error: true,
message: "empty response",
});
}
}
});
// auto sync mask config from global config
// if (session.mask.syncGlobalConfig) {
// console.log("[Mask] syncing from global, name = ", session.mask.name);
// session.mask.modelConfig = { ...config.modelConfig };
// }
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// check if should send message
const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// if ArrowUp and no userInput, fill with last input
if (
e.key === "ArrowUp" &&
userInput.length <= 0 &&
!(e.metaKey || e.altKey || e.ctrlKey)
) {
setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
e.preventDefault();
return;
}
if (shouldSubmit(e) && promptHints.length === 0) {
doSubmit(userInput);
e.preventDefault();
}
};
const onRightClick = (e: any, message: ChatMessage) => {
// copy to clipboard
if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) {
if (userInput.length === 0) {
setUserInput(getMessageTextContent(message));
}
e.preventDefault();
}
};
const deleteMessage = (msgId?: string) => {
chatStore.updateCurrentSession(
(session) =>
(session.messages = session.messages.filter((m) => m.id !== msgId)),
);
};
const onDelete = (msgId: string) => {
deleteMessage(msgId);
};
const onResend = (message: ChatMessage) => {
// when it is resending a message
// 1. for a user's message, find the next bot response
// 2. for a bot's message, find the last user's input
// 3. delete original user input and bot's message
// 4. resend the user's input
const resendingIndex = session.messages.findIndex(
(m) => m.id === message.id,
);
if (resendingIndex < 0 || resendingIndex >= session.messages.length) {
console.error("[Chat] failed to find resending message", message);
return;
}
let userMessage: ChatMessage | undefined;
let botMessage: ChatMessage | undefined;
if (message.role === "assistant") {
// if it is resending a bot's message, find the user input for it
botMessage = message;
for (let i = resendingIndex; i >= 0; i -= 1) {
if (session.messages[i].role === "user") {
userMessage = session.messages[i];
break;
}
}
} else if (message.role === "user") {
// if it is resending a user's input, find the bot's response
userMessage = message;
for (let i = resendingIndex; i < session.messages.length; i += 1) {
if (session.messages[i].role === "assistant") {
botMessage = session.messages[i];
break;
}
}
}
if (userMessage === undefined) {
console.error("[Chat] failed to resend", message);
return;
}
// delete the original messages
deleteMessage(userMessage.id);
deleteMessage(botMessage?.id);
// resend the message
setIsLoading(true);
const textContent = getMessageTextContent(userMessage);
const images = getMessageImages(userMessage);
chatStore.onUserInput(textContent, images).then(() => setIsLoading(false));
inputRef.current?.focus();
};
const onPinMessage = (message: ChatMessage) => {
chatStore.updateCurrentSession((session) =>
session.mask.context.push(message),
);
showToast(Locale.Chat.Actions.PinToastContent, {
text: Locale.Chat.Actions.PinToastAction,
onClick: () => {
setShowPromptModal(true);
},
});
};
const context: RenderMessage[] = useMemo(() => {
return session.mask.hideContext ? [] : session.mask.context.slice();
}, [session.mask.context, session.mask.hideContext]);
const accessStore = useAccessStore();
if (
context.length === 0 &&
session.messages.at(0)?.content !== BOT_HELLO.content
) {
const copiedHello = Object.assign({}, BOT_HELLO);
if (!accessStore.isAuthorized()) {
copiedHello.content = Locale.Error.Unauthorized;
}
context.push(copiedHello);
}
// preview messages
const renderMessages = useMemo(() => {
return context
.concat(session.messages as RenderMessage[])
.concat(
isLoading
? [
{
...createMessage({
role: "assistant",
content: "……",
}),
preview: true,
},
]
: [],
)
.concat(
userInput.length > 0 && config.sendPreviewBubble
? [
{
...createMessage({
role: "user",
content: userInput,
}),
preview: true,
},
]
: [],
);
}, [
config.sendPreviewBubble,
context,
isLoading,
session.messages,
userInput,
]);
const [msgRenderIndex, _setMsgRenderIndex] = useState(
Math.max(0, renderMessages.length - CHAT_PAGE_SIZE),
);
function setMsgRenderIndex(newIndex: number) {
newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex);
newIndex = Math.max(0, newIndex);
_setMsgRenderIndex(newIndex);
}
const messages = useMemo(() => {
const endRenderIndex = Math.min(
msgRenderIndex + 3 * CHAT_PAGE_SIZE,
renderMessages.length,
);
return renderMessages.slice(msgRenderIndex, endRenderIndex);
}, [msgRenderIndex, renderMessages]);
const onChatBodyScroll = (e: HTMLElement) => {
const bottomHeight = e.scrollTop + e.clientHeight;
const edgeThreshold = e.clientHeight;
const isTouchTopEdge = e.scrollTop <= edgeThreshold;
const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold;
const isHitBottom =
bottomHeight >= e.scrollHeight - (isMobileScreen ? 4 : 10);
const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE;
const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE;
if (isTouchTopEdge && !isTouchBottomEdge) {
setMsgRenderIndex(prevPageMsgIndex);
} else if (isTouchBottomEdge) {
setMsgRenderIndex(nextPageMsgIndex);
}
setHitBottom(isHitBottom);
setAutoScroll(isHitBottom);
};
function scrollToBottom() {
setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
scrollDomToBottom();
}
// clear context index = context length + index in messages
const clearContextIndex =
(session.clearContextIndex ?? -1) >= 0
? session.clearContextIndex! + context.length - msgRenderIndex
: -1;
const [showPromptModal, setShowPromptModal] = useState(false);
const clientConfig = useMemo(() => getClientConfig(), []);
const autoFocus = !isMobileScreen; // wont auto focus on mobile screen
const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
useCommand({
fill: setUserInput,
submit: (text) => {
doSubmit(text);
},
code: (text) => {
if (accessStore.disableFastLink) return;
console.log("[Command] got code from url: ", text);
showConfirm(Locale.URLCommand.Code + `code = ${text}`).then((res) => {
if (res) {
accessStore.update((access) => (access.accessCode = text));
}
});
},
settings: (text) => {
if (accessStore.disableFastLink) return;
try {
const payload = JSON.parse(text) as {
key?: string;
url?: string;
};
console.log("[Command] got settings from url: ", payload);
if (payload.key || payload.url) {
showConfirm(
Locale.URLCommand.Settings +
`\n${JSON.stringify(payload, null, 4)}`,
).then((res) => {
if (!res) return;
if (payload.key) {
accessStore.update(
(access) => (access.openaiApiKey = payload.key!),
);
}
if (payload.url) {
accessStore.update((access) => (access.openaiUrl = payload.url!));
}
accessStore.update((access) => (access.useCustomConfig = true));
});
}
} catch {
console.error("[Command] failed to get settings from url: ", text);
}
},
});
// edit / insert message modal
const [isEditingMessage, setIsEditingMessage] = useState(false);
messages?.forEach((msg) => {
if (msg.model === "midjourney" && msg.attr.taskId) {
chatStore.fetchMidjourneyStatus(msg);
}
});
// remember unfinished input
useEffect(() => {
// try to load from local storage
const key = UNFINISHED_INPUT(session.id);
const mayBeUnfinishedInput = localStorage.getItem(key);
if (mayBeUnfinishedInput && userInput.length === 0) {
setUserInput(mayBeUnfinishedInput);
localStorage.removeItem(key);
}
const dom = inputRef.current;
return () => {
localStorage.setItem(key, dom?.value ?? "");
};
// 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);
}
// 加载状态结束获取token
const [loadingChange, setLoadingChange] = useState(false);
useEffect(() => {
if (!isLoading && loadingChange) {
try {
fetch("/api/logs/get_current_token", {
method: "GET",
credentials: "include",
})
.then((response) => response.json())
.then((result) => {
// console.log("请求成功,", result);
localStorage.setItem(
"current_day_token",
result["result"]["current_token"],
);
});
} catch {}
}
return () => {
setLoadingChange(isLoading);
};
}, [isLoading, loadingChange]);
// const [ voiceInputText, setVoiceInputText ] = useState("");
// const [ voiceInputLoading, setVoiceInputLoading ] = useState(false);
// useEffect(() => {
// if (voiceInputLoading) {
// // 正在进行语音输入,输入框应该显示原有文本加上语音输入的。
// setUserInput(userInput + voiceInputText);
// } else {
// // 但是语音输入结束,应该清理多余字符。
// console.log('end', userInput, voiceInputText)
// }
//
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [voiceInputLoading, voiceInputText]);
return (
<div className={styles.chat} key={session.id}>
<div className="window-header" data-tauri-drag-region>
{isMobileScreen && (
<div className="window-actions">
<div className={"window-action-button"}>
<IconButton
icon={<ReturnIcon />}
bordered
title={Locale.Chat.Actions.ChatList}
onClick={() => navigate(Path.Home)}
/>
</div>
</div>
)}
<div className={`window-header-title ${styles["chat-body-title"]}`}>
<div
className={`window-header-main-title ${styles["chat-body-main-title"]}`}
onClickCapture={() => setIsEditingMessage(true)}
>
{!session.topic ? DEFAULT_TOPIC : session.topic}
</div>
<div className="window-header-sub-title">
{/*{Locale.Chat.SubTitle(session.messages.length)}&nbsp;&nbsp;*/}
<Tooltip
title={
<span style={{ color: "black" }}>
{localStorage.getItem("current_day_token") ?? 0}{" "}
<span style={{ color: "black" }}>/ 100000</span>
</span>
}
color={"var(--second)"}
placement="bottom"
>
使
<Progress
percent={
(parseInt(localStorage.getItem("current_day_token") ?? "0") /
100000) *
100
}
size="small"
showInfo={false}
strokeColor={{ from: "green", to: "red" }}
/>
</Tooltip>
</div>
</div>
<div className="window-actions">
{!isMobileScreen && (
<div className="window-action-button">
<IconButton
icon={<RenameIcon />}
bordered
onClick={() => setIsEditingMessage(true)}
/>
</div>
)}
<div className="window-action-button">
<IconButton
icon={<ExportIcon />}
bordered
title={Locale.Chat.Actions.Export}
onClick={() => {
setShowExport(true);
}}
/>
</div>
{showMaxIcon && (
<div className="window-action-button">
<IconButton
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
bordered
onClick={() => {
config.update(
(config) => (config.tightBorder = !config.tightBorder),
);
}}
/>
</div>
)}
</div>
<PromptToast
showToast={!hitBottom}
showModal={showPromptModal}
setShowModal={setShowPromptModal}
/>
</div>
<div
className={styles["chat-body"]}
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
onMouseDown={() => inputRef.current?.blur()}
onTouchStart={() => {
inputRef.current?.blur();
setAutoScroll(false);
}}
>
{messages.map((message, i) => {
const isUser = message.role === "user";
const isContext = i < context.length;
const showActions =
i > 0 &&
!(message.preview || message.content.length === 0) &&
!isContext;
const showTyping = message.preview || message.streaming;
const shouldShowClearContextDivider = i === clearContextIndex - 1;
return (
<Fragment key={message.id}>
<div
className={
isUser ? styles["chat-message-user"] : styles["chat-message"]
}
>
<div className={styles["chat-message-container"]}>
<div className={styles["chat-message-header"]}>
<div className={styles["chat-message-avatar"]}>
<div className={styles["chat-message-edit"]}>
<IconButton
icon={<EditIcon />}
onClick={async () => {
const newMessage = await showPrompt(
Locale.Chat.Actions.Edit,
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 = newContent;
}
});
}}
></IconButton>
</div>
{isUser ? (
<Avatar avatar={config.avatar} />
) : (
<>
{/*{["system"].includes(message.role) ? (*/}
{/* <Avatar avatar="2699-fe0f" />*/}
{/*) : (*/}
{/* <MaskAvatar*/}
{/* avatar={session.mask.avatar}*/}
{/* model={*/}
{/* message.model || session.mask.modelConfig.model*/}
{/* }*/}
{/* />*/}
{/*)}*/}
</>
)}
</div>
<div className={styles["chat-message-action-date"]}>
{isContext
? Locale.Chat.IsContext
: message.date.toLocaleString()}
</div>
</div>
{showTyping && (
<div className={styles["chat-message-status"]}>
{Locale.Chat.Typing}
</div>
)}
<div className={styles["chat-message-item"]}>
<Markdown
content={getMessageTextContent(message)}
loading={
(message.preview || message.streaming) &&
message.content.length === 0 &&
!isUser
}
// onContextMenu={(e) => onRightClick(e, message)}
// onDoubleClickCapture={() => {
// if (!isMobileScreen) return;
// setUserInput(getMessageTextContent(message));
// }}
fontSize={fontSize}
parentRef={scrollRef}
defaultShow={i >= messages.length - 6}
/>
{getMessageImages(message).length == 1 && (
<Image
className={styles["chat-message-item-image"]}
src={getMessageImages(message)[0]}
alt=""
width={100}
height={100}
/>
)}
{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 (
<Image
className={
styles["chat-message-item-image-multi"]
}
key={index}
src={image}
alt=""
width={20}
height={20}
/>
);
})}
</div>
)}
</div>
{!isUser &&
message.model == "midjourney" &&
message.attr?.finished &&
["VARIATION", "IMAGINE", "BLEND"].includes(
message.attr?.action,
) && (
<div
className={[
styles["chat-message-actions"],
styles["column-flex"],
].join(" ")}
>
<div>
<button
onClick={() =>
doSubmit(`/mj UPSCALE::1::${message.attr.taskId}`)
}
className={`${styles["chat-message-action-btn"]} clickable`}
>
U1
</button>
<button
onClick={() =>
doSubmit(`/mj UPSCALE::2::${message.attr.taskId}`)
}
className={`${styles["chat-message-action-btn"]} clickable`}
>
U2
</button>
<button
onClick={() =>
doSubmit(`/mj UPSCALE::3::${message.attr.taskId}`)
}
className={`${styles["chat-message-action-btn"]} clickable`}
>
U3
</button>
<button
onClick={() =>
doSubmit(`/mj UPSCALE::4::${message.attr.taskId}`)
}
className={`${styles["chat-message-action-btn"]} clickable`}
>
U4
</button>
{/*<button onClick={() => doSubmit(`/mj REROLL::0::${message.attr.taskId}`)} className={`${styles["chat-message-action-btn"]} clickable`}>RESET</button>*/}
</div>
<div>
<button
onClick={() =>
doSubmit(
`/mj VARIATION::1::${message.attr.taskId}`,
)
}
className={`${styles["chat-message-action-btn"]} clickable`}
>
V1
</button>
<button
onClick={() =>
doSubmit(
`/mj VARIATION::2::${message.attr.taskId}`,
)
}
className={`${styles["chat-message-action-btn"]} clickable`}
>
V2
</button>
<button
onClick={() =>
doSubmit(
`/mj VARIATION::3::${message.attr.taskId}`,
)
}
className={`${styles["chat-message-action-btn"]} clickable`}
>
V3
</button>
<button
onClick={() =>
doSubmit(
`/mj VARIATION::4::${message.attr.taskId}`,
)
}
className={`${styles["chat-message-action-btn"]} clickable`}
>
V4
</button>
</div>
</div>
)}
{showActions && (
<div
className={styles["chat-message-actions"]}
style={{
marginTop: "10px",
marginBottom: "0px",
}}
>
<div className={styles["chat-input-actions"]}>
{message.streaming ? (
<ChatAction
text={Locale.Chat.Actions.Stop}
icon={<StopIcon />}
onClick={() => onUserStop(message.id ?? i)}
/>
) : (
<>
<ChatAction
text={Locale.Chat.Actions.Retry}
icon={<ResetIcon />}
onClick={() => onResend(message)}
/>
<ChatAction
text={Locale.Chat.Actions.Delete}
icon={<DeleteIcon />}
onClick={() => onDelete(message.id ?? i)}
/>
<ChatAction
text={Locale.Chat.Actions.Pin}
icon={<PinIcon />}
onClick={() => onPinMessage(message)}
/>
<ChatAction
text={Locale.Chat.Actions.Copy}
icon={<CopyIcon />}
onClick={() =>
copyToClipboard(getMessageTextContent(message))
}
/>
</>
)}
</div>
</div>
)}
</div>
</div>
{shouldShowClearContextDivider && <ClearContextDivider />}
</Fragment>
);
})}
</div>
<div className={styles["chat-input-panel"]}>
{/*<GptPrompts />*/}
<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) {
setPromptHints([]);
return;
}
inputRef.current?.focus();
setUserInput("/");
onSearch("");
}}
/>
<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)}
onInput={(e) => onInput(e.currentTarget.value)}
value={userInput}
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>
)}
<div className={styles["chat-input-send-area"]}>
<VoiceInput
// voiceInputText={voiceInputText}
// setVoiceInputText={setVoiceInputText}
// voiceInputLoading={voiceInputLoading}
// setVoiceInputLoading={setVoiceInputLoading}
userInput={userInput}
setUserInput={setUserInput}
/>
</div>
<IconButton
icon={<SendWhiteIcon />}
text={Locale.Chat.Send}
className={styles["chat-input-send"]}
type="primary"
onClick={() => doSubmit(userInput)}
/>
</label>
</div>
<div
className={styles["bottom-tip"]}
// style={{
// color: "var(--text-secondary)",
// fontSize: ".75rem",
// lineHeight: "1rem",
// textAlign: "center",
// paddingBottom: ".5rem",
// paddingTop: ".5rem"
// }}
>
<span>AI </span>
</div>
{showExport && (
<ExportMessageModal onClose={() => setShowExport(false)} />
)}
{isEditingMessage && (
<EditMessageModal
onClose={() => {
setIsEditingMessage(false);
}}
/>
)}
</div>
);
}
// function getCurrentDayToken(sessions: ChatSession[]): number {
// try {
// const currentTime = new Date();
// const startOfTheDayInTimeZone = new Date(
// currentTime.getFullYear(),
// currentTime.getMonth(),
// currentTime.getDate(),
// 0,
// 0,
// 0,
// );
// const current_day_message = sessions
// .reduce((acc, item) => {
// // @ts-ignore
// return acc.concat(item.messages);
// }, [])
// .filter((item1) => {
// // @ts-ignore
// const dateToCheck = new Date(item1.date);
// return startOfTheDayInTimeZone < dateToCheck;
// });
// const all_current_day_content = current_day_message
// // @ts-ignore
// .map((item) => item.content)
// .join(" ");
// // 获取会话之后再整合content
// return getTokenLength(all_current_day_content);
// } catch (e) {
// return 0;
// }
// }
export function Chat() {
const chatStore = useChatStore();
const sessionIndex = chatStore.currentSessionIndex;
// 这里计先计算一下当天总token数。
// localStorage.setItem(
// "current_day_token",
// String(getCurrentDayToken(chatStore.sessions)),
// );
return <_Chat key={sessionIndex}></_Chat>;
}