|
|
@@ -15,6 +15,8 @@ import RenameIcon from "../icons/rename.svg";
|
|
|
import ExportIcon from "../icons/share.svg";
|
|
|
import ReturnIcon from "../icons/return.svg";
|
|
|
import CopyIcon from "../icons/copy.svg";
|
|
|
+import 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";
|
|
|
@@ -28,6 +30,7 @@ import DeleteIcon from "../icons/clear.svg";
|
|
|
import PinIcon from "../icons/pin.svg";
|
|
|
import EditIcon from "../icons/rename.svg";
|
|
|
import ConfirmIcon from "../icons/confirm.svg";
|
|
|
+import CloseIcon from "../icons/close.svg";
|
|
|
import CancelIcon from "../icons/cancel.svg";
|
|
|
import ImageIcon from "../icons/image.svg";
|
|
|
|
|
|
@@ -37,6 +40,12 @@ 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 ReloadIcon from "../icons/reload.svg";
|
|
|
|
|
|
import {
|
|
|
ChatMessage,
|
|
|
@@ -49,6 +58,7 @@ import {
|
|
|
useAppConfig,
|
|
|
DEFAULT_TOPIC,
|
|
|
ModelType,
|
|
|
+ usePluginStore,
|
|
|
} from "../store";
|
|
|
|
|
|
import {
|
|
|
@@ -59,12 +69,17 @@ import {
|
|
|
getMessageTextContent,
|
|
|
getMessageImages,
|
|
|
isVisionModel,
|
|
|
- compressImage,
|
|
|
+ isDalle3,
|
|
|
+ showPlugins,
|
|
|
+ safeLocalStorage,
|
|
|
} from "../utils";
|
|
|
|
|
|
+import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
|
|
|
+
|
|
|
import dynamic from "next/dynamic";
|
|
|
|
|
|
import { ChatControllerPool } from "../client/controller";
|
|
|
+import { DalleSize, DalleQuality, DalleStyle } from "../typing";
|
|
|
import { Prompt, usePromptStore } from "../store/prompt";
|
|
|
import Locale from "../locales";
|
|
|
|
|
|
@@ -83,10 +98,12 @@ import {
|
|
|
import { useNavigate } from "react-router-dom";
|
|
|
import {
|
|
|
CHAT_PAGE_SIZE,
|
|
|
- LAST_INPUT_KEY,
|
|
|
+ DEFAULT_TTS_ENGINE,
|
|
|
+ ModelProvider,
|
|
|
Path,
|
|
|
REQUEST_TIMEOUT_MS,
|
|
|
UNFINISHED_INPUT,
|
|
|
+ ServiceProvider,
|
|
|
} from "../constant";
|
|
|
import { Avatar } from "./emoji";
|
|
|
import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
|
|
|
@@ -98,6 +115,13 @@ import { getClientConfig } from "../config/client";
|
|
|
import { useAllModels } from "../utils/hooks";
|
|
|
import { MultimodalContent } from "../client/api";
|
|
|
|
|
|
+const localStorage = safeLocalStorage();
|
|
|
+import { ClientApi } from "../client/api";
|
|
|
+import { createTTSPlayer } from "../utils/audio";
|
|
|
+import { MsEdgeTTS, OUTPUT_FORMAT } from "../utils/ms_edge_tts";
|
|
|
+
|
|
|
+const ttsPlayer = createTTSPlayer();
|
|
|
+
|
|
|
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
|
|
loading: () => <LoadingIcon />,
|
|
|
});
|
|
|
@@ -177,7 +201,7 @@ function PromptToast(props: {
|
|
|
|
|
|
return (
|
|
|
<div className={styles["prompt-toast"]} key="prompt-toast">
|
|
|
- {props.showToast && (
|
|
|
+ {props.showToast && context.length > 0 && (
|
|
|
<div
|
|
|
className={styles["prompt-toast-inner"] + " clickable"}
|
|
|
role="button"
|
|
|
@@ -243,11 +267,11 @@ function useSubmitHandler() {
|
|
|
};
|
|
|
}
|
|
|
|
|
|
-export type RenderPompt = Pick<Prompt, "title" | "content">;
|
|
|
+export type RenderPrompt = Pick<Prompt, "title" | "content">;
|
|
|
|
|
|
export function PromptHints(props: {
|
|
|
- prompts: RenderPompt[];
|
|
|
- onPromptSelect: (prompt: RenderPompt) => void;
|
|
|
+ prompts: RenderPrompt[];
|
|
|
+ onPromptSelect: (prompt: RenderPrompt) => void;
|
|
|
}) {
|
|
|
const noPrompts = props.prompts.length === 0;
|
|
|
const [selectIndex, setSelectIndex] = useState(0);
|
|
|
@@ -336,7 +360,7 @@ function ClearContextDivider() {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-function ChatAction(props: {
|
|
|
+export function ChatAction(props: {
|
|
|
text: string;
|
|
|
icon: JSX.Element;
|
|
|
onClick: () => void;
|
|
|
@@ -426,10 +450,13 @@ export function ChatActions(props: {
|
|
|
showPromptHints: () => void;
|
|
|
hitBottom: boolean;
|
|
|
uploading: boolean;
|
|
|
+ setShowShortcutKeyModal: React.Dispatch<React.SetStateAction<boolean>>;
|
|
|
+ setUserInput: (input: string) => void;
|
|
|
}) {
|
|
|
const config = useAppConfig();
|
|
|
const navigate = useNavigate();
|
|
|
const chatStore = useChatStore();
|
|
|
+ const pluginStore = usePluginStore();
|
|
|
|
|
|
// switch themes
|
|
|
const theme = config.theme;
|
|
|
@@ -447,14 +474,51 @@ export function ChatActions(props: {
|
|
|
|
|
|
// switch model
|
|
|
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
|
|
+ const currentProviderName =
|
|
|
+ chatStore.currentSession().mask.modelConfig?.providerName ||
|
|
|
+ ServiceProvider.OpenAI;
|
|
|
const allModels = useAllModels();
|
|
|
- const models = useMemo(
|
|
|
- () => allModels.filter((m) => m.available),
|
|
|
- [allModels],
|
|
|
- );
|
|
|
+ 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 dalle3Sizes: DalleSize[] = ["1024x1024", "1792x1024", "1024x1792"];
|
|
|
+ const dalle3Qualitys: DalleQuality[] = ["standard", "hd"];
|
|
|
+ const dalle3Styles: DalleStyle[] = ["vivid", "natural"];
|
|
|
+ const currentSize =
|
|
|
+ chatStore.currentSession().mask.modelConfig?.size ?? "1024x1024";
|
|
|
+ const currentQuality =
|
|
|
+ chatStore.currentSession().mask.modelConfig?.quality ?? "standard";
|
|
|
+ const currentStyle =
|
|
|
+ chatStore.currentSession().mask.modelConfig?.style ?? "vivid";
|
|
|
+
|
|
|
+ const isMobileScreen = useMobileScreen();
|
|
|
+
|
|
|
useEffect(() => {
|
|
|
const show = isVisionModel(currentModel);
|
|
|
setShowUploadImage(show);
|
|
|
@@ -465,13 +529,20 @@ export function ChatActions(props: {
|
|
|
|
|
|
// if current model is not available
|
|
|
// switch to first available model
|
|
|
- const isUnavaliableModel = !models.some((m) => m.name === currentModel);
|
|
|
- if (isUnavaliableModel && models.length > 0) {
|
|
|
- const nextModel = models[0].name as ModelType;
|
|
|
- chatStore.updateCurrentSession(
|
|
|
- (session) => (session.mask.modelConfig.model = nextModel),
|
|
|
+ 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.updateCurrentSession((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,
|
|
|
);
|
|
|
- showToast(nextModel);
|
|
|
}
|
|
|
}, [chatStore, currentModel, models]);
|
|
|
|
|
|
@@ -553,28 +624,162 @@ export function ChatActions(props: {
|
|
|
|
|
|
<ChatAction
|
|
|
onClick={() => setShowModelSelector(true)}
|
|
|
- text={currentModel}
|
|
|
+ text={currentModelName}
|
|
|
icon={<RobotIcon />}
|
|
|
/>
|
|
|
|
|
|
{showModelSelector && (
|
|
|
<Selector
|
|
|
- defaultSelectedValue={currentModel}
|
|
|
+ defaultSelectedValue={`${currentModel}@${currentProviderName}`}
|
|
|
items={models.map((m) => ({
|
|
|
- title: m.displayName,
|
|
|
- value: m.name,
|
|
|
+ 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] = s[0].split("@");
|
|
|
chatStore.updateCurrentSession((session) => {
|
|
|
- session.mask.modelConfig.model = s[0] as ModelType;
|
|
|
+ session.mask.modelConfig.model = model as ModelType;
|
|
|
+ session.mask.modelConfig.providerName =
|
|
|
+ providerName as ServiceProvider;
|
|
|
session.mask.syncGlobalConfig = false;
|
|
|
});
|
|
|
- showToast(s[0]);
|
|
|
+ if (providerName == "ByteDance") {
|
|
|
+ const selectedModel = models.find(
|
|
|
+ (m) =>
|
|
|
+ m.name == model && m?.provider?.providerName == providerName,
|
|
|
+ );
|
|
|
+ showToast(selectedModel?.displayName ?? "");
|
|
|
+ } else {
|
|
|
+ showToast(model);
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {isDalle3(currentModel) && (
|
|
|
+ <ChatAction
|
|
|
+ onClick={() => setShowSizeSelector(true)}
|
|
|
+ text={currentSize}
|
|
|
+ icon={<SizeIcon />}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {showSizeSelector && (
|
|
|
+ <Selector
|
|
|
+ defaultSelectedValue={currentSize}
|
|
|
+ items={dalle3Sizes.map((m) => ({
|
|
|
+ title: m,
|
|
|
+ value: m,
|
|
|
+ }))}
|
|
|
+ onClose={() => setShowSizeSelector(false)}
|
|
|
+ onSelection={(s) => {
|
|
|
+ if (s.length === 0) return;
|
|
|
+ const size = s[0];
|
|
|
+ chatStore.updateCurrentSession((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.updateCurrentSession((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.updateCurrentSession((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.updateCurrentSession((session) => {
|
|
|
+ session.mask.plugin = s as string[];
|
|
|
+ });
|
|
|
}}
|
|
|
/>
|
|
|
)}
|
|
|
+
|
|
|
+ {!isMobileScreen && (
|
|
|
+ <ChatAction
|
|
|
+ onClick={() => props.setShowShortcutKeyModal(true)}
|
|
|
+ text={Locale.Chat.ShortcutKey.Title}
|
|
|
+ icon={<ShortcutkeyIcon />}
|
|
|
+ />
|
|
|
+ )}
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
@@ -649,6 +854,67 @@ export function DeleteImageButton(props: { deleteImage: () => void }) {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+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", "/"],
|
|
|
+ },
|
|
|
+ ];
|
|
|
+ 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 };
|
|
|
|
|
|
@@ -656,6 +922,7 @@ function _Chat() {
|
|
|
const session = chatStore.currentSession();
|
|
|
const config = useAppConfig();
|
|
|
const fontSize = config.fontSize;
|
|
|
+ const fontFamily = config.fontFamily;
|
|
|
|
|
|
const [showExport, setShowExport] = useState(false);
|
|
|
|
|
|
@@ -682,7 +949,7 @@ function _Chat() {
|
|
|
|
|
|
// prompt hints
|
|
|
const promptStore = usePromptStore();
|
|
|
- const [promptHints, setPromptHints] = useState<RenderPompt[]>([]);
|
|
|
+ const [promptHints, setPromptHints] = useState<RenderPrompt[]>([]);
|
|
|
const onSearch = useDebouncedCallback(
|
|
|
(text: string) => {
|
|
|
const matchedPrompts = promptStore.search(text);
|
|
|
@@ -723,6 +990,7 @@ function _Chat() {
|
|
|
chatStore.updateCurrentSession(
|
|
|
(session) => (session.clearContextIndex = session.messages.length),
|
|
|
),
|
|
|
+ fork: () => chatStore.forkSession(),
|
|
|
del: () => chatStore.deleteSession(chatStore.currentSessionIndex),
|
|
|
});
|
|
|
|
|
|
@@ -735,7 +1003,7 @@ function _Chat() {
|
|
|
// clear search results
|
|
|
if (n === 0) {
|
|
|
setPromptHints([]);
|
|
|
- } else if (text.startsWith(ChatCommandPrefix)) {
|
|
|
+ } else if (text.match(ChatCommandPrefix)) {
|
|
|
setPromptHints(chatCommands.search(text));
|
|
|
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
|
|
|
// check if need to trigger auto completion
|
|
|
@@ -760,14 +1028,14 @@ function _Chat() {
|
|
|
.onUserInput(userInput, attachImages)
|
|
|
.then(() => setIsLoading(false));
|
|
|
setAttachImages([]);
|
|
|
- localStorage.setItem(LAST_INPUT_KEY, userInput);
|
|
|
+ chatStore.setLastInput(userInput);
|
|
|
setUserInput("");
|
|
|
setPromptHints([]);
|
|
|
if (!isMobileScreen) inputRef.current?.focus();
|
|
|
setAutoScroll(true);
|
|
|
};
|
|
|
|
|
|
- const onPromptSelect = (prompt: RenderPompt) => {
|
|
|
+ const onPromptSelect = (prompt: RenderPrompt) => {
|
|
|
setTimeout(() => {
|
|
|
setPromptHints([]);
|
|
|
|
|
|
@@ -826,7 +1094,7 @@ function _Chat() {
|
|
|
userInput.length <= 0 &&
|
|
|
!(e.metaKey || e.altKey || e.ctrlKey)
|
|
|
) {
|
|
|
- setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
|
|
|
+ setUserInput(chatStore.lastInput ?? "");
|
|
|
e.preventDefault();
|
|
|
return;
|
|
|
}
|
|
|
@@ -926,10 +1194,55 @@ function _Chat() {
|
|
|
});
|
|
|
};
|
|
|
|
|
|
+ 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]);
|
|
|
- const accessStore = useAccessStore();
|
|
|
|
|
|
if (
|
|
|
context.length === 0 &&
|
|
|
@@ -1075,6 +1388,7 @@ function _Chat() {
|
|
|
if (payload.url) {
|
|
|
accessStore.update((access) => (access.openaiUrl = payload.url!));
|
|
|
}
|
|
|
+ accessStore.update((access) => (access.useCustomConfig = true));
|
|
|
});
|
|
|
}
|
|
|
} catch {
|
|
|
@@ -1102,11 +1416,13 @@ function _Chat() {
|
|
|
};
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
}, []);
|
|
|
-
|
|
|
+
|
|
|
const handlePaste = useCallback(
|
|
|
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
|
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
|
|
- if(!isVisionModel(currentModel)){return;}
|
|
|
+ if (!isVisionModel(currentModel)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
const items = (event.clipboardData || window.clipboardData).items;
|
|
|
for (const item of items) {
|
|
|
if (item.kind === "file" && item.type.startsWith("image/")) {
|
|
|
@@ -1119,7 +1435,7 @@ function _Chat() {
|
|
|
...(await new Promise<string[]>((res, rej) => {
|
|
|
setUploading(true);
|
|
|
const imagesData: string[] = [];
|
|
|
- compressImage(file, 256 * 1024)
|
|
|
+ uploadImageRemote(file)
|
|
|
.then((dataUrl) => {
|
|
|
imagesData.push(dataUrl);
|
|
|
setUploading(false);
|
|
|
@@ -1161,7 +1477,7 @@ function _Chat() {
|
|
|
const imagesData: string[] = [];
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
|
const file = event.target.files[i];
|
|
|
- compressImage(file, 256 * 1024)
|
|
|
+ uploadImageRemote(file)
|
|
|
.then((dataUrl) => {
|
|
|
imagesData.push(dataUrl);
|
|
|
if (
|
|
|
@@ -1189,6 +1505,70 @@ function _Chat() {
|
|
|
setAttachImages(images);
|
|
|
}
|
|
|
|
|
|
+ // 快捷键 shortcut keys
|
|
|
+ const [showShortcutKeyModal, setShowShortcutKeyModal] = useState(false);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const handleKeyDown = (event: any) => {
|
|
|
+ // 打开新聊天 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);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ window.addEventListener("keydown", handleKeyDown);
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ window.removeEventListener("keydown", handleKeyDown);
|
|
|
+ };
|
|
|
+ }, [messages, chatStore, navigate]);
|
|
|
+
|
|
|
return (
|
|
|
<div className={styles.chat} key={session.id}>
|
|
|
<div className="window-header" data-tauri-drag-region>
|
|
|
@@ -1217,11 +1597,24 @@ function _Chat() {
|
|
|
</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);
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </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>
|
|
|
@@ -1241,6 +1634,8 @@ function _Chat() {
|
|
|
<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),
|
|
|
@@ -1292,6 +1687,7 @@ function _Chat() {
|
|
|
<div className={styles["chat-message-edit"]}>
|
|
|
<IconButton
|
|
|
icon={<EditIcon />}
|
|
|
+ aria={Locale.Chat.Actions.Edit}
|
|
|
onClick={async () => {
|
|
|
const newMessage = await showPrompt(
|
|
|
Locale.Chat.Actions.Edit,
|
|
|
@@ -1340,6 +1736,11 @@ function _Chat() {
|
|
|
</>
|
|
|
)}
|
|
|
</div>
|
|
|
+ {!isUser && (
|
|
|
+ <div className={styles["chat-model-name"]}>
|
|
|
+ {message.model}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
{showActions && (
|
|
|
<div className={styles["chat-message-actions"]}>
|
|
|
@@ -1378,31 +1779,72 @@ function _Chat() {
|
|
|
)
|
|
|
}
|
|
|
/>
|
|
|
+ {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>
|
|
|
- {showTyping && (
|
|
|
+ {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}
|
|
|
+ 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)}
|
|
|
+ // 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}
|
|
|
/>
|
|
|
@@ -1473,6 +1915,8 @@ function _Chat() {
|
|
|
setUserInput("/");
|
|
|
onSearch("");
|
|
|
}}
|
|
|
+ setShowShortcutKeyModal={setShowShortcutKeyModal}
|
|
|
+ setUserInput={setUserInput}
|
|
|
/>
|
|
|
<label
|
|
|
className={`${styles["chat-input-panel-inner"]} ${
|
|
|
@@ -1497,6 +1941,7 @@ function _Chat() {
|
|
|
autoFocus={autoFocus}
|
|
|
style={{
|
|
|
fontSize: config.fontSize,
|
|
|
+ fontFamily: config.fontFamily,
|
|
|
}}
|
|
|
/>
|
|
|
{attachImages.length != 0 && (
|
|
|
@@ -1543,6 +1988,10 @@ function _Chat() {
|
|
|
}}
|
|
|
/>
|
|
|
)}
|
|
|
+
|
|
|
+ {showShortcutKeyModal && (
|
|
|
+ <ShortcutKeyModal onClose={() => setShowShortcutKeyModal(false)} />
|
|
|
+ )}
|
|
|
</div>
|
|
|
);
|
|
|
}
|