mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-10-01 15:46:39 +08:00
Merge remote-tracking branch 'upstream/main' into dev
# Conflicts: # app/components/chat.tsx
This commit is contained in:
commit
9142cd3303
@ -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);
|
||||
}
|
@ -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: () => <LoadingIcon />,
|
||||
});
|
||||
@ -460,6 +464,7 @@ export function ChatActions(props: {
|
||||
showPromptHints: () => void;
|
||||
hitBottom: boolean;
|
||||
uploading: boolean;
|
||||
setShowShortcutKeyModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
const config = useAppConfig();
|
||||
const navigate = useNavigate();
|
||||
@ -782,6 +787,12 @@ export function ChatActions(props: {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChatAction
|
||||
onClick={() => props.setShowShortcutKeyModal(true)}
|
||||
text={Locale.Chat.ShortcutKey.Title}
|
||||
icon={<ShortcutkeyIcon />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<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 };
|
||||
|
||||
@ -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<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}>
|
||||
@ -1902,6 +2022,7 @@ function _Chat() {
|
||||
setUserInput("/");
|
||||
onSearch("");
|
||||
}}
|
||||
setShowShortcutKeyModal={setShowShortcutKeyModal}
|
||||
/>
|
||||
<label
|
||||
className={`${styles["chat-input-panel-inner"]} ${
|
||||
@ -1986,6 +2107,10 @@ function _Chat() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showShortcutKeyModal && (
|
||||
<ShortcutKeyModal onClose={() => setShowShortcutKeyModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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<any, IErrorBoundaryState> {
|
||||
try {
|
||||
useSyncStore.getState().export();
|
||||
} finally {
|
||||
localStorage.clear();
|
||||
location.reload();
|
||||
useChatStore.getState().clearAllData();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -426,16 +426,7 @@ export function MaskPage() {
|
||||
const maskStore = useMaskStore();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const [filterLang, setFilterLang] = useState<Lang | undefined>(
|
||||
() => 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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
1
app/icons/shortcutkey.svg
Normal file
1
app/icons/shortcutkey.svg
Normal file
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg width="16" height="16" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M42 7H6C4.89543 7 4 7.89543 4 9V37C4 38.1046 4.89543 39 6 39H42C43.1046 39 44 38.1046 44 37V9C44 7.89543 43.1046 7 42 7Z" fill="none" stroke="#000" stroke-width="3" stroke-linejoin="round"/><path d="M12 19H14" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M21 19H23" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M29 19H36" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 28H36" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
After Width: | Height: | Size: 734 B |
@ -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: "分享聊天记录",
|
||||
|
@ -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",
|
||||
|
@ -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() {
|
||||
|
@ -81,6 +81,14 @@ const tw = {
|
||||
SaveAs: "另存新檔",
|
||||
},
|
||||
IsContext: "預設提示詞",
|
||||
ShortcutKey: {
|
||||
Title: "鍵盤快捷方式",
|
||||
newChat: "打開新聊天",
|
||||
focusInput: "聚焦輸入框",
|
||||
copyLastMessage: "複製最後一個回覆",
|
||||
copyLastCode: "複製最後一個代碼塊",
|
||||
showShortcutKey: "顯示快捷方式",
|
||||
},
|
||||
},
|
||||
Export: {
|
||||
Title: "將聊天記錄匯出為 Markdown",
|
||||
|
@ -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;
|
||||
|
@ -23,9 +23,12 @@ export type Mask = {
|
||||
|
||||
export const DEFAULT_MASK_STATE = {
|
||||
masks: {} as Record<string, Mask>,
|
||||
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,
|
||||
|
60
app/utils.ts
60
app/utils.ts
@ -318,3 +318,63 @@ export function adapter(config: Record<string, unknown>) {
|
||||
: 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.",
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -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<string | null> {
|
||||
|
Loading…
Reference in New Issue
Block a user