From 21bf685d12c4bb3b217dbe3985b780742e1521d3 Mon Sep 17 00:00:00 2001 From: Hk-Gosuto Date: Mon, 23 Dec 2024 15:48:21 +0800 Subject: [PATCH] feat: openai realtime merge --- app/command.ts | 14 +- app/components/chat.module.scss | 164 ++- app/components/chat.tsx | 1262 ++++++++++------- app/components/realtime-chat/index.ts | 1 + .../realtime-chat/realtime-chat.module.scss | 74 + .../realtime-chat/realtime-chat.tsx | 359 +++++ .../realtime-chat/realtime-config.tsx | 173 +++ app/components/settings.tsx | 20 +- app/components/voice-print/index.ts | 1 + .../voice-print/voice-print.module.scss | 11 + app/components/voice-print/voice-print.tsx | 180 +++ app/icons/arrow.svg | 1 + app/icons/fire.svg | 1 + app/icons/headphone.svg | 11 + app/icons/logo.svg | 19 + app/icons/power.svg | 7 + app/icons/voice-off.svg | 13 + app/icons/voice.svg | 9 + app/lib/audio.ts | 200 +++ app/locales/cn.ts | 45 + app/locales/en.ts | 45 + app/store/chat.ts | 464 +++--- app/store/config.ts | 15 + app/store/plugin.ts | 8 + app/utils.ts | 36 +- package.json | 6 +- public/audio-processor.js | 48 + public/plugins.json | 17 + public/serviceWorker.js | 9 +- yarn.lock | 38 +- 30 files changed, 2418 insertions(+), 833 deletions(-) create mode 100644 app/components/realtime-chat/index.ts create mode 100644 app/components/realtime-chat/realtime-chat.module.scss create mode 100644 app/components/realtime-chat/realtime-chat.tsx create mode 100644 app/components/realtime-chat/realtime-config.tsx create mode 100644 app/components/voice-print/index.ts create mode 100644 app/components/voice-print/voice-print.module.scss create mode 100644 app/components/voice-print/voice-print.tsx create mode 100644 app/icons/arrow.svg create mode 100644 app/icons/fire.svg create mode 100644 app/icons/headphone.svg create mode 100644 app/icons/logo.svg create mode 100644 app/icons/power.svg create mode 100644 app/icons/voice-off.svg create mode 100644 app/icons/voice.svg create mode 100644 app/lib/audio.ts create mode 100644 public/audio-processor.js create mode 100644 public/plugins.json diff --git a/app/command.ts b/app/command.ts index e515e5f0b..aec73ef53 100644 --- a/app/command.ts +++ b/app/command.ts @@ -38,16 +38,20 @@ interface ChatCommands { next?: Command; prev?: Command; clear?: Command; + fork?: Command; del?: Command; } -export const ChatCommandPrefix = ":"; +// Compatible with Chinese colon character ":" +export const ChatCommandPrefix = /^[::]/; export function useChatCommand(commands: ChatCommands = {}) { function extract(userInput: string) { - return ( - userInput.startsWith(ChatCommandPrefix) ? userInput.slice(1) : userInput - ) as keyof ChatCommands; + const match = userInput.match(ChatCommandPrefix); + if (match) { + return userInput.slice(1) as keyof ChatCommands; + } + return userInput as keyof ChatCommands; } function search(userInput: string) { @@ -57,7 +61,7 @@ export function useChatCommand(commands: ChatCommands = {}) { .filter((c) => c.startsWith(input)) .map((c) => ({ title: desc[c as keyof ChatCommands], - content: ChatCommandPrefix + c, + content: ":" + c, })); } diff --git a/app/components/chat.module.scss b/app/components/chat.module.scss index 703d029a7..9e901e27b 100644 --- a/app/components/chat.module.scss +++ b/app/components/chat.module.scss @@ -110,6 +110,13 @@ display: flex; flex-wrap: wrap; justify-content: space-between; + gap: 5px; + + &-end { + display: flex; + margin-left: auto; + gap: 5px; + } .chat-input-action { display: inline-flex; @@ -127,10 +134,6 @@ width: var(--icon-width); overflow: hidden; - &:not(:last-child) { - margin-right: 5px; - } - .text { white-space: nowrap; padding-left: 5px; @@ -413,6 +416,12 @@ flex-wrap: nowrap; } } + + .chat-model-name { + font-size: 12px; + color: var(--black); + margin-left: 6px; + } } .chat-message-container { @@ -467,37 +476,6 @@ } } -.chat-message-checkmark { - display: inline-block; - margin-right: 5px; - height: 12px; - width: 12px; - color: #13a10e; - fill: #13a10e; - user-select: none; - backface-visibility: hidden; - transform: translateZ(0px); -} - -.chat-message-tools-status { - display: flex; - justify-content: center; - align-items: center; - font-size: 12px; - margin-top: 5px; - line-height: 1.5; -} - -.chat-message-tools-name { - color: #aaa; -} - -.chat-message-tools-details { - margin-left: 5px; - font-weight: bold; - color: #999; -} - .chat-message-status { font-size: 12px; color: #aaa; @@ -505,6 +483,21 @@ margin-top: 5px; } +.chat-message-tools { + font-size: 12px; + color: #aaa; + line-height: 1.5; + margin-top: 5px; + .chat-message-tool { + display: flex; + align-items: end; + svg { + margin-left: 5px; + margin-right: 5px; + } + } +} + .chat-message-item { box-sizing: border-box; max-width: 100%; @@ -520,15 +513,23 @@ transition: all ease 0.3s; } -.chat-message-item-files { - display: grid; - grid-template-columns: repeat(var(--file-count), auto); - grid-gap: 5px; -} - -.chat-message-item-file { - text-decoration: none; - color: #aaa; +.chat-message-audio { + display: flex; + align-items: center; + justify-content: space-between; + border-radius: 10px; + background-color: rgba(0, 0, 0, 0.05); + border: var(--border-in-light); + position: relative; + transition: all ease 0.3s; + margin-top: 10px; + font-size: 14px; + user-select: text; + word-break: break-word; + box-sizing: border-box; + audio { + height: 30px; /* 调整高度 */ + } } .chat-message-item-image { @@ -739,3 +740,78 @@ bottom: 30px; } } + +.shortcut-key-container { + padding: 10px; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.shortcut-key-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 16px; +} + +.shortcut-key-item { + display: flex; + justify-content: space-between; + align-items: center; + overflow: hidden; + padding: 10px; + background-color: var(--white); +} + +.shortcut-key-title { + font-size: 14px; + color: var(--black); +} + +.shortcut-key-keys { + display: flex; + gap: 8px; +} + +.shortcut-key { + display: flex; + align-items: center; + justify-content: center; + border: var(--border-in-light); + border-radius: 8px; + padding: 4px; + background-color: var(--gray); + min-width: 32px; +} + +.shortcut-key span { + font-size: 12px; + color: var(--black); +} + +.chat-main { + display: flex; + height: 100%; + width: 100%; + position: relative; + overflow: hidden; + .chat-body-container { + height: 100%; + display: flex; + flex-direction: column; + flex: 1; + width: 100%; + } + .chat-side-panel { + position: absolute; + inset: 0; + background: var(--white); + overflow: hidden; + z-index: 10; + transform: translateX(100%); + transition: all ease 0.3s; + &-show { + transform: translateX(0); + } + } +} diff --git a/app/components/chat.tsx b/app/components/chat.tsx index ba27b14d1..406199699 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -28,10 +28,10 @@ import ResetIcon from "../icons/reload.svg"; import BreakIcon from "../icons/break.svg"; import SettingsIcon from "../icons/chat-settings.svg"; import DeleteIcon from "../icons/clear.svg"; -import CloseIcon from "../icons/close.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 EnablePluginIcon from "../icons/plugin_enable.svg"; import DisablePluginIcon from "../icons/plugin_disable.svg"; @@ -51,7 +51,7 @@ 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 HeadphoneIcon from "../icons/headphone.svg"; import { ChatMessage, SubmitKey, @@ -63,6 +63,7 @@ import { useAppConfig, DEFAULT_TOPIC, ModelType, + usePluginStore, } from "../store"; import { @@ -74,10 +75,11 @@ import { getMessageImages, isVisionModel, isDalle3, + showPlugins, safeLocalStorage, - isFirefox, isSupportRAGModel, isFunctionCallModel, + isFirefox, } from "../utils"; import { uploadImage as uploadImageRemote } from "@/app/utils/chat"; @@ -87,7 +89,7 @@ import dynamic from "next/dynamic"; import { ChatControllerPool } from "../client/controller"; import { DalleSize, DalleQuality, DalleStyle } from "../typing"; import { Prompt, usePromptStore } from "../store/prompt"; -import Locale, { getLang, getSTTLang } from "../locales"; +import Locale from "../locales"; import { IconButton } from "./button"; import styles from "./chat.module.scss"; @@ -123,16 +125,23 @@ import { prettyObject } from "../utils/format"; import { ExportMessageModal } from "./exporter"; import { getClientConfig } from "../config/client"; import { useAllModels } from "../utils/hooks"; +import { MultimodalContent } from "../client/api"; + import { ClientApi } from "../client/api"; import { createTTSPlayer } from "../utils/audio"; -import { MultimodalContent } from "../client/api"; +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 { OpenAITranscriptionApi, SpeechApi, WebTranscriptionApi, } from "../utils/speech"; import { FileInfo } from "../client/platforms/utils"; -import { MsEdgeTTS, OUTPUT_FORMAT } from "../utils/ms_edge_tts"; const ttsPlayer = createTTSPlayer(); @@ -159,7 +168,8 @@ export function SessionConfigModel(props: { onClose: () => void }) { text={Locale.Chat.Config.Reset} onClick={async () => { if (await showConfirm(Locale.Memory.ResetConfirm)) { - chatStore.updateCurrentSession( + chatStore.updateTargetSession( + session, (session) => (session.memoryPrompt = ""), ); } @@ -184,7 +194,10 @@ export function SessionConfigModel(props: { onClose: () => void }) { updateMask={(updater) => { const mask = { ...session.mask }; updater(mask); - chatStore.updateCurrentSession((session) => (session.mask = mask)); + chatStore.updateTargetSession( + session, + (session) => (session.mask = mask), + ); }} shouldSyncFromGlobal extraListItems={ @@ -215,9 +228,9 @@ function PromptToast(props: { return (
- {props.showToast && ( + {props.showToast && context.length > 0 && (
props.setShowModal(true)} > @@ -338,10 +351,9 @@ export function PromptHints(props: { {props.prompts.map((prompt, i) => (
props.onPromptSelect(prompt)} onMouseEnter={() => setSelectIndex(i)} @@ -356,12 +368,14 @@ export function PromptHints(props: { function ClearContextDivider() { const chatStore = useChatStore(); + const session = chatStore.currentSession(); return (
- chatStore.updateCurrentSession( + chatStore.updateTargetSession( + session, (session) => (session.clearContextIndex = undefined), ) } @@ -402,7 +416,7 @@ function ChatAction(props: { return (
{ if (props.loding) return; props.onClick(); @@ -485,15 +499,20 @@ export function ChatActions(props: { showPromptHints: () => void; hitBottom: boolean; uploading: boolean; + setShowShortcutKeyModal: React.Dispatch>; + setUserInput: (input: string) => void; + setShowChatSidePanel: React.Dispatch>; }) { const config = useAppConfig(); const navigate = useNavigate(); const chatStore = useChatStore(); + const pluginStore = usePluginStore(); + const session = chatStore.currentSession(); // switch Plugins const usePlugins = chatStore.currentSession().mask.usePlugins; function switchUsePlugins() { - chatStore.updateCurrentSession((session) => { + chatStore.updateTargetSession(session, (session) => { session.mask.usePlugins = !session.mask.usePlugins; }); } @@ -513,10 +532,9 @@ export function ChatActions(props: { const stopAll = () => ChatControllerPool.stopAll(); // switch model - const currentModel = chatStore.currentSession().mask.modelConfig.model; + const currentModel = session.mask.modelConfig.model; const currentProviderName = - chatStore.currentSession().mask.modelConfig?.providerName || - ServiceProvider.OpenAI; + session.mask.modelConfig?.providerName || ServiceProvider.OpenAI; const allModels = useAllModels(); const models = useMemo(() => { const filteredModels = allModels.filter((m) => m.available); @@ -548,6 +566,7 @@ export function ChatActions(props: { return model?.displayName ?? ""; }, [models, currentModel, currentProviderName]); const [showModelSelector, setShowModelSelector] = useState(false); + const [showPluginSelector, setShowPluginSelector] = useState(false); const [showUploadImage, setShowUploadImage] = useState(false); const [showUploadFile, setShowUploadFile] = useState(false); @@ -557,12 +576,11 @@ export function ChatActions(props: { 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 currentSize = session.mask.modelConfig?.size ?? "1024x1024"; + const currentQuality = session.mask.modelConfig?.quality ?? "standard"; + const currentStyle = session.mask.modelConfig?.style ?? "vivid"; + + const isMobileScreen = useMobileScreen(); const accessStore = useAccessStore(); const isEnableRAG = useMemo( @@ -587,11 +605,11 @@ 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 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) => { + chatStore.updateTargetSession(session, (session) => { session.mask.modelConfig.model = nextModel.name; session.mask.modelConfig.providerName = nextModel?.provider ?.providerName as ServiceProvider; @@ -602,11 +620,11 @@ export function ChatActions(props: { : nextModel.name, ); } - }, [chatStore, currentModel, models]); + }, [chatStore, currentModel, models, session]); return (
-
+ <> {couldStop && ( } /> + + } + 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 + } + }); + }} + /> + {config.pluginConfig.enable && isFunctionCallModel(currentModel) && ( setShowModelSelector(false)} onSelection={(s) => { if (s.length === 0) return; - const [model, providerName] = s[0].split("@"); - chatStore.updateCurrentSession((session) => { + const [model, providerName] = getModelProvider(s[0]); + chatStore.updateTargetSession(session, (session) => { session.mask.modelConfig.model = model as ModelType; session.mask.modelConfig.providerName = providerName as ServiceProvider; @@ -744,7 +778,7 @@ export function ChatActions(props: { onSelection={(s) => { if (s.length === 0) return; const size = s[0]; - chatStore.updateCurrentSession((session) => { + chatStore.updateTargetSession(session, (session) => { session.mask.modelConfig.size = size; }); showToast(size); @@ -771,7 +805,7 @@ export function ChatActions(props: { onSelection={(q) => { if (q.length === 0) return; const quality = q[0]; - chatStore.updateCurrentSession((session) => { + chatStore.updateTargetSession(session, (session) => { session.mask.modelConfig.quality = quality; }); showToast(quality); @@ -798,29 +832,60 @@ export function ChatActions(props: { onSelection={(s) => { if (s.length === 0) return; const style = s[0]; - chatStore.updateCurrentSession((session) => { + chatStore.updateTargetSession(session, (session) => { session.mask.modelConfig.style = style; }); showToast(style); }} /> )} -
-
- } - onClick={() => { - chatStore.updateCurrentSession((session) => { - if (session.clearContextIndex === session.messages.length) { - session.clearContextIndex = undefined; + + {showPlugins(currentProviderName, currentModel) && ( + { + if (pluginStore.getAll().length == 0) { + navigate(Path.Plugins); } else { - session.clearContextIndex = session.messages.length; - session.memoryPrompt = ""; // will clear memory + setShowPluginSelector(true); } - }); - }} - /> + }} + text={Locale.Plugin.Name} + icon={} + /> + )} + {showPluginSelector && ( + ({ + title: `${item?.title}@${item?.version}`, + value: item?.id, + }))} + onClose={() => setShowPluginSelector(false)} + onSelection={(s) => { + chatStore.updateTargetSession(session, (session) => { + session.mask.plugin = s as string[]; + }); + }} + /> + )} + + {!isMobileScreen && ( + props.setShowShortcutKeyModal(true)} + text={Locale.Chat.ShortcutKey.Title} + icon={} + /> + )} + +
+ {config.realtimeConfig.enable && ( + props.setShowChatSidePanel(true)} + text={Locale.Settings.Realtime.Enable.Title} + icon={} + /> + )}
); @@ -851,7 +916,8 @@ export function EditMessageModal(props: { onClose: () => void }) { icon={} key="ok" onClick={() => { - chatStore.updateCurrentSession( + chatStore.updateTargetSession( + session, (session) => (session.messages = messages), ); props.onClose(); @@ -868,7 +934,8 @@ export function EditMessageModal(props: { onClose: () => void }) { type="text" value={session.topic} onInput={(e) => - chatStore.updateCurrentSession( + chatStore.updateTargetSession( + session, (session) => (session.topic = e.currentTarget.value), ) } @@ -904,6 +971,67 @@ export function DeleteFileButton(props: { deleteFile: () => 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 ( +
+ } + key="ok" + onClick={() => { + props.onClose(); + }} + />, + ]} + > +
+
+ {shortcuts.map((shortcut, index) => ( +
+
+ {shortcut.title} +
+
+ {shortcut.keys.map((key, i) => ( +
+ {key} +
+ ))} +
+
+ ))} +
+
+
+
+ ); +} + function _Chat() { type RenderMessage = ChatMessage & { preview?: boolean }; @@ -911,6 +1039,7 @@ function _Chat() { const session = chatStore.currentSession(); const config = useAppConfig(); const fontSize = config.fontSize; + const fontFamily = config.fontFamily; const [showExport, setShowExport] = useState(false); @@ -925,9 +1054,24 @@ function _Chat() { (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, + (isScrolledToBottom || isAttachWithTop) && !isTyping, ); const [hitBottom, setHitBottom] = useState(true); const isMobileScreen = useMobileScreen(); @@ -976,9 +1120,11 @@ function _Chat() { prev: () => chatStore.nextSession(-1), next: () => chatStore.nextSession(1), clear: () => - chatStore.updateCurrentSession( + chatStore.updateTargetSession( + session, (session) => (session.clearContextIndex = session.messages.length), ), + fork: () => chatStore.forkSession(), del: () => chatStore.deleteSession(chatStore.currentSessionIndex), }); @@ -991,7 +1137,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 @@ -1030,7 +1176,7 @@ function _Chat() { }; const doSubmit = (userInput: string) => { - if (userInput.trim() === "") return; + if (userInput.trim() === "" && isEmpty(attachImages)) return; const matchCommand = chatCommands.match(userInput); if (matchCommand.matched) { setUserInput(""); @@ -1040,11 +1186,11 @@ function _Chat() { } setIsLoading(true); chatStore - .onUserInput(userInput, attachImages, attachFiles) + .onUserInput(userInput, attachImages) .then(() => setIsLoading(false)); setAttachImages([]); setAttachFiles([]); - localStorage.setItem(LAST_INPUT_KEY, userInput); + chatStore.setLastInput(userInput); setUserInput(""); setPromptHints([]); if (!isMobileScreen) inputRef.current?.focus(); @@ -1074,7 +1220,7 @@ function _Chat() { }; useEffect(() => { - chatStore.updateCurrentSession((session) => { + chatStore.updateTargetSession(session, (session) => { const stopTiming = Date.now() - REQUEST_TIMEOUT_MS; session.messages.forEach((m) => { // check if should stop all stale messages @@ -1110,7 +1256,7 @@ function _Chat() { onRecognitionEnd(transcription), ), ); - }, []); + }, [session]); // check if should send message const onInputKeyDown = (e: React.KeyboardEvent) => { @@ -1120,7 +1266,7 @@ function _Chat() { userInput.length <= 0 && !(e.metaKey || e.altKey || e.ctrlKey) ) { - setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? ""); + setUserInput(chatStore.lastInput ?? ""); e.preventDefault(); return; } @@ -1141,7 +1287,8 @@ function _Chat() { }; const deleteMessage = (msgId?: string) => { - chatStore.updateCurrentSession( + chatStore.updateTargetSession( + session, (session) => (session.messages = session.messages.filter((m) => m.id !== msgId)), ); @@ -1210,7 +1357,7 @@ function _Chat() { }; const onPinMessage = (message: ChatMessage) => { - chatStore.updateCurrentSession((session) => + chatStore.updateTargetSession(session, (session) => session.mask.context.push(message), ); @@ -1222,6 +1369,7 @@ function _Chat() { }); }; + const accessStore = useAccessStore(); const [speechStatus, setSpeechStatus] = useState(false); const [speechLoading, setSpeechLoading] = useState(false); async function openaiSpeech(text: string) { @@ -1270,7 +1418,6 @@ function _Chat() { 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 && @@ -1285,36 +1432,34 @@ function _Chat() { // 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, - }, - ] - : [], - ) - ); + 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, @@ -1575,455 +1720,528 @@ function _Chat() { setAttachFiles(uploadFiles); } - return ( -
-
- {isMobileScreen && ( -
-
- } - bordered - title={Locale.Chat.Actions.ChatList} - onClick={() => navigate(Path.Home)} - /> -
-
- )} + // 快捷键 shortcut keys + const [showShortcutKeyModal, setShowShortcutKeyModal] = useState(false); -
-
setIsEditingMessage(true)} - > - {!session.topic ? DEFAULT_TOPIC : session.topic} -
-
- {Locale.Chat.SubTitle(session.messages.length)} -
-
-
- {!isMobileScreen && ( -
- } - bordered - onClick={() => setIsEditingMessage(true)} - /> + 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(".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]); + + const [showChatSidePanel, setShowChatSidePanel] = useState(false); + + return ( + <> +
+
+ {isMobileScreen && ( +
+
+ } + bordered + title={Locale.Chat.Actions.ChatList} + onClick={() => navigate(Path.Home)} + /> +
)} -
- } - bordered - title={Locale.Chat.Actions.Export} - onClick={() => { - setShowExport(true); - }} - /> + +
+
setIsEditingMessage(true)} + > + {!session.topic ? DEFAULT_TOPIC : session.topic} +
+
+ {Locale.Chat.SubTitle(session.messages.length)} +
- {showMaxIcon && ( +
: } + icon={} bordered + title={Locale.Chat.Actions.RefreshTitle} onClick={() => { - config.update( - (config) => (config.tightBorder = !config.tightBorder), - ); + showToast(Locale.Chat.Actions.RefreshToast); + chatStore.summarizeSession(true, session); }} />
- )} + {!isMobileScreen && ( +
+ } + bordered + title={Locale.Chat.EditMessage.Title} + aria={Locale.Chat.EditMessage.Title} + onClick={() => setIsEditingMessage(true)} + /> +
+ )} +
+ } + bordered + title={Locale.Chat.Actions.Export} + onClick={() => { + setShowExport(true); + }} + /> +
+ {showMaxIcon && ( +
+ : } + bordered + title={Locale.Chat.Actions.FullScreen} + aria={Locale.Chat.Actions.FullScreen} + onClick={() => { + config.update( + (config) => (config.tightBorder = !config.tightBorder), + ); + }} + /> +
+ )} +
+ +
+
+
+
onChatBodyScroll(e.currentTarget)} + onMouseDown={() => inputRef.current?.blur()} + onTouchStart={() => { + inputRef.current?.blur(); + setAutoScroll(false); + }} + > + {messages.map((message, i) => { + const isUser = message.role === "user"; + const isContext = i < context.length; + const showActions = + i > 0 && + !(message.preview || message.content.length === 0) && + !isContext; + const showTyping = message.preview || message.streaming; - -
+ const shouldShowClearContextDivider = + i === clearContextIndex - 1; -
onChatBodyScroll(e.currentTarget)} - onMouseDown={() => inputRef.current?.blur()} - onTouchStart={() => { - inputRef.current?.blur(); - setAutoScroll(false); - }} - > - {messages.map((message, i) => { - const isUser = message.role === "user"; - const isContext = i < context.length; - const showActions = - i > 0 && - !(message.preview || message.content.length === 0) && - !isContext; - const showTyping = message.preview || message.streaming; - - const shouldShowClearContextDivider = i === clearContextIndex - 1; - - return ( - -
-
-
-
-
- } - onClick={async () => { - const newMessage = await showPrompt( - Locale.Chat.Actions.Edit, - getMessageTextContent(message), - 10, - ); - let newContent: string | MultimodalContent[] = - newMessage; - const images = getMessageImages(message); - if (images.length > 0) { - newContent = [{ type: "text", text: newMessage }]; - for (let i = 0; i < images.length; i++) { - newContent.push({ - type: "image_url", - image_url: { - url: images[i], - }, - }); - } - } - chatStore.updateCurrentSession((session) => { - const m = session.mask.context - .concat(session.messages) - .find((m) => m.id === message.id); - if (m) { - m.content = newContent; - } - }); - }} - > -
- {isUser ? ( - - ) : ( - <> - {["system"].includes(message.role) ? ( - - ) : ( - - )} - - )} -
- - {showActions && ( -
-
- {message.streaming ? ( - } - onClick={() => onUserStop(message.id ?? i)} - /> - ) : ( - <> - } - onClick={() => onResend(message)} - /> - - } - onClick={() => onDelete(message.id ?? i)} - /> - - } - onClick={() => onPinMessage(message)} - /> - } - onClick={() => - copyToClipboard( - getMessageTextContent(message), - ) - } - /> - {config.ttsConfig.enable && ( - - ) : ( - - ) - } - onClick={() => - openaiSpeech(getMessageTextContent(message)) - } - /> - )} - - )} -
-
- )} -
- {!isUser && - message.toolMessages && - message.toolMessages.map((tool, index) => ( -
-
- - {tool.toolName}: - - {tool.toolInput} - -
-
- ))} - - {showTyping && ( -
- {Locale.Chat.Typing} -
- )} -
- +
onRightClick(e, message)} - onDoubleClickCapture={() => { - if (!isMobileScreen) return; - setUserInput(getMessageTextContent(message)); - }} - fontSize={fontSize} - parentRef={scrollRef} - defaultShow={i >= messages.length - 6} - /> - {/* {message.fileInfos && message.fileInfos.length > 0 && ( - - )} */} - {getMessageImages(message).length == 1 && ( - - )} - {getMessageImages(message).length > 1 && ( -
- {getMessageImages(message).map((image, index) => { - return ( + > +
+
+
+
+ } + 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; + } + }, + ); + }} + > +
+ {isUser ? ( + + ) : ( + <> + {["system"].includes(message.role) ? ( + + ) : ( + + )} + + )} +
+ {!isUser && ( +
+ {message.model} +
+ )} + + {showActions && ( +
+
+ {message.streaming ? ( + } + onClick={() => onUserStop(message.id ?? i)} + /> + ) : ( + <> + } + onClick={() => onResend(message)} + /> + + } + onClick={() => onDelete(message.id ?? i)} + /> + + } + onClick={() => onPinMessage(message)} + /> + } + onClick={() => + copyToClipboard( + getMessageTextContent(message), + ) + } + /> + {config.ttsConfig.enable && ( + + ) : ( + + ) + } + onClick={() => + openaiSpeech( + getMessageTextContent(message), + ) + } + /> + )} + + )} +
+
+ )} +
+ {message?.tools?.length == 0 && showTyping && ( +
+ {Locale.Chat.Typing} +
+ )} + {/*@ts-ignore*/} + {message?.tools?.length > 0 && ( +
+ {message?.tools?.map((tool) => ( +
+ {tool.isError === false ? ( + + ) : tool.isError === true ? ( + + ) : ( + + )} + {tool?.function?.name} +
+ ))} +
+ )} +
+ 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 && ( - ); - })} + )} + {getMessageImages(message).length > 1 && ( +
+ {getMessageImages(message).map((image, index) => { + return ( + + ); + })} +
+ )} +
+ {message?.audio_url && ( +
+
+ )} + +
+ {isContext + ? Locale.Chat.IsContext + : message.date.toLocaleString()} +
- )} -
-
- {isContext - ? Locale.Chat.IsContext - : message.date.toLocaleString()} -
-
-
- {shouldShowClearContextDivider && } - - ); - })} -
- -
- - - setShowPromptModal(true)} - scrollToBottom={scrollToBottom} - hitBottom={hitBottom} - uploading={uploading} - showPromptHints={() => { - // Click again to close - if (promptHints.length > 0) { - setPromptHints([]); - return; - } - - inputRef.current?.focus(); - setUserInput("/"); - onSearch(""); - }} - /> -