Spaces:
Sleeping
Sleeping
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, | |
isDalle3, | |
isVisionModel, | |
safeLocalStorage, | |
getModelSizes, | |
supportsCustomSize, | |
useMobileScreen, | |
selectOrCopy, | |
showPlugins, | |
} 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) { | |
const arr = [ | |
defaultModel, | |
...filteredModels.filter((m) => m !== defaultModel), | |
]; | |
return arr; | |
} 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 [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={() => setShowModelSelector(true)} | |
text={currentModelName} | |
icon={<RobotIcon />} | |
/> | |
{showModelSelector && ( | |
<Selector | |
defaultSelectedValue={`${currentModel}@${currentProviderName}`} | |
items={models.map((m) => ({ | |
title: `${m.displayName}${ | |
m?.provider?.providerName | |
? " (" + m?.provider?.providerName + ")" | |
: "" | |
}`, | |
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>; | |
} | |