ChatGPT-Next-Web/app/components/chat.tsx

2246 lines
74 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, {
Fragment,
RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import SendWhiteIcon from "../icons/send-white.svg";
import BrainIcon from "../icons/brain.svg";
import RenameIcon from "../icons/rename.svg";
import EditIcon from "../icons/rename.svg";
import ExportIcon from "../icons/share.svg";
import ReturnIcon from "../icons/return.svg";
import CopyIcon from "../icons/copy.svg";
import SpeakIcon from "../icons/speak.svg";
import SpeakStopIcon from "../icons/speak-stop.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 ReloadIcon 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 ConfirmIcon from "../icons/confirm.svg";
import CloseIcon from "../icons/close.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 SizeIcon from "../icons/size.svg";
import QualityIcon from "../icons/hd.svg";
import StyleIcon from "../icons/palette.svg";
import PluginIcon from "../icons/plugin.svg";
import ShortcutkeyIcon from "../icons/shortcutkey.svg";
import McpToolIcon from "../icons/tool.svg";
import HeadphoneIcon from "../icons/headphone.svg";
import {
BOT_HELLO,
ChatMessage,
createMessage,
DEFAULT_TOPIC,
ModelType,
SubmitKey,
Theme,
useAccessStore,
useAppConfig,
useChatStore,
usePluginStore,
} from "../store";
import {
autoGrowTextArea,
copyToClipboard,
getMessageImages,
getMessageTextContent,
getModelSizes,
isDalle3,
isVisionModel,
safeLocalStorage,
selectOrCopy,
showPlugins,
supportsCustomSize,
useMobileScreen,
} from "../utils";
import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
import dynamic from "next/dynamic";
import { ChatControllerPool } from "../client/controller";
import { DalleQuality, DalleStyle, ModelSize } from "../typing";
import { Prompt, usePromptStore } from "../store/prompt";
import Locale from "../locales";
import { IconButton } from "./button";
import styles from "./chat.module.scss";
import {
List,
ListItem,
Modal,
Selector,
showConfirm,
showPrompt,
showToast,
} from "./ui-lib";
import { useNavigate } from "react-router-dom";
import {
CHAT_PAGE_SIZE,
DEFAULT_TTS_ENGINE,
ModelProvider,
Path,
REQUEST_TIMEOUT_MS,
ServiceProvider,
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 { useAllModels } from "../utils/hooks";
import { ClientApi, MultimodalContent } from "../client/api";
import { createTTSPlayer } from "../utils/audio";
import { MsEdgeTTS, OUTPUT_FORMAT } from "../utils/ms_edge_tts";
import { isEmpty } from "lodash-es";
import { getModelProvider } from "../utils/model";
import { RealtimeChat } from "@/app/components/realtime-chat";
import clsx from "clsx";
import { getAvailableClientsCount, isMcpEnabled } from "../mcp/actions";
const localStorage = safeLocalStorage();
const ttsPlayer = createTTSPlayer();
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
});
const MCPAction = () => {
const navigate = useNavigate();
const [count, setCount] = useState<number>(0);
const [mcpEnabled, setMcpEnabled] = useState(false);
useEffect(() => {
const checkMcpStatus = async () => {
const enabled = await isMcpEnabled();
setMcpEnabled(enabled);
if (enabled) {
const count = await getAvailableClientsCount();
setCount(count);
}
};
checkMcpStatus();
}, []);
if (!mcpEnabled) return null;
return (
<ChatAction
onClick={() => navigate(Path.McpMarket)}
text={`MCP${count ? ` (${count})` : ""}`}
icon={<McpToolIcon />}
/>
);
};
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.updateTargetSession(
session,
(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.updateTargetSession(
session,
(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 && context.length > 0 && (
<div
className={clsx(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 RenderPrompt = Pick<Prompt, "title" | "content">;
export function PromptHints(props: {
prompts: RenderPrompt[];
onPromptSelect: (prompt: RenderPrompt) => 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={clsx(styles["prompt-hint"], {
[styles["prompt-hint-selected"]]: i === selectIndex,
})}
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();
const session = chatStore.currentSession();
return (
<div
className={styles["clear-context"]}
onClick={() =>
chatStore.updateTargetSession(
session,
(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>
);
}
export 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,
});
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,
});
}
return (
<div
className={clsx(styles["chat-input-action"], "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,
messages: ChatMessage[],
) {
// for auto-scroll
const [autoScroll, setAutoScroll] = useState(true);
const scrollDomToBottom = useCallback(() => {
const dom = scrollRef.current;
if (dom) {
requestAnimationFrame(() => {
setAutoScroll(true);
dom.scrollTo(0, dom.scrollHeight);
});
}
}, [scrollRef]);
// auto scroll
useEffect(() => {
if (autoScroll && !detach) {
scrollDomToBottom();
}
});
// auto scroll when messages length changes
const lastMessagesLength = useRef(messages.length);
useEffect(() => {
if (messages.length > lastMessagesLength.current && !detach) {
scrollDomToBottom();
}
lastMessagesLength.current = messages.length;
}, [messages.length, 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;
setShowShortcutKeyModal: React.Dispatch<React.SetStateAction<boolean>>;
setUserInput: (input: string) => void;
setShowChatSidePanel: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const config = useAppConfig();
const navigate = useNavigate();
const chatStore = useChatStore();
const pluginStore = usePluginStore();
const session = chatStore.currentSession();
// 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 = session.mask.modelConfig.model;
const currentProviderName =
session.mask.modelConfig?.providerName || ServiceProvider.OpenAI;
const allModels = useAllModels();
const models = useMemo(() => {
const filteredModels = allModels.filter((m) => m.available);
const defaultModel = filteredModels.find((m) => m.isDefault);
if (defaultModel) {
return [
defaultModel,
...filteredModels.filter((m) => m !== defaultModel),
];
} else {
return filteredModels;
}
}, [allModels]);
const currentModelName = useMemo(() => {
const model = models.find(
(m) =>
m.name == currentModel &&
m?.provider?.providerName == currentProviderName,
);
return model?.displayName ?? "";
}, [models, currentModel, currentProviderName]);
const [showModelSelector, setShowModelSelector] = useState(false);
const [showProviderSelector, setShowProviderSelector] = useState(false);
const [showPluginSelector, setShowPluginSelector] = useState(false);
const [showUploadImage, setShowUploadImage] = useState(false);
const [showSizeSelector, setShowSizeSelector] = useState(false);
const [showQualitySelector, setShowQualitySelector] = useState(false);
const [showStyleSelector, setShowStyleSelector] = useState(false);
const modelSizes = getModelSizes(currentModel);
const dalle3Qualitys: DalleQuality[] = ["standard", "hd"];
const dalle3Styles: DalleStyle[] = ["vivid", "natural"];
const currentSize =
session.mask.modelConfig?.size ?? ("1024x1024" as ModelSize);
const currentQuality = session.mask.modelConfig?.quality ?? "standard";
const currentStyle = session.mask.modelConfig?.style ?? "vivid";
const isMobileScreen = useMobileScreen();
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 isUnavailableModel = !models.some((m) => m.name === currentModel);
if (isUnavailableModel && models.length > 0) {
// show next model to default model if exist
let nextModel = models.find((model) => model.isDefault) || models[0];
chatStore.updateTargetSession(session, (session) => {
session.mask.modelConfig.model = nextModel.name;
session.mask.modelConfig.providerName = nextModel?.provider
?.providerName as ServiceProvider;
});
showToast(
nextModel?.provider?.providerName == "ByteDance"
? nextModel.displayName
: nextModel.name,
);
}
}, [chatStore, currentModel, models, session]);
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.updateTargetSession(session, (session) => {
if (session.clearContextIndex === session.messages.length) {
session.clearContextIndex = undefined;
} else {
session.clearContextIndex = session.messages.length;
session.memoryPrompt = ""; // will clear memory
}
});
}}
/>
<ChatAction
onClick={() => setShowProviderSelector(true)}
text={currentProviderName}
icon={<BrainIcon />}
/>
<ChatAction
onClick={() => setShowModelSelector(true)}
text={currentModelName}
icon={<RobotIcon />}
/>
{showProviderSelector && (
<Selector
defaultSelectedValue={currentProviderName}
items={Object.entries(ServiceProvider)
.filter(([_, value]) => {
const accessStore = useAccessStore.getState();
switch (value) {
case ServiceProvider.OpenAI:
return true; // 始终保留OpenAI选项即使没有配置API密钥
case ServiceProvider.Azure:
return accessStore.isValidAzure();
case ServiceProvider.Google:
return accessStore.isValidGoogle();
case ServiceProvider.Anthropic:
return accessStore.isValidAnthropic();
case ServiceProvider.Baidu:
return accessStore.isValidBaidu();
case ServiceProvider.ByteDance:
return accessStore.isValidByteDance();
case ServiceProvider.Alibaba:
return accessStore.isValidAlibaba();
case ServiceProvider.Tencent:
return accessStore.isValidTencent();
case ServiceProvider.Moonshot:
return accessStore.isValidMoonshot();
case ServiceProvider.Iflytek:
return accessStore.isValidIflytek();
case ServiceProvider.DeepSeek:
return accessStore.isValidDeepSeek();
case ServiceProvider.XAI:
return accessStore.isValidXAI();
case ServiceProvider.ChatGLM:
return accessStore.isValidChatGLM();
case ServiceProvider.SiliconFlow:
return accessStore.isValidSiliconFlow();
case ServiceProvider.Stability:
return true; // 假设不需要验证
default:
return false;
}
})
.map(([name, value]) => ({
title: name,
value: value,
}))}
onClose={() => setShowProviderSelector(false)}
onSelection={(s) => {
if (s.length === 0) return;
const provider = s[0] as ServiceProvider;
chatStore.updateTargetSession(session, (session) => {
session.mask.modelConfig.providerName = provider;
const filteredModels = models.filter(
(m) => m.available && m.provider?.providerName === provider,
);
if (filteredModels.length > 0) {
// 选择新的服务商后,自动选择该服务商下的第一个模型
session.mask.modelConfig.model = filteredModels[0]
.name as ModelType;
session.mask.syncGlobalConfig = false;
}
});
showToast(s[0]);
}}
/>
)}
{showModelSelector && (
<Selector
defaultSelectedValue={`${currentModel}@${currentProviderName}`}
items={models
.filter(
(m) =>
m.available &&
m.provider?.providerName === currentProviderName,
)
.map((m) => ({
title: m.displayName,
value: `${m.name}@${m?.provider?.providerName}`,
}))}
onClose={() => setShowModelSelector(false)}
onSelection={(s) => {
if (s.length === 0) return;
const [model, providerName] = getModelProvider(s[0]);
chatStore.updateTargetSession(session, (session) => {
session.mask.modelConfig.model = model as ModelType;
session.mask.modelConfig.providerName =
providerName as ServiceProvider;
session.mask.syncGlobalConfig = false;
});
if (providerName == "ByteDance") {
const selectedModel = models.find(
(m) =>
m.name == model &&
m?.provider?.providerName == providerName,
);
showToast(selectedModel?.displayName ?? "");
} else {
showToast(model);
}
}}
/>
)}
{supportsCustomSize(currentModel) && (
<ChatAction
onClick={() => setShowSizeSelector(true)}
text={currentSize}
icon={<SizeIcon />}
/>
)}
{showSizeSelector && (
<Selector
defaultSelectedValue={currentSize}
items={modelSizes.map((m) => ({
title: m,
value: m,
}))}
onClose={() => setShowSizeSelector(false)}
onSelection={(s) => {
if (s.length === 0) return;
const size = s[0];
chatStore.updateTargetSession(session, (session) => {
session.mask.modelConfig.size = size;
});
showToast(size);
}}
/>
)}
{isDalle3(currentModel) && (
<ChatAction
onClick={() => setShowQualitySelector(true)}
text={currentQuality}
icon={<QualityIcon />}
/>
)}
{showQualitySelector && (
<Selector
defaultSelectedValue={currentQuality}
items={dalle3Qualitys.map((m) => ({
title: m,
value: m,
}))}
onClose={() => setShowQualitySelector(false)}
onSelection={(q) => {
if (q.length === 0) return;
const quality = q[0];
chatStore.updateTargetSession(session, (session) => {
session.mask.modelConfig.quality = quality;
});
showToast(quality);
}}
/>
)}
{isDalle3(currentModel) && (
<ChatAction
onClick={() => setShowStyleSelector(true)}
text={currentStyle}
icon={<StyleIcon />}
/>
)}
{showStyleSelector && (
<Selector
defaultSelectedValue={currentStyle}
items={dalle3Styles.map((m) => ({
title: m,
value: m,
}))}
onClose={() => setShowStyleSelector(false)}
onSelection={(s) => {
if (s.length === 0) return;
const style = s[0];
chatStore.updateTargetSession(session, (session) => {
session.mask.modelConfig.style = style;
});
showToast(style);
}}
/>
)}
{showPlugins(currentProviderName, currentModel) && (
<ChatAction
onClick={() => {
if (pluginStore.getAll().length == 0) {
navigate(Path.Plugins);
} else {
setShowPluginSelector(true);
}
}}
text={Locale.Plugin.Name}
icon={<PluginIcon />}
/>
)}
{showPluginSelector && (
<Selector
multiple
defaultSelectedValue={chatStore.currentSession().mask?.plugin}
items={pluginStore.getAll().map((item) => ({
title: `${item?.title}@${item?.version}`,
value: item?.id,
}))}
onClose={() => setShowPluginSelector(false)}
onSelection={(s) => {
chatStore.updateTargetSession(session, (session) => {
session.mask.plugin = s as string[];
});
}}
/>
)}
{!isMobileScreen && (
<ChatAction
onClick={() => props.setShowShortcutKeyModal(true)}
text={Locale.Chat.ShortcutKey.Title}
icon={<ShortcutkeyIcon />}
/>
)}
{!isMobileScreen && <MCPAction />}
</>
<div className={styles["chat-input-actions-end"]}>
{config.realtimeConfig.enable && (
<ChatAction
onClick={() => props.setShowChatSidePanel(true)}
text={"Realtime Chat"}
icon={<HeadphoneIcon />}
/>
)}
</div>
</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.updateTargetSession(
session,
(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.updateTargetSession(
session,
(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>
);
}
export function ShortcutKeyModal(props: { onClose: () => void }) {
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
const shortcuts = [
{
title: Locale.Chat.ShortcutKey.newChat,
keys: isMac ? ["⌘", "Shift", "O"] : ["Ctrl", "Shift", "O"],
},
{ title: Locale.Chat.ShortcutKey.focusInput, keys: ["Shift", "Esc"] },
{
title: Locale.Chat.ShortcutKey.copyLastCode,
keys: isMac ? ["⌘", "Shift", ";"] : ["Ctrl", "Shift", ";"],
},
{
title: Locale.Chat.ShortcutKey.copyLastMessage,
keys: isMac ? ["⌘", "Shift", "C"] : ["Ctrl", "Shift", "C"],
},
{
title: Locale.Chat.ShortcutKey.showShortcutKey,
keys: isMac ? ["⌘", "/"] : ["Ctrl", "/"],
},
{
title: Locale.Chat.ShortcutKey.clearContext,
keys: isMac
? ["⌘", "Shift", "backspace"]
: ["Ctrl", "Shift", "backspace"],
},
];
return (
<div className="modal-mask">
<Modal
title={Locale.Chat.ShortcutKey.Title}
onClose={props.onClose}
actions={[
<IconButton
type="primary"
text={Locale.UI.Confirm}
icon={<ConfirmIcon />}
key="ok"
onClick={() => {
props.onClose();
}}
/>,
]}
>
<div className={styles["shortcut-key-container"]}>
<div className={styles["shortcut-key-grid"]}>
{shortcuts.map((shortcut, index) => (
<div key={index} className={styles["shortcut-key-item"]}>
<div className={styles["shortcut-key-title"]}>
{shortcut.title}
</div>
<div className={styles["shortcut-key-keys"]}>
{shortcut.keys.map((key, i) => (
<div key={i} className={styles["shortcut-key"]}>
<span>{key}</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
</Modal>
</div>
);
}
function _Chat() {
type RenderMessage = ChatMessage & { preview?: boolean };
const chatStore = useChatStore();
const session = chatStore.currentSession();
const config = useAppConfig();
const fontSize = config.fontSize;
const fontFamily = config.fontFamily;
const [showExport, setShowExport] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [userInput, setUserInput] = useState("");
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 isAttachWithTop = useMemo(() => {
const lastMessage = scrollRef.current?.lastElementChild as HTMLElement;
// if scrolllRef is not ready or no message, return false
if (!scrollRef?.current || !lastMessage) return false;
const topDistance =
lastMessage!.getBoundingClientRect().top -
scrollRef.current.getBoundingClientRect().top;
// leave some space for user question
return topDistance < 100;
}, [scrollRef?.current?.scrollHeight]);
const isTyping = userInput !== "";
// if user is typing, should auto scroll to bottom
// if user is not typing, should auto scroll to bottom only if already at bottom
const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(
scrollRef,
(isScrolledToBottom || isAttachWithTop) && !isTyping,
session.messages,
);
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<RenderPrompt[]>([]);
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.updateTargetSession(
session,
(session) => (session.clearContextIndex = session.messages.length),
),
fork: () => chatStore.forkSession(),
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.match(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() === "" && isEmpty(attachImages)) return;
const matchCommand = chatCommands.match(userInput);
if (matchCommand.matched) {
setUserInput("");
setPromptHints([]);
matchCommand.invoke();
return;
}
setIsLoading(true);
chatStore
.onUserInput(userInput, attachImages)
.then(() => setIsLoading(false));
setAttachImages([]);
chatStore.setLastInput(userInput);
setUserInput("");
setPromptHints([]);
if (!isMobileScreen) inputRef.current?.focus();
setAutoScroll(true);
};
const onPromptSelect = (prompt: RenderPrompt) => {
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.updateTargetSession(session, (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
}, [session]);
// 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(chatStore.lastInput ?? "");
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.updateTargetSession(
session,
(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.updateTargetSession(session, (session) =>
session.mask.context.push(message),
);
showToast(Locale.Chat.Actions.PinToastContent, {
text: Locale.Chat.Actions.PinToastAction,
onClick: () => {
setShowPromptModal(true);
},
});
};
const accessStore = useAccessStore();
const [speechStatus, setSpeechStatus] = useState(false);
const [speechLoading, setSpeechLoading] = useState(false);
async function openaiSpeech(text: string) {
if (speechStatus) {
ttsPlayer.stop();
setSpeechStatus(false);
} else {
var api: ClientApi;
api = new ClientApi(ModelProvider.GPT);
const config = useAppConfig.getState();
setSpeechLoading(true);
ttsPlayer.init();
let audioBuffer: ArrayBuffer;
const { markdownToTxt } = require("markdown-to-txt");
const textContent = markdownToTxt(text);
if (config.ttsConfig.engine !== DEFAULT_TTS_ENGINE) {
const edgeVoiceName = accessStore.edgeVoiceName();
const tts = new MsEdgeTTS();
await tts.setMetadata(
edgeVoiceName,
OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3,
);
audioBuffer = await tts.toArrayBuffer(textContent);
} else {
audioBuffer = await api.llm.speech({
model: config.ttsConfig.model,
input: textContent,
voice: config.ttsConfig.voice,
speed: config.ttsConfig.speed,
});
}
setSpeechStatus(true);
ttsPlayer
.play(audioBuffer, () => {
setSpeechStatus(false);
})
.catch((e) => {
console.error("[OpenAI Speech]", e);
showToast(prettyObject(e));
setSpeechStatus(false);
})
.finally(() => setSpeechLoading(false));
}
}
const context: RenderMessage[] = useMemo(() => {
return session.mask.hideContext ? [] : session.mask.context.slice();
}, [session.mask.context, session.mask.hideContext]);
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);
// 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[] = [];
uploadImageRemote(file)
.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];
uploadImageRemote(file)
.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);
}
// 快捷键 shortcut keys
const [showShortcutKeyModal, setShowShortcutKeyModal] = useState(false);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// 打开新聊天 command + shift + o
if (
(event.metaKey || event.ctrlKey) &&
event.shiftKey &&
event.key.toLowerCase() === "o"
) {
event.preventDefault();
setTimeout(() => {
chatStore.newSession();
navigate(Path.Chat);
}, 10);
}
// 聚焦聊天输入 shift + esc
else if (event.shiftKey && event.key.toLowerCase() === "escape") {
event.preventDefault();
inputRef.current?.focus();
}
// 复制最后一个代码块 command + shift + ;
else if (
(event.metaKey || event.ctrlKey) &&
event.shiftKey &&
event.code === "Semicolon"
) {
event.preventDefault();
const copyCodeButton =
document.querySelectorAll<HTMLElement>(".copy-code-button");
if (copyCodeButton.length > 0) {
copyCodeButton[copyCodeButton.length - 1].click();
}
}
// 复制最后一个回复 command + shift + c
else if (
(event.metaKey || event.ctrlKey) &&
event.shiftKey &&
event.key.toLowerCase() === "c"
) {
event.preventDefault();
const lastNonUserMessage = messages
.filter((message) => message.role !== "user")
.pop();
if (lastNonUserMessage) {
const lastMessageContent = getMessageTextContent(lastNonUserMessage);
copyToClipboard(lastMessageContent);
}
}
// 展示快捷键 command + /
else if ((event.metaKey || event.ctrlKey) && event.key === "/") {
event.preventDefault();
setShowShortcutKeyModal(true);
}
// 清除上下文 command + shift + backspace
else if (
(event.metaKey || event.ctrlKey) &&
event.shiftKey &&
event.key.toLowerCase() === "backspace"
) {
event.preventDefault();
chatStore.updateTargetSession(session, (session) => {
if (session.clearContextIndex === session.messages.length) {
session.clearContextIndex = undefined;
} else {
session.clearContextIndex = session.messages.length;
session.memoryPrompt = ""; // will clear memory
}
});
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [messages, chatStore, navigate, session]);
const [showChatSidePanel, setShowChatSidePanel] = useState(false);
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={clsx("window-header-title", styles["chat-body-title"])}
>
<div
className={clsx(
"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)}
</div>
</div>
<div className="window-actions">
<div className="window-action-button">
<IconButton
icon={<ReloadIcon />}
bordered
title={Locale.Chat.Actions.RefreshTitle}
onClick={() => {
showToast(Locale.Chat.Actions.RefreshToast);
chatStore.summarizeSession(true, session);
}}
/>
</div>
{!isMobileScreen && (
<div className="window-action-button">
<IconButton
icon={<RenameIcon />}
bordered
title={Locale.Chat.EditMessage.Title}
aria={Locale.Chat.EditMessage.Title}
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
title={Locale.Chat.Actions.FullScreen}
aria={Locale.Chat.Actions.FullScreen}
onClick={() => {
config.update(
(config) => (config.tightBorder = !config.tightBorder),
);
}}
/>
</div>
)}
</div>
<PromptToast
showToast={!hitBottom}
showModal={showPromptModal}
setShowModal={setShowPromptModal}
/>
</div>
<div className={styles["chat-main"]}>
<div className={styles["chat-body-container"]}>
<div
className={styles["chat-body"]}
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
onMouseDown={() => inputRef.current?.blur()}
onTouchStart={() => {
inputRef.current?.blur();
setAutoScroll(false);
}}
>
{messages
// TODO
// .filter((m) => !m.isMcpResponse)
.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 />}
aria={Locale.Chat.Actions.Edit}
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.updateTargetSession(
session,
(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>
{!isUser && (
<div className={styles["chat-model-name"]}>
{message.model}
</div>
)}
{showActions && (
<div className={styles["chat-message-actions"]}>
<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),
)
}
/>
{config.ttsConfig.enable && (
<ChatAction
text={
speechStatus
? Locale.Chat.Actions.StopSpeech
: Locale.Chat.Actions.Speech
}
icon={
speechStatus ? (
<SpeakStopIcon />
) : (
<SpeakIcon />
)
}
onClick={() =>
openaiSpeech(
getMessageTextContent(message),
)
}
/>
)}
</>
)}
</div>
</div>
)}
</div>
{message?.tools?.length == 0 && showTyping && (
<div className={styles["chat-message-status"]}>
{Locale.Chat.Typing}
</div>
)}
{/*@ts-ignore*/}
{message?.tools?.length > 0 && (
<div className={styles["chat-message-tools"]}>
{message?.tools?.map((tool) => (
<div
key={tool.id}
title={tool?.errorMsg}
className={styles["chat-message-tool"]}
>
{tool.isError === false ? (
<ConfirmIcon />
) : tool.isError === true ? (
<CloseIcon />
) : (
<LoadingButtonIcon />
)}
<span>{tool?.function?.name}</span>
</div>
))}
</div>
)}
<div className={styles["chat-message-item"]}>
<Markdown
key={message.streaming ? "loading" : "done"}
content={getMessageTextContent(message)}
loading={
(message.preview || message.streaming) &&
message.content.length === 0 &&
!isUser
}
// onContextMenu={(e) => onRightClick(e, message)} // hard to use
onDoubleClickCapture={() => {
if (!isMobileScreen) return;
setUserInput(getMessageTextContent(message));
}}
fontSize={fontSize}
fontFamily={fontFamily}
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>
{message?.audio_url && (
<div className={styles["chat-message-audio"]}>
<audio src={message.audio_url} controls />
</div>
)}
<div className={styles["chat-message-action-date"]}>
{isContext
? Locale.Chat.IsContext
: message.date.toLocaleString()}
</div>
</div>
</div>
{shouldShowClearContextDivider && <ClearContextDivider />}
</Fragment>
);
})}
</div>
<div className={styles["chat-input-panel"]}>
<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("");
}}
setShowShortcutKeyModal={setShowShortcutKeyModal}
setUserInput={setUserInput}
setShowChatSidePanel={setShowChatSidePanel}
/>
<label
className={clsx(styles["chat-input-panel-inner"], {
[styles["chat-input-panel-inner-attach"]]:
attachImages.length !== 0,
})}
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,
fontFamily: config.fontFamily,
}}
/>
{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}
className={styles["chat-input-send"]}
type="primary"
onClick={() => doSubmit(userInput)}
/>
</label>
</div>
</div>
<div
className={clsx(styles["chat-side-panel"], {
[styles["mobile"]]: isMobileScreen,
[styles["chat-side-panel-show"]]: showChatSidePanel,
})}
>
{showChatSidePanel && (
<RealtimeChat
onClose={() => {
setShowChatSidePanel(false);
}}
onStartVoice={async () => {
console.log("start voice");
}}
/>
)}
</div>
</div>
</div>
{showExport && (
<ExportMessageModal onClose={() => setShowExport(false)} />
)}
{isEditingMessage && (
<EditMessageModal
onClose={() => {
setIsEditingMessage(false);
}}
/>
)}
{showShortcutKeyModal && (
<ShortcutKeyModal onClose={() => setShowShortcutKeyModal(false)} />
)}
</>
);
}
export function Chat() {
const chatStore = useChatStore();
const session = chatStore.currentSession();
return <_Chat key={session.id}></_Chat>;
}