diff --git a/app/components/chat.module.scss b/app/components/chat.module.scss index c62439b76..79dac5a8f 100644 --- a/app/components/chat.module.scss +++ b/app/components/chat.module.scss @@ -719,3 +719,51 @@ z-index: 1; color: #7d7d7d !important; } + +.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); +} \ No newline at end of file diff --git a/app/components/chat.tsx b/app/components/chat.tsx index ecc27b020..8bba19588 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -42,6 +42,7 @@ 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 UploadIcon from "../icons/upload.svg"; import { @@ -70,6 +71,7 @@ import { isVisionModel, isDalle3, showPlugins, + safeLocalStorage, } from "../utils"; import { uploadImage as uploadImageRemote } from "@/app/utils/chat"; @@ -121,6 +123,8 @@ import { white } from "kleur/colors"; // const VoiceInput = dynamic( // () => import('@/app/components/voice-input'), { ssr: false }); +const localStorage = safeLocalStorage(); + const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { loading: () => , }); @@ -460,6 +464,7 @@ export function ChatActions(props: { showPromptHints: () => void; hitBottom: boolean; uploading: boolean; + setShowShortcutKeyModal: React.Dispatch>; }) { const config = useAppConfig(); const navigate = useNavigate(); @@ -782,6 +787,12 @@ export function ChatActions(props: { }} /> )} + + props.setShowShortcutKeyModal(true)} + text={Locale.Chat.ShortcutKey.Title} + icon={} + /> ); } @@ -856,6 +867,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 ( +
+ } + key="ok" + onClick={() => { + props.onClose(); + }} + />, + ]} + > +
+
+ {shortcuts.map((shortcut, index) => ( +
+
+ {shortcut.title} +
+
+ {shortcut.keys.map((key, i) => ( +
+ {key} +
+ ))} +
+
+ ))} +
+
+
+
+ ); +} + function _Chat() { type RenderMessage = ChatMessage & { preview?: boolean }; @@ -972,7 +1044,7 @@ function _Chat() { }) .then(() => setIsLoading(false)); setAttachImages([]); - localStorage.setItem(LAST_INPUT_KEY, userInput); + chatStore.setLastInput(userInput); setUserInput(""); setMjImageMode("IMAGINE"); setPromptHints([]); @@ -1039,7 +1111,7 @@ function _Chat() { userInput.length <= 0 && !(e.metaKey || e.altKey || e.ctrlKey) ) { - setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? ""); + setUserInput(chatStore.lastInput ?? ""); e.preventDefault(); return; } @@ -1410,7 +1482,6 @@ function _Chat() { } setAttachImages(images); } - // 加载状态结束,获取token const [loadingChange, setLoadingChange] = useState(false); useEffect(() => { @@ -1439,20 +1510,69 @@ function _Chat() { }; }, [isLoading, loadingChange]); - // const [ voiceInputText, setVoiceInputText ] = useState(""); - // const [ voiceInputLoading, setVoiceInputLoading ] = useState(false); + // 快捷键 shortcut keys + const [showShortcutKeyModal, setShowShortcutKeyModal] = useState(false); - // useEffect(() => { - // if (voiceInputLoading) { - // // 正在进行语音输入,输入框应该显示原有文本加上语音输入的。 - // setUserInput(userInput + voiceInputText); - // } else { - // // 但是语音输入结束,应该清理多余字符。 - // console.log('end', userInput, voiceInputText) - // } - // - // // eslint-disable-next-line react-hooks/exhaustive-deps - // }, [voiceInputLoading, voiceInputText]); + 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]); return (
@@ -1902,6 +2022,7 @@ function _Chat() { setUserInput("/"); onSearch(""); }} + setShowShortcutKeyModal={setShowShortcutKeyModal} />
); } diff --git a/app/components/error.tsx b/app/components/error.tsx index c90997d11..4fcf759c1 100644 --- a/app/components/error.tsx +++ b/app/components/error.tsx @@ -8,6 +8,7 @@ import { ISSUE_URL } from "../constant"; import Locale from "../locales"; import { showConfirm } from "./ui-lib"; import { useSyncStore } from "../store/sync"; +import { useChatStore } from "../store/chat"; interface IErrorBoundaryState { hasError: boolean; @@ -30,8 +31,7 @@ export class ErrorBoundary extends React.Component { try { useSyncStore.getState().export(); } finally { - localStorage.clear(); - location.reload(); + useChatStore.getState().clearAllData(); } } diff --git a/app/components/mask.tsx b/app/components/mask.tsx index 62503c37a..ee6c7da97 100644 --- a/app/components/mask.tsx +++ b/app/components/mask.tsx @@ -426,16 +426,7 @@ export function MaskPage() { const maskStore = useMaskStore(); const chatStore = useChatStore(); - const [filterLang, setFilterLang] = useState( - () => localStorage.getItem("Mask-language") as Lang | undefined, - ); - useEffect(() => { - if (filterLang) { - localStorage.setItem("Mask-language", filterLang); - } else { - localStorage.removeItem("Mask-language"); - } - }, [filterLang]); + const filterLang = maskStore.language; const allMasks = maskStore .getAll() @@ -542,9 +533,9 @@ export function MaskPage() { onChange={(e) => { const value = e.currentTarget.value; if (value === Locale.Settings.Lang.All) { - setFilterLang(undefined); + maskStore.setLanguage(undefined); } else { - setFilterLang(value as Lang); + maskStore.setLanguage(value as Lang); } }} > diff --git a/app/icons/shortcutkey.svg b/app/icons/shortcutkey.svg new file mode 100644 index 000000000..32a4e7d3e --- /dev/null +++ b/app/icons/shortcutkey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 928e34324..4b18e2e75 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -1,3 +1,4 @@ +import { ShortcutKeyModal } from "../components/chat"; import { getClientConfig } from "../config/client"; import { SubmitKey } from "../store/config"; @@ -126,6 +127,14 @@ const cn = { SaveAs: "存为面具", }, IsContext: "预设提示词", + ShortcutKey: { + Title: "键盘快捷方式", + newChat: "打开新聊天", + focusInput: "聚焦输入框", + copyLastMessage: "复制最后一个回复", + copyLastCode: "复制最后一个代码块", + showShortcutKey: "显示快捷方式", + }, }, Export: { Title: "分享聊天记录", diff --git a/app/locales/en.ts b/app/locales/en.ts index 0fbde05e1..b84065461 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -128,6 +128,14 @@ const en: LocaleType = { SaveAs: "Save as Mask", }, IsContext: "Contextual Prompt", + ShortcutKey: { + Title: "Keyboard Shortcuts", + newChat: "Open New Chat", + focusInput: "Focus Input Field", + copyLastMessage: "Copy Last Reply", + copyLastCode: "Copy Last Code Block", + showShortcutKey: "Show Shortcuts", + }, }, Export: { Title: "Export Messages", diff --git a/app/locales/index.ts b/app/locales/index.ts index 5690f9874..6bf4e41d2 100644 --- a/app/locales/index.ts +++ b/app/locales/index.ts @@ -18,10 +18,13 @@ import en from "./en"; // import bn from "./bn"; // import sk from "./sk"; import { merge } from "../utils/merge"; +import { safeLocalStorage } from "@/app/utils"; import type { LocaleType } from "./cn"; export type { LocaleType, PartialLocaleType } from "./cn"; +const localStorage = safeLocalStorage(); + const ALL_LANGS = { cn, en, @@ -82,17 +85,11 @@ merge(fallbackLang, targetLang); export default fallbackLang as LocaleType; function getItem(key: string) { - try { - return localStorage.getItem(key); - } catch { - return null; - } + return localStorage.getItem(key); } function setItem(key: string, value: string) { - try { - localStorage.setItem(key, value); - } catch {} + localStorage.setItem(key, value); } function getLanguage() { diff --git a/app/locales/tw.ts b/app/locales/tw.ts index 6b2c0fd65..c54a7b8c5 100644 --- a/app/locales/tw.ts +++ b/app/locales/tw.ts @@ -81,6 +81,14 @@ const tw = { SaveAs: "另存新檔", }, IsContext: "預設提示詞", + ShortcutKey: { + Title: "鍵盤快捷方式", + newChat: "打開新聊天", + focusInput: "聚焦輸入框", + copyLastMessage: "複製最後一個回覆", + copyLastCode: "複製最後一個代碼塊", + showShortcutKey: "顯示快捷方式", + }, }, Export: { Title: "將聊天記錄匯出為 Markdown", diff --git a/app/store/chat.ts b/app/store/chat.ts index a66cef0d1..0a164ed0d 100644 --- a/app/store/chat.ts +++ b/app/store/chat.ts @@ -31,9 +31,11 @@ import { nanoid } from "nanoid"; import { createPersistStore } from "../utils/store"; import { collectModelsWithDefaultModel } from "../utils/model"; import { useAccessStore } from "./access"; -import { isDalle3 } from "../utils"; +import { isDalle3, safeLocalStorage } from "../utils"; import { indexedDBStorage } from "@/app/utils/indexedDB-storage"; +const localStorage = safeLocalStorage(); + export type ChatMessageTool = { id: string; index?: number; @@ -199,6 +201,7 @@ function fillTemplateWith(input: string, modelConfig: ModelConfig) { const DEFAULT_CHAT_STATE = { sessions: [createEmptySession()], currentSessionIndex: 0, + lastInput: "", }; export const useChatStore = createPersistStore( @@ -1058,6 +1061,11 @@ export const useChatStore = createPersistStore( localStorage.clear(); location.reload(); }, + setLastInput(lastInput: string) { + set({ + lastInput, + }); + }, }; return methods; diff --git a/app/store/mask.ts b/app/store/mask.ts index 083121b65..0c74a892e 100644 --- a/app/store/mask.ts +++ b/app/store/mask.ts @@ -23,9 +23,12 @@ export type Mask = { export const DEFAULT_MASK_STATE = { masks: {} as Record, + language: undefined as Lang | undefined, }; -export type MaskState = typeof DEFAULT_MASK_STATE; +export type MaskState = typeof DEFAULT_MASK_STATE & { + language?: Lang | undefined; +}; export const DEFAULT_MASK_AVATAR = "gpt-bot"; export const createEmptyMask = () => @@ -102,6 +105,11 @@ export const useMaskStore = createPersistStore( search(text: string) { return Object.values(get().masks); }, + setLanguage(language: Lang | undefined) { + set({ + language, + }); + }, }), { name: StoreKey.Mask, diff --git a/app/utils.ts b/app/utils.ts index 60041ba06..bf7450929 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -318,3 +318,63 @@ export function adapter(config: Record) { : path; return fetch(fetchUrl as string, { ...rest, responseType: "text" }); } + +export function safeLocalStorage(): { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; + removeItem: (key: string) => void; + clear: () => void; +} { + let storage: Storage | null; + + try { + if (typeof window !== "undefined" && window.localStorage) { + storage = window.localStorage; + } else { + storage = null; + } + } catch (e) { + console.error("localStorage is not available:", e); + storage = null; + } + + return { + getItem(key: string): string | null { + if (storage) { + return storage.getItem(key); + } else { + console.warn( + `Attempted to get item "${key}" from localStorage, but localStorage is not available.`, + ); + return null; + } + }, + setItem(key: string, value: string): void { + if (storage) { + storage.setItem(key, value); + } else { + console.warn( + `Attempted to set item "${key}" in localStorage, but localStorage is not available.`, + ); + } + }, + removeItem(key: string): void { + if (storage) { + storage.removeItem(key); + } else { + console.warn( + `Attempted to remove item "${key}" from localStorage, but localStorage is not available.`, + ); + } + }, + clear(): void { + if (storage) { + storage.clear(); + } else { + console.warn( + "Attempted to clear localStorage, but localStorage is not available.", + ); + } + }, + }; +} diff --git a/app/utils/indexedDB-storage.ts b/app/utils/indexedDB-storage.ts index da3094550..51417e9f3 100644 --- a/app/utils/indexedDB-storage.ts +++ b/app/utils/indexedDB-storage.ts @@ -1,5 +1,8 @@ import { StateStorage } from "zustand/middleware"; import { get, set, del, clear } from "idb-keyval"; +import { safeLocalStorage } from "@/app/utils"; + +const localStorage = safeLocalStorage(); class IndexedDBStorage implements StateStorage { public async getItem(name: string): Promise {