diff --git a/.github/ISSUE_TEMPLATE/反馈问题.md b/.github/ISSUE_TEMPLATE/反馈问题.md index ea56aa6fa..b21442f53 100644 --- a/.github/ISSUE_TEMPLATE/反馈问题.md +++ b/.github/ISSUE_TEMPLATE/反馈问题.md @@ -8,6 +8,9 @@ assignees: '' --- **反馈须知** + +⚠️ 注意:不遵循此模板的任何帖子都会被立即关闭。 + > 请在下方中括号内输入 x 来表示你已经知晓相关内容。 - [ ] 我确认已经在 [常见问题](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/faq-cn.md) 中搜索了此次反馈的问题,没有找到解答; - [ ] 我确认已经在 [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) 列表(包括已经 Close 的)中搜索了此次反馈的问题,没有找到解答。 diff --git a/README.md b/README.md index a6288798a..a6d1e3d33 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ One-Click to deploy well-designed ChatGPT web UI on Vercel. [Demo](https://chat-gpt-next-web.vercel.app/) / [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Join Discord](https://discord.gg/zrhvHCr79N) / [Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa) -[演示](https://chat-gpt-next-web.vercel.app/) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [QQ 群](https://user-images.githubusercontent.com/16968934/231789746-41f34d05-6ef9-43f3-a1d1-ff109d4c3c14.jpg) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) +[演示](https://chat-gpt-next-web.vercel.app/) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [QQ 群](https://user-images.githubusercontent.com/16968934/233002565-139daa1a-eb3a-4a12-ac37-6418e7a15d36.png) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) @@ -35,10 +35,12 @@ One-Click to deploy well-designed ChatGPT web UI on Vercel. ## Roadmap - [x] System Prompt: pin a user defined prompt as system prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138) -- [ ] User Prompt: user can edit and save custom prompts to prompt list +- [x] User Prompt: user can edit and save custom prompts to prompt list +- [ ] Prompt Template: create a new chat with pre-defined in-context prompts +- [ ] Share as image, share to ShareGPT - [ ] Desktop App with tauri - [ ] Self-host Model: support llama, alpaca, ChatGLM, BELLE etc. -- [ ] Plugins: support network search, caculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) +- [ ] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) ### Not in Plan @@ -59,7 +61,9 @@ One-Click to deploy well-designed ChatGPT web UI on Vercel. ## 开发计划 - [x] 为每个对话设置系统 Prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138) -- [ ] 允许用户自行编辑内置 Prompt 列表 +- [x] 允许用户自行编辑内置 Prompt 列表 +- [ ] 提示词模板:使用预制上下文快速定制新对话 +- [ ] 分享为图片,分享到 ShareGPT - [ ] 使用 tauri 打包桌面应用 - [ ] 支持自部署的大语言模型 - [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) @@ -98,7 +102,7 @@ We recommend that you follow the steps below to re-deploy: ### Enable Automatic Updates -After forking the project, due to the limitations imposed by Github, you need to manually enable Workflows and Upstream Sync Action on the Actions page of the forked project. Once enabled, automatic updates will be scheduled every hour: +After forking the project, due to the limitations imposed by GitHub, you need to manually enable Workflows and Upstream Sync Action on the Actions page of the forked project. Once enabled, automatic updates will be scheduled every hour: ![Automatic Updates](./docs/images/enable-actions.jpg) @@ -106,7 +110,7 @@ After forking the project, due to the limitations imposed by Github, you need to ### Manually Updating Code -If you want to update instantly, you can check out the [Github documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) to learn how to synchronize a forked project with upstream code. +If you want to update instantly, you can check out the [GitHub documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) to learn how to synchronize a forked project with upstream code. You can star or watch this project or follow author to get release notifictions in time. @@ -142,6 +146,10 @@ Access passsword, separated by comma. Override openai api request base url. +### `OPENAI_ORG_ID` (optional) + +Specify OpenAI organization ID. + ## Development > [简体中文 > 如何进行二次开发](./README_CN.md#开发) @@ -221,6 +229,9 @@ bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/s [@chazzhou](https://github.com/chazzhou) [@hauy](https://github.com/hauy) [@Corwin006](https://github.com/Corwin006) +[@yankunsong](https://github.com/yankunsong) +[@ypwhs](https://github.com/ypwhs) +[@fxxxchao](https://github.com/fxxxchao) ### Contributor diff --git a/README_CN.md b/README_CN.md index d2d64aa00..23dfee85a 100644 --- a/README_CN.md +++ b/README_CN.md @@ -94,6 +94,10 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填 > 如果遇到 ssl 证书问题,请将 `BASE_URL` 的协议设置为 http。 +### `OPENAI_ORG_ID` (可选) + +指定 OpenAI 中的组织 ID。 + ## 开发 > 强烈不建议在本地进行开发或者部署,由于一些技术原因,很难在本地配置好 OpenAI API 代理,除非你能保证可以直连 OpenAI 服务器。 @@ -110,14 +114,16 @@ OPENAI_API_KEY= ### 本地开发 -1. 安装 nodejs 和 yarn,具体细节请询问 ChatGPT; -2. 执行 `yarn install && yarn dev` 即可。 +1. 安装 nodejs 18 和 yarn,具体细节请询问 ChatGPT; +2. 执行 `yarn install && yarn dev` 即可。⚠️注意:此命令仅用于本地开发,不要用于部署! +3. 如果你想本地部署,请使用 `yarn install && yarn start` 命令,你可以配合 pm2 来守护进程,防止被杀死,详情询问 ChatGPT。 ## 部署 ### 容器部署 (推荐) +> Docker 版本需要在 20 及其以上,否则会提示找不到镜像。 -> 注意:docker 版本在大多数时间都会落后最新的版本 1 到 2 天,所以部署后会持续出现“存在更新”的提示,属于正常现象。 +> ⚠️注意:docker 版本在大多数时间都会落后最新的版本 1 到 2 天,所以部署后会持续出现“存在更新”的提示,属于正常现象。 ```shell docker pull yidadaa/chatgpt-next-web @@ -139,6 +145,8 @@ docker run -d -p 3000:3000 \ yidadaa/chatgpt-next-web ``` +如果你需要指定其他环境变量,请自行在上述命令中增加 `-e 环境变量=环境变量值` 来指定。 + ### 本地部署 在控制台运行下方命令: @@ -147,6 +155,8 @@ docker run -d -p 3000:3000 \ bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh) ``` +⚠️注意:如果你安装过程中遇到了问题,请使用 docker 部署。 + ## 鸣谢 ### 捐赠者 diff --git a/app/api/chat-stream/route.ts b/app/api/chat-stream/route.ts index 41f135495..22550e39c 100644 --- a/app/api/chat-stream/route.ts +++ b/app/api/chat-stream/route.ts @@ -59,6 +59,4 @@ export async function POST(req: NextRequest) { } } -export const config = { - runtime: "edge", -}; +export const runtime = "experimental-edge"; diff --git a/app/api/common.ts b/app/api/common.ts index 53ab18ed6..22e71884f 100644 --- a/app/api/common.ts +++ b/app/api/common.ts @@ -18,10 +18,15 @@ export async function requestOpenai(req: NextRequest) { console.log("[Proxy] ", openaiPath); console.log("[Base Url]", baseUrl); + if (process.env.OPENAI_ORG_ID) { + console.log("[Org ID]", process.env.OPENAI_ORG_ID); + } + return fetch(`${baseUrl}/${openaiPath}`, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, + ...(process.env.OPENAI_ORG_ID && { "OpenAI-Organization": process.env.OPENAI_ORG_ID }), }, method: req.method, body: req.body, diff --git a/app/api/openai/route.ts b/app/api/openai/route.ts index 261c20a85..bed70d928 100644 --- a/app/api/openai/route.ts +++ b/app/api/openai/route.ts @@ -17,7 +17,7 @@ async function makeRequest(req: NextRequest) { }, { status: 500, - }, + } ); } } @@ -29,3 +29,5 @@ export async function POST(req: NextRequest) { export async function GET(req: NextRequest) { return makeRequest(req); } + +export const runtime = "experimental-edge"; diff --git a/app/components/chat-list.tsx b/app/components/chat-list.tsx index cab8812c3..626336afd 100644 --- a/app/components/chat-list.tsx +++ b/app/components/chat-list.tsx @@ -10,7 +10,8 @@ import { import { useChatStore } from "../store"; import Locale from "../locales"; -import { isMobileScreen } from "../utils"; +import { Link, useNavigate } from "react-router-dom"; +import { Path } from "../constant"; export function ChatItem(props: { onClick?: () => void; @@ -21,6 +22,7 @@ export function ChatItem(props: { selected: boolean; id: number; index: number; + narrow?: boolean; }) { return ( @@ -34,13 +36,20 @@ export function ChatItem(props: { {...provided.draggableProps} {...provided.dragHandleProps} > -
{props.title}
-
-
- {Locale.ChatItem.ChatItemCount(props.count)} -
-
{props.time}
-
+ {props.narrow ? ( +
{props.count}
+ ) : ( + <> +
{props.title}
+
+
+ {Locale.ChatItem.ChatItemCount(props.count)} +
+
{props.time}
+
+ + )} +
@@ -50,7 +59,7 @@ export function ChatItem(props: { ); } -export function ChatList() { +export function ChatList(props: { narrow?: boolean }) { const [sessions, selectedIndex, selectSession, removeSession, moveSession] = useChatStore((state) => [ state.sessions, @@ -60,6 +69,7 @@ export function ChatList() { state.moveSession, ]); const chatStore = useChatStore(); + const navigate = useNavigate(); const onDragEnd: OnDragEndResponder = (result) => { const { destination, source } = result; @@ -95,8 +105,16 @@ export function ChatList() { id={item.id} index={i} selected={i === selectedIndex} - onClick={() => selectSession(i)} - onDelete={() => chatStore.deleteSession(i)} + onClick={() => { + navigate(Path.Chat); + selectSession(i); + }} + onDelete={() => { + if (!props.narrow || confirm(Locale.Home.DeleteChat)) { + chatStore.deleteSession(i); + } + }} + narrow={props.narrow} /> ))} {provided.placeholder} diff --git a/app/components/chat.tsx b/app/components/chat.tsx index f7b67b293..b80bf5a18 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -10,6 +10,7 @@ import CopyIcon from "../icons/copy.svg"; import DownloadIcon from "../icons/download.svg"; import LoadingIcon from "../icons/three-dots.svg"; import BotIcon from "../icons/bot.svg"; +import BlackBotIcon from "../icons/black-bot.svg"; import AddIcon from "../icons/add.svg"; import DeleteIcon from "../icons/delete.svg"; import MaxIcon from "../icons/max.svg"; @@ -19,6 +20,7 @@ import LightIcon from "../icons/light.svg"; import DarkIcon from "../icons/dark.svg"; import AutoIcon from "../icons/auto.svg"; import BottomIcon from "../icons/bottom.svg"; +import StopIcon from "../icons/pause.svg"; import { Message, @@ -29,16 +31,17 @@ import { createMessage, useAccessStore, Theme, + ModelType, + useAppConfig, } from "../store"; import { copyToClipboard, downloadAs, getEmojiUrl, - isMobileScreen, selectOrCopy, autoGrowTextArea, - getCSSVar, + useMobileScreen, } from "../utils"; import dynamic from "next/dynamic"; @@ -52,6 +55,8 @@ import styles from "./home.module.scss"; import chatStyle from "./chat.module.scss"; import { Input, Modal, showModal } from "./ui-lib"; +import { useNavigate } from "react-router-dom"; +import { Path } from "../constant"; const Markdown = dynamic( async () => memo((await import("./markdown")).Markdown), @@ -64,13 +69,17 @@ const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, { loading: () => , }); -export function Avatar(props: { role: Message["role"] }) { - const config = useChatStore((state) => state.config); +export function Avatar(props: { role: Message["role"]; model?: ModelType }) { + const config = useAppConfig(); if (props.role !== "user") { return (
- + {props.model?.startsWith("gpt-4") ? ( + + ) : ( + + )}
); } @@ -277,7 +286,7 @@ function PromptToast(props: { } function useSubmitHandler() { - const config = useChatStore((state) => state.config); + const config = useAppConfig(); const submitKey = config.submitKey; const shouldSubmit = (e: React.KeyboardEvent) => { @@ -353,20 +362,32 @@ export function ChatActions(props: { scrollToBottom: () => void; hitBottom: boolean; }) { - const chatStore = useChatStore(); - - const theme = chatStore.config.theme; + const config = useAppConfig(); + // switch themes + const theme = config.theme; function nextTheme() { const themes = [Theme.Auto, Theme.Light, Theme.Dark]; const themeIndex = themes.indexOf(theme); const nextIndex = (themeIndex + 1) % themes.length; const nextTheme = themes[nextIndex]; - chatStore.updateConfig((config) => (config.theme = nextTheme)); + config.update((config) => (config.theme = nextTheme)); } + // stop all responses + const couldStop = ControllerPool.hasPending(); + const stopAll = () => ControllerPool.stopAll(); + return (
+ {couldStop && ( +
+ +
+ )} {!props.hitBottom && (
void; - sideBarShowing?: boolean; -}) { +export function Chat() { type RenderMessage = Message & { preview?: boolean }; const chatStore = useChatStore(); @@ -411,7 +429,8 @@ export function Chat(props: { state.currentSession(), state.currentSessionIndex, ]); - const fontSize = useChatStore((state) => state.config.fontSize); + const config = useAppConfig(); + const fontSize = config.fontSize; const inputRef = useRef(null); const [userInput, setUserInput] = useState(""); @@ -420,6 +439,8 @@ export function Chat(props: { const { submitKey, shouldSubmit } = useSubmitHandler(); const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom(); const [hitBottom, setHitBottom] = useState(false); + const isMobileScreen = useMobileScreen(); + const navigate = useNavigate(); const onChatBodyScroll = (e: HTMLElement) => { const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 20; @@ -450,7 +471,7 @@ export function Chat(props: { const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1; const inputRows = Math.min( 5, - Math.max(2 + Number(!isMobileScreen()), rows), + Math.max(2 + Number(!isMobileScreen), rows), ); setInputRows(inputRows); }, @@ -473,7 +494,7 @@ export function Chat(props: { // clear search results if (n === 0) { setPromptHints([]); - } else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) { + } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) { // check if need to trigger auto completion if (text.startsWith("/")) { let searchText = text.slice(1); @@ -490,7 +511,7 @@ export function Chat(props: { setBeforeInput(userInput); setUserInput(""); setPromptHints([]); - if (!isMobileScreen()) inputRef.current?.focus(); + if (!isMobileScreen) inputRef.current?.focus(); setAutoScroll(true); }; @@ -524,24 +545,45 @@ export function Chat(props: { } }; - const onResend = (botIndex: number) => { + const findLastUserIndex = (messageId: number) => { // find last user input message and resend - for (let i = botIndex; i >= 0; i -= 1) { - if (messages[i].role === "user") { - setIsLoading(true); - chatStore - .onUserInput(messages[i].content) - .then(() => setIsLoading(false)); - chatStore.updateCurrentSession((session) => - session.messages.splice(i, 2), - ); - inputRef.current?.focus(); - return; + let lastUserMessageIndex: number | null = null; + for (let i = 0; i < session.messages.length; i += 1) { + const message = session.messages[i]; + if (message.id === messageId) { + break; + } + if (message.role === "user") { + lastUserMessageIndex = i; } } + + return lastUserMessageIndex; }; - const config = useChatStore((state) => state.config); + const deleteMessage = (userIndex: number) => { + chatStore.updateCurrentSession((session) => + session.messages.splice(userIndex, 2), + ); + }; + + const onDelete = (botMessageId: number) => { + const userIndex = findLastUserIndex(botMessageId); + if (userIndex === null) return; + deleteMessage(userIndex); + }; + + const onResend = (botMessageId: number) => { + // find last user input message and resend + const userIndex = findLastUserIndex(botMessageId); + if (userIndex === null) return; + + setIsLoading(true); + const content = session.messages[userIndex].content; + deleteMessage(userIndex); + chatStore.onUserInput(content).then(() => setIsLoading(false)); + inputRef.current?.focus(); + }; const context: RenderMessage[] = session.context.slice(); @@ -599,7 +641,7 @@ export function Chat(props: { // Auto focus useEffect(() => { - if (props.sideBarShowing && isMobileScreen()) return; + if (isMobileScreen) return; inputRef.current?.focus(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -624,7 +666,7 @@ export function Chat(props: { icon={} bordered title={Locale.Chat.Actions.ChatList} - onClick={props?.showSideBar} + onClick={() => navigate(Path.Home)} />
@@ -647,13 +689,13 @@ export function Chat(props: { }} />
- {!isMobileScreen() && ( + {!isMobileScreen && (
: } + icon={config.tightBorder ? : } bordered onClick={() => { - chatStore.updateConfig( + config.update( (config) => (config.tightBorder = !config.tightBorder), ); }} @@ -673,6 +715,7 @@ export function Chat(props: { className={styles["chat-body"]} ref={scrollRef} onScroll={(e) => onChatBodyScroll(e.currentTarget)} + onMouseDown={() => inputRef.current?.blur()} onWheel={(e) => setAutoScroll(hitBottom && e.deltaY > 0)} onTouchStart={() => { inputRef.current?.blur(); @@ -681,6 +724,11 @@ export function Chat(props: { > {messages.map((message, i) => { const isUser = message.role === "user"; + const showActions = + !isUser && + i > 0 && + !(message.preview || message.content.length === 0); + const showTyping = message.preview || message.streaming; return (
- +
- {(message.preview || message.streaming) && ( + {showTyping && (
{Locale.Chat.Typing}
)}
- {!isUser && - !(message.preview || message.content.length === 0) && ( -
- {message.streaming ? ( + {showActions && ( +
+ {message.streaming ? ( +
onUserStop(message.id ?? i)} + > + {Locale.Chat.Actions.Stop} +
+ ) : ( + <>
onUserStop(message.id ?? i)} + onClick={() => onDelete(message.id ?? i)} > - {Locale.Chat.Actions.Stop} + {Locale.Chat.Actions.Delete}
- ) : (
onResend(i)} + onClick={() => onResend(message.id ?? i)} > {Locale.Chat.Actions.Retry}
- )} + + )} -
copyToClipboard(message.content)} - > - {Locale.Chat.Actions.Copy} -
+
copyToClipboard(message.content)} + > + {Locale.Chat.Actions.Copy}
- )} +
+ )} onRightClick(e, message)} onDoubleClickCapture={() => { - if (!isMobileScreen()) return; + if (!isMobileScreen) return; setUserInput(message.content); }} fontSize={fontSize} @@ -775,7 +830,7 @@ export function Chat(props: { setAutoScroll(false); setTimeout(() => setPromptHints([]), 500); }} - autoFocus={!props?.sideBarShowing} + autoFocus rows={inputRows} /> .chat-item-delete { + opacity: 0.5; + right: 5px; + } + + .sidebar-tail { + flex-direction: column; + align-items: center; + + .sidebar-actions { + flex-direction: column; + align-items: center; + + .sidebar-action { + margin-right: 0; + margin-bottom: 15px; + } + } + } +} + .sidebar-tail { display: flex; justify-content: space-between; @@ -246,6 +313,10 @@ .chat-message { display: flex; flex-direction: row; + + &:last-child { + animation: slide-in ease 0.3s; + } } .chat-message-user { @@ -258,7 +329,6 @@ display: flex; flex-direction: column; align-items: flex-start; - animation: slide-in ease 0.3s; &:hover { .chat-message-top-actions { diff --git a/app/components/home.tsx b/app/components/home.tsx index 828b7576a..32334028f 100644 --- a/app/components/home.tsx +++ b/app/components/home.tsx @@ -2,32 +2,32 @@ require("../polyfill"); -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, StyleHTMLAttributes } from "react"; -import { IconButton } from "./button"; import styles from "./home.module.scss"; -import SettingsIcon from "../icons/settings.svg"; -import GithubIcon from "../icons/github.svg"; -import ChatGptIcon from "../icons/chatgpt.svg"; - import BotIcon from "../icons/bot.svg"; -import AddIcon from "../icons/add.svg"; import LoadingIcon from "../icons/three-dots.svg"; -import CloseIcon from "../icons/close.svg"; -import { useChatStore } from "../store"; -import { getCSSVar, isMobileScreen } from "../utils"; -import Locale from "../locales"; +import { getCSSVar, useMobileScreen } from "../utils"; import { Chat } from "./chat"; import dynamic from "next/dynamic"; -import { REPO_URL } from "../constant"; +import { Path } from "../constant"; import { ErrorBoundary } from "./error"; +import { + HashRouter as Router, + Routes, + Route, + useLocation, +} from "react-router-dom"; +import { SideBar } from "./sidebar"; +import { useAppConfig } from "../store/config"; + export function Loading(props: { noLogo?: boolean }) { return ( -
+
{!props.noLogo && }
@@ -38,12 +38,8 @@ const Settings = dynamic(async () => (await import("./settings")).Settings, { loading: () => , }); -const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, { - loading: () => , -}); - -function useSwitchTheme() { - const config = useChatStore((state) => state.config); +export function useSwitchTheme() { + const config = useAppConfig(); useEffect(() => { document.body.classList.remove("light"); @@ -73,53 +69,6 @@ function useSwitchTheme() { }, [config.theme]); } -function useDragSideBar() { - const limit = (x: number) => Math.min(500, Math.max(220, x)); - - const chatStore = useChatStore(); - const startX = useRef(0); - const startDragWidth = useRef(chatStore.config.sidebarWidth ?? 300); - const lastUpdateTime = useRef(Date.now()); - - const handleMouseMove = useRef((e: MouseEvent) => { - if (Date.now() < lastUpdateTime.current + 100) { - return; - } - lastUpdateTime.current = Date.now(); - const d = e.clientX - startX.current; - const nextWidth = limit(startDragWidth.current + d); - chatStore.updateConfig((config) => (config.sidebarWidth = nextWidth)); - }); - - const handleMouseUp = useRef(() => { - startDragWidth.current = chatStore.config.sidebarWidth ?? 300; - window.removeEventListener("mousemove", handleMouseMove.current); - window.removeEventListener("mouseup", handleMouseUp.current); - }); - - const onDragMouseDown = (e: MouseEvent) => { - startX.current = e.clientX; - - window.addEventListener("mousemove", handleMouseMove.current); - window.addEventListener("mouseup", handleMouseUp.current); - }; - - useEffect(() => { - if (isMobileScreen()) { - return; - } - - document.documentElement.style.setProperty( - "--sidebar-width", - `${limit(chatStore.config.sidebarWidth ?? 300)}px`, - ); - }, [chatStore.config.sidebarWidth]); - - return { - onDragMouseDown, - }; -} - const useHasHydrated = () => { const [hasHydrated, setHasHydrated] = useState(false); @@ -130,129 +79,58 @@ const useHasHydrated = () => { return hasHydrated; }; -function _Home() { - const [createNewSession, currentIndex, removeSession] = useChatStore( - (state) => [ - state.newSession, - state.currentSessionIndex, - state.removeSession, - ], - ); - const chatStore = useChatStore(); - const loading = !useHasHydrated(); - const [showSideBar, setShowSideBar] = useState(true); - - // setting - const [openSettings, setOpenSettings] = useState(false); - const config = useChatStore((state) => state.config); - - // drag side bar - const { onDragMouseDown } = useDragSideBar(); - - useSwitchTheme(); - - if (loading) { - return ; - } +function WideScreen() { + const config = useAppConfig(); return (
-
-
-
ChatGPT Next
-
- Build your own AI assistant. -
-
- -
-
- -
{ - setOpenSettings(false); - setShowSideBar(false); - }} - > - -
- -
-
-
- } - onClick={chatStore.deleteSession} - /> -
-
- } - onClick={() => { - setOpenSettings(true); - setShowSideBar(false); - }} - shadow - /> -
- -
-
- } - text={Locale.Home.NewChat} - onClick={() => { - createNewSession(); - setShowSideBar(false); - }} - shadow - /> -
-
- -
onDragMouseDown(e as any)} - >
-
+
- {openSettings ? ( - { - setOpenSettings(false); - setShowSideBar(true); - }} - /> - ) : ( - setShowSideBar(true)} - sideBarShowing={showSideBar} - /> - )} + + } /> + } /> + } /> + +
+
+ ); +} + +function MobileScreen() { + const location = useLocation(); + const isHome = location.pathname === Path.Home; + + return ( +
+ + +
+ + + } /> + } /> +
); } export function Home() { + const isMobileScreen = useMobileScreen(); + useSwitchTheme(); + + if (!useHasHydrated()) { + return ; + } + return ( - <_Home> + {isMobileScreen ? : } ); } diff --git a/app/components/settings.module.scss b/app/components/settings.module.scss index 830e1baeb..b7f095580 100644 --- a/app/components/settings.module.scss +++ b/app/components/settings.module.scss @@ -32,3 +32,63 @@ min-width: 80%; } } + +.user-prompt-modal { + min-height: 40vh; + + .user-prompt-search { + width: 100%; + max-width: 100%; + margin-bottom: 10px; + background-color: var(--gray); + } + + .user-prompt-list { + padding: 10px 0; + + .user-prompt-item { + margin-bottom: 10px; + widows: 100%; + + .user-prompt-header { + display: flex; + widows: 100%; + margin-bottom: 5px; + + .user-prompt-title { + flex-grow: 1; + max-width: 100%; + margin-right: 5px; + padding: 5px; + font-size: 12px; + text-align: left; + } + + .user-prompt-buttons { + display: flex; + align-items: center; + + .user-prompt-button { + height: 100%; + + &:not(:last-child) { + margin-right: 5px; + } + } + } + } + + .user-prompt-content { + width: 100%; + box-sizing: border-box; + padding: 5px; + margin-right: 10px; + font-size: 12px; + flex-grow: 1; + } + } + } + + .user-prompt-actions { + } +} diff --git a/app/components/settings.tsx b/app/components/settings.tsx index e418d4843..4dba46b43 100644 --- a/app/components/settings.tsx +++ b/app/components/settings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, HTMLProps } from "react"; +import { useState, useEffect, useMemo, HTMLProps, useRef } from "react"; import EmojiPicker, { Theme as EmojiTheme } from "emoji-picker-react"; @@ -6,12 +6,13 @@ import styles from "./settings.module.scss"; import ResetIcon from "../icons/reload.svg"; import CloseIcon from "../icons/close.svg"; +import CopyIcon from "../icons/copy.svg"; import ClearIcon from "../icons/clear.svg"; import EditIcon from "../icons/edit.svg"; import EyeIcon from "../icons/eye.svg"; import EyeOffIcon from "../icons/eye-off.svg"; -import { List, ListItem, Popover, showToast } from "./ui-lib"; +import { Input, List, ListItem, Modal, Popover } from "./ui-lib"; import { IconButton } from "./button"; import { @@ -22,17 +23,119 @@ import { useUpdateStore, useAccessStore, ModalConfigValidator, + useAppConfig, } from "../store"; import { Avatar } from "./chat"; import Locale, { AllLangs, changeLang, getLang } from "../locales"; -import { getEmojiUrl } from "../utils"; +import { copyToClipboard, getEmojiUrl } from "../utils"; import Link from "next/link"; -import { UPDATE_URL } from "../constant"; -import { SearchService, usePromptStore } from "../store/prompt"; -import { requestUsage } from "../requests"; +import { Path, UPDATE_URL } from "../constant"; +import { Prompt, SearchService, usePromptStore } from "../store/prompt"; import { ErrorBoundary } from "./error"; import { InputRange } from "./input-range"; +import { useNavigate } from "react-router-dom"; + +function UserPromptModal(props: { onClose?: () => void }) { + const promptStore = usePromptStore(); + const userPrompts = promptStore.getUserPrompts(); + const builtinPrompts = SearchService.builtinPrompts; + const allPrompts = userPrompts.concat(builtinPrompts); + const [searchInput, setSearchInput] = useState(""); + const [searchPrompts, setSearchPrompts] = useState([]); + const prompts = searchInput.length > 0 ? searchPrompts : allPrompts; + + useEffect(() => { + if (searchInput.length > 0) { + const searchResult = SearchService.search(searchInput); + setSearchPrompts(searchResult); + } else { + setSearchPrompts([]); + } + }, [searchInput]); + + return ( +
+ props.onClose?.()} + actions={[ + promptStore.add({ title: "", content: "" })} + icon={} + bordered + text={Locale.Settings.Prompt.Modal.Add} + />, + ]} + > +
+ setSearchInput(e.currentTarget.value)} + > + +
+ {prompts.map((v, _) => ( +
+
+ { + if (v.isUser) { + promptStore.updateUserPrompts( + v.id!, + (prompt) => (prompt.title = e.currentTarget.value), + ); + } + }} + > + +
+ {v.isUser && ( + } + bordered + className={styles["user-prompt-button"]} + onClick={() => promptStore.remove(v.id!)} + /> + )} + } + bordered + className={styles["user-prompt-button"]} + onClick={() => copyToClipboard(v.content)} + /> +
+
+ { + if (v.isUser) { + promptStore.updateUserPrompts( + v.id!, + (prompt) => (prompt.content = e.currentTarget.value), + ); + } + }} + /> +
+ ))} +
+
+
+
+ ); +} function SettingItem(props: { title: string; @@ -75,16 +178,16 @@ function PasswordInput(props: HTMLProps) { ); } -export function Settings(props: { closeSettings: () => void }) { +export function Settings() { + const navigate = useNavigate(); const [showEmojiPicker, setShowEmojiPicker] = useState(false); - const [config, updateConfig, resetConfig, clearAllData, clearSessions] = - useChatStore((state) => [ - state.config, - state.updateConfig, - state.resetConfig, - state.clearAllData, - state.clearSessions, - ]); + const config = useAppConfig(); + const updateConfig = config.update; + const resetConfig = config.reset; + const [clearAllData, clearSessions] = useChatStore((state) => [ + state.clearAllData, + state.clearSessions, + ]); const updateStore = useUpdateStore(); const [checkingUpdate, setCheckingUpdate] = useState(false); @@ -99,18 +202,16 @@ export function Settings(props: { closeSettings: () => void }) { }); } - const [usage, setUsage] = useState<{ - used?: number; - subscription?: number; - }>(); + const usage = { + used: updateStore.used, + subscription: updateStore.subscription, + }; const [loadingUsage, setLoadingUsage] = useState(false); function checkUsage() { setLoadingUsage(true); - requestUsage() - .then((res) => setUsage(res)) - .finally(() => { - setLoadingUsage(false); - }); + updateStore.updateUsage().finally(() => { + setLoadingUsage(false); + }); } const accessStore = useAccessStore(); @@ -122,10 +223,12 @@ export function Settings(props: { closeSettings: () => void }) { const promptStore = usePromptStore(); const builtinCount = SearchService.count.builtin; - const customCount = promptStore.prompts.size ?? 0; + const customCount = promptStore.getUserPrompts().length ?? 0; + const [shouldShowPromptModal, setShowPromptModal] = useState(false); const showUsage = accessStore.isAuthorized(); useEffect(() => { + // checks per minutes checkUpdate(); showUsage && checkUsage(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -134,7 +237,7 @@ export function Settings(props: { closeSettings: () => void }) { useEffect(() => { const keydownEvent = (e: KeyboardEvent) => { if (e.key === "Escape") { - props.closeSettings(); + navigate(Path.Home); } }; document.addEventListener("keydown", keydownEvent); @@ -189,7 +292,7 @@ export function Settings(props: { closeSettings: () => void }) {
} - onClick={props.closeSettings} + onClick={() => navigate(Path.Home)} bordered title={Locale.Settings.Actions.Close} /> @@ -469,7 +572,7 @@ export function Settings(props: { closeSettings: () => void }) { } text={Locale.Settings.Prompt.Edit} - onClick={() => showToast(Locale.WIP)} + onClick={() => setShowPromptModal(true)} /> @@ -542,7 +645,7 @@ export function Settings(props: { closeSettings: () => void }) { value={config.modelConfig.presence_penalty?.toFixed(1)} min="-2" max="2" - step="0.5" + step="0.1" onChange={(e) => { updateConfig( (config) => @@ -555,6 +658,10 @@ export function Settings(props: { closeSettings: () => void }) { > + + {shouldShowPromptModal && ( + setShowPromptModal(false)} /> + )}
); diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx new file mode 100644 index 000000000..d0c99dd19 --- /dev/null +++ b/app/components/sidebar.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef } from "react"; + +import styles from "./home.module.scss"; + +import { IconButton } from "./button"; +import SettingsIcon from "../icons/settings.svg"; +import GithubIcon from "../icons/github.svg"; +import ChatGptIcon from "../icons/chatgpt.svg"; +import AddIcon from "../icons/add.svg"; +import CloseIcon from "../icons/close.svg"; +import Locale from "../locales"; + +import { useAppConfig, useChatStore } from "../store"; + +import { + MAX_SIDEBAR_WIDTH, + MIN_SIDEBAR_WIDTH, + NARROW_SIDEBAR_WIDTH, + Path, + REPO_URL, +} from "../constant"; + +import { Link, useNavigate } from "react-router-dom"; +import { useMobileScreen } from "../utils"; +import dynamic from "next/dynamic"; + +const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, { + loading: () => null, +}); + +function useDragSideBar() { + const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x); + + const config = useAppConfig(); + const startX = useRef(0); + const startDragWidth = useRef(config.sidebarWidth ?? 300); + const lastUpdateTime = useRef(Date.now()); + + const handleMouseMove = useRef((e: MouseEvent) => { + if (Date.now() < lastUpdateTime.current + 50) { + return; + } + lastUpdateTime.current = Date.now(); + const d = e.clientX - startX.current; + const nextWidth = limit(startDragWidth.current + d); + config.update((config) => (config.sidebarWidth = nextWidth)); + }); + + const handleMouseUp = useRef(() => { + startDragWidth.current = config.sidebarWidth ?? 300; + window.removeEventListener("mousemove", handleMouseMove.current); + window.removeEventListener("mouseup", handleMouseUp.current); + }); + + const onDragMouseDown = (e: MouseEvent) => { + startX.current = e.clientX; + + window.addEventListener("mousemove", handleMouseMove.current); + window.addEventListener("mouseup", handleMouseUp.current); + }; + const isMobileScreen = useMobileScreen(); + const shouldNarrow = + !isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH; + + useEffect(() => { + const barWidth = shouldNarrow + ? NARROW_SIDEBAR_WIDTH + : limit(config.sidebarWidth ?? 300); + const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`; + document.documentElement.style.setProperty("--sidebar-width", sideBarWidth); + }, [config.sidebarWidth, isMobileScreen, shouldNarrow]); + + return { + onDragMouseDown, + shouldNarrow, + }; +} + +export function SideBar(props: { className?: string }) { + const chatStore = useChatStore(); + + // drag side bar + const { onDragMouseDown, shouldNarrow } = useDragSideBar(); + const navigate = useNavigate(); + + return ( +
+
+
ChatGPT Next
+
+ Build your own AI assistant. +
+
+ +
+
+ +
{ + if (e.target === e.currentTarget) { + navigate(Path.Home); + } + }} + > + +
+ +
+
+
+ } + onClick={chatStore.deleteSession} + /> +
+
+ + } shadow /> + +
+ +
+
+ } + text={shouldNarrow ? undefined : Locale.Home.NewChat} + onClick={() => { + chatStore.newSession(); + }} + shadow + /> +
+
+ +
onDragMouseDown(e as any)} + >
+
+ ); +} diff --git a/app/components/ui-lib.module.scss b/app/components/ui-lib.module.scss index 457c55049..8965c06a0 100644 --- a/app/components/ui-lib.module.scss +++ b/app/components/ui-lib.module.scss @@ -53,7 +53,7 @@ box-shadow: var(--card-shadow); background-color: var(--white); border-radius: 12px; - width: 50vw; + width: 60vw; animation: slide-in ease 0.3s; --modal-padding: 20px; diff --git a/app/components/ui-lib.tsx b/app/components/ui-lib.tsx index a72aa868f..ffc05cf8e 100644 --- a/app/components/ui-lib.tsx +++ b/app/components/ui-lib.tsx @@ -2,7 +2,7 @@ import styles from "./ui-lib.module.scss"; import LoadingIcon from "../icons/three-dots.svg"; import CloseIcon from "../icons/close.svg"; import { createRoot } from "react-dom/client"; -import React from "react"; +import React, { useEffect } from "react"; export function Popover(props: { children: JSX.Element; @@ -64,6 +64,21 @@ interface ModalProps { onClose?: () => void; } export function Modal(props: ModalProps) { + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + props.onClose?.(); + } + }; + + window.addEventListener("keydown", onKeyDown); + + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
diff --git a/app/constant.ts b/app/constant.ts index 6f08ad756..43ae4cc68 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -6,3 +6,13 @@ export const UPDATE_URL = `${REPO_URL}#keep-updated`; export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`; export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`; export const RUNTIME_CONFIG_DOM = "danger-runtime-config"; + +export enum Path { + Home = "/", + Chat = "/chat", + Settings = "/settings", +} + +export const MAX_SIDEBAR_WIDTH = 500; +export const MIN_SIDEBAR_WIDTH = 230; +export const NARROW_SIDEBAR_WIDTH = 100; diff --git a/app/icons/black-bot.svg b/app/icons/black-bot.svg new file mode 100644 index 000000000..3aad2adde --- /dev/null +++ b/app/icons/black-bot.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/icons/bottom.svg b/app/icons/bottom.svg index 06c663ab2..e2cfba2c7 100644 --- a/app/icons/bottom.svg +++ b/app/icons/bottom.svg @@ -1 +1 @@ - + diff --git a/app/icons/pause.svg b/app/icons/pause.svg new file mode 100644 index 000000000..382f7a939 --- /dev/null +++ b/app/icons/pause.svg @@ -0,0 +1 @@ + diff --git a/app/icons/user.svg b/app/icons/user.svg deleted file mode 100644 index 7f91de4dc..000000000 --- a/app/icons/user.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - 🤣 - - - - - \ No newline at end of file diff --git a/app/locales/cn.ts b/app/locales/cn.ts index e3021b95a..d7d72d0be 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -1,4 +1,4 @@ -import { SubmitKey } from "../store/app"; +import { SubmitKey } from "../store/config"; const cn = { WIP: "该功能仍在开发中……", @@ -17,6 +17,7 @@ const cn = { Copy: "复制", Stop: "停止", Retry: "重试", + Delete: "删除", }, Rename: "重命名对话", Typing: "正在输入…", @@ -37,12 +38,12 @@ const cn = { MessageFromChatGPT: "来自 ChatGPT 的消息", }, Memory: { - Title: "历史记忆", - EmptyContent: "尚未记忆", - Send: "发送记忆", - Copy: "复制记忆", + Title: "历史摘要", + EmptyContent: "尚未总结", + Send: "启用总结并发送摘要", + Copy: "复制摘要", Reset: "重置对话", - ResetConfirm: "重置后将清空当前对话记录以及历史记忆,确认重置?", + ResetConfirm: "重置后将清空当前对话记录以及历史摘要,确认重置?", }, Home: { NewChat: "新的聊天", @@ -58,10 +59,10 @@ const cn = { ResetAll: "重置所有选项", Close: "关闭", ConfirmResetAll: { - Confirm: "Are you sure you want to reset all configurations?", + Confirm: "确认清除所有配置?", }, ConfirmClearAll: { - Confirm: "Are you sure you want to reset all chat?", + Confirm: "确认清除所有聊天记录?", }, }, Lang: { @@ -103,6 +104,11 @@ const cn = { ListCount: (builtin: number, custom: number) => `内置 ${builtin} 条,用户定义 ${custom} 条`, Edit: "编辑", + Modal: { + Title: "提示词列表", + Add: "增加一条", + Search: "搜索提示词", + }, }, HistoryCount: { Title: "附带历史消息数", diff --git a/app/locales/de.ts b/app/locales/de.ts index 60698c626..42a4c8f68 100644 --- a/app/locales/de.ts +++ b/app/locales/de.ts @@ -1,4 +1,4 @@ -import { SubmitKey } from "../store/app"; +import { SubmitKey } from "../store/config"; import type { LocaleType } from "./index"; const de: LocaleType = { @@ -19,6 +19,7 @@ const de: LocaleType = { Copy: "Kopieren", Stop: "Stop", Retry: "Wiederholen", + Delete: "Delete", }, Rename: "Chat umbenennen", Typing: "Tippen...", @@ -106,6 +107,11 @@ const de: LocaleType = { ListCount: (builtin: number, custom: number) => `${builtin} integriert, ${custom} benutzerdefiniert`, Edit: "Bearbeiten", + Modal: { + Title: "Prompt List", + Add: "Add One", + Search: "Search Prompts", + }, }, HistoryCount: { Title: "Anzahl der angehängten Nachrichten", diff --git a/app/locales/en.ts b/app/locales/en.ts index 686d73eee..f7af4bfb3 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -1,4 +1,4 @@ -import { SubmitKey } from "../store/app"; +import { SubmitKey } from "../store/config"; import type { LocaleType } from "./index"; const en: LocaleType = { @@ -19,6 +19,7 @@ const en: LocaleType = { Copy: "Copy", Stop: "Stop", Retry: "Retry", + Delete: "Delete", }, Rename: "Rename Chat", Typing: "Typing…", @@ -106,6 +107,11 @@ const en: LocaleType = { ListCount: (builtin: number, custom: number) => `${builtin} built-in, ${custom} user-defined`, Edit: "Edit", + Modal: { + Title: "Prompt List", + Add: "Add One", + Search: "Search Prompts", + }, }, HistoryCount: { Title: "Attached Messages Count", @@ -127,7 +133,7 @@ const en: LocaleType = { return `Used this month $${used}, subscription $${total}`; }, IsChecking: "Checking...", - Check: "Check Again", + Check: "Check", NoAccess: "Enter API Key to check balance", }, AccessCode: { diff --git a/app/locales/es.ts b/app/locales/es.ts index a895367f0..efecf113b 100644 --- a/app/locales/es.ts +++ b/app/locales/es.ts @@ -1,4 +1,4 @@ -import { SubmitKey } from "../store/app"; +import { SubmitKey } from "../store/config"; import type { LocaleType } from "./index"; const es: LocaleType = { @@ -19,6 +19,7 @@ const es: LocaleType = { Copy: "Copiar", Stop: "Detener", Retry: "Reintentar", + Delete: "Delete", }, Rename: "Renombrar chat", Typing: "Escribiendo...", @@ -106,6 +107,11 @@ const es: LocaleType = { ListCount: (builtin: number, custom: number) => `${builtin} incorporado, ${custom} definido por el usuario`, Edit: "Editar", + Modal: { + Title: "Prompt List", + Add: "Add One", + Search: "Search Prompts", + }, }, HistoryCount: { Title: "Cantidad de mensajes adjuntos", diff --git a/app/locales/index.ts b/app/locales/index.ts index e4b303b41..389304f85 100644 --- a/app/locales/index.ts +++ b/app/locales/index.ts @@ -54,23 +54,13 @@ export function getLang(): Lang { const lang = getLanguage(); - if (lang.includes("zh") || lang.includes("cn")) { - return "cn"; - } else if (lang.includes("tw")) { - return "tw"; - } else if (lang.includes("es")) { - return "es"; - } else if (lang.includes("it")) { - return "it"; - } else if (lang.includes("tr")) { - return "tr"; - } else if (lang.includes("jp")) { - return "jp"; - } else if (lang.includes("de")) { - return "de"; - } else { - return "en"; + for (const option of AllLangs) { + if (lang.includes(option)) { + return option; + } } + + return "en"; } export function changeLang(lang: Lang) { @@ -87,4 +77,4 @@ export default { tr: TR, jp: JP, de: DE, -}[getLang()]; +}[getLang()] as typeof CN; diff --git a/app/locales/it.ts b/app/locales/it.ts index 928572214..b519ef453 100644 --- a/app/locales/it.ts +++ b/app/locales/it.ts @@ -1,4 +1,4 @@ -import { SubmitKey } from "../store/app"; +import { SubmitKey } from "../store/config"; import type { LocaleType } from "./index"; const it: LocaleType = { @@ -19,6 +19,7 @@ const it: LocaleType = { Copy: "Copia", Stop: "Stop", Retry: "Riprova", + Delete: "Delete", }, Rename: "Rinomina Chat", Typing: "Typing…", @@ -106,6 +107,11 @@ const it: LocaleType = { ListCount: (builtin: number, custom: number) => `${builtin} built-in, ${custom} user-defined`, Edit: "Modifica", + Modal: { + Title: "Prompt List", + Add: "Add One", + Search: "Search Prompts", + }, }, HistoryCount: { Title: "Conteggio dei messaggi allegati", diff --git a/app/locales/jp.ts b/app/locales/jp.ts index 09623a570..1c8d66d90 100644 --- a/app/locales/jp.ts +++ b/app/locales/jp.ts @@ -1,4 +1,4 @@ -import { SubmitKey } from "../store/app"; +import { SubmitKey } from "../store/config"; const jp = { WIP: "この機能は開発中です……", @@ -18,6 +18,7 @@ const jp = { Copy: "コピー", Stop: "停止", Retry: "リトライ", + Delete: "Delete", }, Rename: "チャットの名前を変更", Typing: "入力中…", @@ -107,6 +108,11 @@ const jp = { ListCount: (builtin: number, custom: number) => `組み込み ${builtin} 件、ユーザー定義 ${custom} 件`, Edit: "編集", + Modal: { + Title: "プロンプトリスト", + Add: "新規追加", + Search: "プロンプトワード検索", + }, }, HistoryCount: { Title: "履歴メッセージ数を添付", @@ -178,6 +184,4 @@ const jp = { }, }; -export type LocaleType = typeof jp; - export default jp; diff --git a/app/locales/tr.ts b/app/locales/tr.ts index a77771be1..86f1f417c 100644 --- a/app/locales/tr.ts +++ b/app/locales/tr.ts @@ -1,4 +1,4 @@ -import { SubmitKey } from "../store/app"; +import { SubmitKey } from "../store/config"; import type { LocaleType } from "./index"; const tr: LocaleType = { @@ -19,6 +19,7 @@ const tr: LocaleType = { Copy: "Kopyala", Stop: "Durdur", Retry: "Tekrar Dene", + Delete: "Delete", }, Rename: "Sohbeti Yeniden Adlandır", Typing: "Yazıyor…", @@ -106,6 +107,11 @@ const tr: LocaleType = { ListCount: (builtin: number, custom: number) => `${builtin} yerleşik, ${custom} kullanıcı tanımlı`, Edit: "Düzenle", + Modal: { + Title: "Prompt List", + Add: "Add One", + Search: "Search Prompts", + }, }, HistoryCount: { Title: "Ekli Mesaj Sayısı", diff --git a/app/locales/tw.ts b/app/locales/tw.ts index 318bdcd29..26791c77d 100644 --- a/app/locales/tw.ts +++ b/app/locales/tw.ts @@ -1,4 +1,4 @@ -import { SubmitKey } from "../store/app"; +import { SubmitKey } from "../store/config"; import type { LocaleType } from "./index"; const tw: LocaleType = { @@ -12,12 +12,13 @@ const tw: LocaleType = { Chat: { SubTitle: (count: number) => `您已經與 ChatGPT 進行了 ${count} 條對話`, Actions: { - ChatList: "查看消息列表", + ChatList: "查看訊息列表", CompressedHistory: "查看壓縮後的歷史 Prompt", Export: "匯出聊天紀錄", Copy: "複製", Stop: "停止", Retry: "重試", + Delete: "刪除", }, Rename: "重命名對話", Typing: "正在輸入…", @@ -31,10 +32,10 @@ const tw: LocaleType = { Send: "發送", }, Export: { - Title: "匯出聊天記錄為 Markdown", + Title: "將聊天記錄匯出為 Markdown", Copy: "複製全部", Download: "下載檔案", - MessageFromYou: "來自你的訊息", + MessageFromYou: "來自您的訊息", MessageFromChatGPT: "來自 ChatGPT 的訊息", }, Memory: { @@ -42,8 +43,8 @@ const tw: LocaleType = { EmptyContent: "尚未記憶", Copy: "複製全部", Send: "發送記憶", - Reset: "重置對話", - ResetConfirm: "重置後將清空當前對話記錄以及歷史記憶,確認重置?", + Reset: "重設對話", + ResetConfirm: "重設後將清除目前對話記錄以及歷史記憶,確認重設?", }, Home: { NewChat: "新的對話", @@ -55,18 +56,18 @@ const tw: LocaleType = { Title: "設定", SubTitle: "設定選項", Actions: { - ClearAll: "清除所有數據", - ResetAll: "重置所有設定", + ClearAll: "清除所有資料", + ResetAll: "重設所有設定", Close: "關閉", ConfirmResetAll: { - Confirm: "Are you sure you want to reset all configurations?", + Confirm: "您確定要重設所有設定嗎?", }, ConfirmClearAll: { - Confirm: "Are you sure you want to reset all chat?", + Confirm: "您確定要清除所有聊天嗎?", }, }, Lang: { - Name: "Language", + Name: "語言", Options: { cn: "简体中文", en: "English", @@ -97,13 +98,18 @@ const tw: LocaleType = { SendPreviewBubble: "發送預覽氣泡", Prompt: { Disable: { - Title: "停用提示詞自動補全", - SubTitle: "在輸入框開頭輸入 / 即可觸發自動補全", + Title: "停用提示詞自動補齊", + SubTitle: "在輸入框開頭輸入 / 即可觸發自動補齊", }, List: "自定義提示詞列表", ListCount: (builtin: number, custom: number) => - `內置 ${builtin} 條,用戶定義 ${custom} 條`, + `內建 ${builtin} 條,用戶定義 ${custom} 條`, Edit: "編輯", + Modal: { + Title: "提示詞列表", + Add: "新增一條", + Search: "搜尋提示詞", + }, }, HistoryCount: { Title: "附帶歷史訊息數", @@ -115,13 +121,13 @@ const tw: LocaleType = { }, Token: { Title: "API Key", - SubTitle: "使用自己的 Key 可規避授權訪問限制", + SubTitle: "使用自己的 Key 可規避授權存取限制", Placeholder: "OpenAI API Key", }, Usage: { Title: "帳戶餘額", SubTitle(used: any, total: any) { - return `本月已使用 $${used},订阅总额 $${total}`; + return `本月已使用 $${used},訂閱總額 $${total}`; }, IsChecking: "正在檢查…", Check: "重新檢查", @@ -129,17 +135,17 @@ const tw: LocaleType = { }, AccessCode: { Title: "授權碼", - SubTitle: "現在是未授權訪問狀態", + SubTitle: "目前是未授權存取狀態", Placeholder: "請輸入授權碼", }, Model: "模型 (model)", Temperature: { Title: "隨機性 (temperature)", - SubTitle: "值越大,回復越隨機", + SubTitle: "值越大,回應越隨機", }, MaxTokens: { - Title: "單次回復限制 (max_tokens)", - SubTitle: "單次交互所用的最大 Token 數", + Title: "單次回應限制 (max_tokens)", + SubTitle: "單次互動所用的最大 Token 數", }, PresencePenlty: { Title: "話題新穎度 (presence_penalty)", @@ -154,20 +160,20 @@ const tw: LocaleType = { History: (content: string) => "這是 AI 與用戶的歷史聊天總結,作為前情提要:" + content, Topic: - "Summarise the conversation in a short and concise eye-catching title that instantly conveys the main topic. Use as few words as possible. Use the language used in the enquiry, e.g. use English for English enquiry, use zh-hant for traditional chinese enquiry. Don't use quotation marks at the beginning and the end.", + "Use the language used by the user (e.g. en for english conversation, zh-hant for chinese conversation, etc.) to generate a title (at most 6 words) summarizing our conversation without any lead-in, quotation marks, preamble like 'Title:', direct text copies, single-word replies, quotation marks, translations, or brackets. Remove enclosing quotation marks. The title should make third-party grasp the essence of the conversation in first sight.", Summarize: - "Summarise the conversation in at most 250 tokens for continuing the conversation in future. Use the language used in the conversation, e.g. use English for English conversation, use zh-hant for traditional chinese conversation.", + "Use the language used by the user (e.g. en-us for english conversation, zh-hant for chinese conversation, etc.) to summarise the conversation in at most 200 words. The summary will be used as prompt for you to continue the conversation in the future.", }, - ConfirmClearAll: "確認清除所有對話、設定數據?", + ConfirmClearAll: "確認清除所有對話、設定?", }, Copy: { Success: "已複製到剪貼簿中", Failed: "複製失敗,請賦予剪貼簿權限", }, Context: { - Toast: (x: any) => `已設置 ${x} 條前置上下文`, + Toast: (x: any) => `已設定 ${x} 條前置上下文`, Edit: "前置上下文和歷史記憶", - Add: "新增壹條", + Add: "新增一條", }, }; diff --git a/app/requests.ts b/app/requests.ts index c48ef2817..0e7570904 100644 --- a/app/requests.ts +++ b/app/requests.ts @@ -1,14 +1,22 @@ import type { ChatRequest, ChatResponse } from "./api/openai/typing"; -import { Message, ModelConfig, useAccessStore, useChatStore } from "./store"; +import { + Message, + ModelConfig, + ModelType, + useAccessStore, + useAppConfig, + useChatStore, +} from "./store"; import { showToast } from "./components/ui-lib"; -const TIME_OUT_MS = 30000; +const TIME_OUT_MS = 60000; const makeRequestParam = ( messages: Message[], options?: { filterBot?: boolean; stream?: boolean; + model?: ModelType; }, ): ChatRequest => { let sendMessages = messages.map((v) => ({ @@ -20,12 +28,17 @@ const makeRequestParam = ( sendMessages = sendMessages.filter((m) => m.role !== "assistant"); } - const modelConfig = { ...useChatStore.getState().config.modelConfig }; + const modelConfig = { ...useAppConfig.getState().modelConfig }; // @yidadaa: wont send max_tokens, because it is nonsense for Muggles // @ts-expect-error delete modelConfig.max_tokens; + // override model config + if (options?.model) { + modelConfig.model = options.model; + } + return { messages: sendMessages, stream: options?.stream, @@ -50,7 +63,7 @@ function getHeaders() { export function requestOpenaiClient(path: string) { return (body: any, method = "POST") => - fetch("/api/openai?_vercel_no_cache=1", { + fetch("/api/openai", { method, headers: { "Content-Type": "application/json", @@ -61,8 +74,16 @@ export function requestOpenaiClient(path: string) { }); } -export async function requestChat(messages: Message[]) { - const req: ChatRequest = makeRequestParam(messages, { filterBot: true }); +export async function requestChat( + messages: Message[], + options?: { + model?: ModelType; + }, +) { + const req: ChatRequest = makeRequestParam(messages, { + filterBot: true, + model: options?.model, + }); const res = await requestOpenaiClient("v1/chat/completions")(req); @@ -114,6 +135,10 @@ export async function requestUsage() { response.total_usage = Math.round(response.total_usage) / 100; } + if (total.hard_limit_usd) { + total.hard_limit_usd = Math.round(total.hard_limit_usd * 100) / 100; + } + return { used: response.total_usage, subscription: total.hard_limit_usd, @@ -125,6 +150,7 @@ export async function requestChatStream( options?: { filterBot?: boolean; modelConfig?: ModelConfig; + model?: ModelType; onMessage: (message: string, done: boolean) => void; onError: (error: Error, statusCode?: number) => void; onController?: (controller: AbortController) => void; @@ -133,6 +159,7 @@ export async function requestChatStream( const req = makeRequestParam(messages, { stream: true, filterBot: options?.filterBot, + model: options?.model, }); console.log("[Request] ", req); @@ -167,15 +194,14 @@ export async function requestChatStream( options?.onController?.(controller); while (true) { - // handle time out, will stop if no response in 10 secs const resTimeoutId = setTimeout(() => finish(), TIME_OUT_MS); const content = await reader?.read(); clearTimeout(resTimeoutId); - + if (!content || !content.value) { break; } - + const text = decoder.decode(content.value, { stream: true }); responseText += text; @@ -201,7 +227,13 @@ export async function requestChatStream( } } -export async function requestWithPrompt(messages: Message[], prompt: string) { +export async function requestWithPrompt( + messages: Message[], + prompt: string, + options?: { + model?: ModelType; + }, +) { messages = messages.concat([ { role: "user", @@ -210,7 +242,7 @@ export async function requestWithPrompt(messages: Message[], prompt: string) { }, ]); - const res = await requestChat(messages); + const res = await requestChat(messages, options); return res?.choices?.at(0)?.message?.content ?? ""; } @@ -235,6 +267,14 @@ export const ControllerPool = { controller?.abort(); }, + stopAll() { + Object.values(this.controllers).forEach((v) => v.abort()); + }, + + hasPending() { + return Object.values(this.controllers).length > 0; + }, + remove(sessionIndex: number, messageId: number) { const key = this.key(sessionIndex, messageId); delete this.controllers[key]; diff --git a/app/store/app.ts b/app/store/app.ts index 813668905..2294130ad 100644 --- a/app/store/app.ts +++ b/app/store/app.ts @@ -11,12 +11,14 @@ import { isMobileScreen, trimTopic } from "../utils"; import Locale from "../locales"; import { showToast } from "../components/ui-lib"; +import { ModelType, useAppConfig } from "./config"; export type Message = ChatCompletionResponseMessage & { date: string; streaming?: boolean; isError?: boolean; id?: number; + model?: ModelType; }; export function createMessage(override: Partial): Message { @@ -29,131 +31,8 @@ export function createMessage(override: Partial): Message { }; } -export enum SubmitKey { - Enter = "Enter", - CtrlEnter = "Ctrl + Enter", - ShiftEnter = "Shift + Enter", - AltEnter = "Alt + Enter", - MetaEnter = "Meta + Enter", -} - -export enum Theme { - Auto = "auto", - Dark = "dark", - Light = "light", -} - -export interface ChatConfig { - historyMessageCount: number; // -1 means all - compressMessageLengthThreshold: number; - sendBotMessages: boolean; // send bot's message or not - submitKey: SubmitKey; - avatar: string; - fontSize: number; - theme: Theme; - tightBorder: boolean; - sendPreviewBubble: boolean; - sidebarWidth: number; - - disablePromptHint: boolean; - - modelConfig: { - model: string; - temperature: number; - max_tokens: number; - presence_penalty: number; - }; -} - -export type ModelConfig = ChatConfig["modelConfig"]; - export const ROLES: Message["role"][] = ["system", "user", "assistant"]; -const ENABLE_GPT4 = true; - -export const ALL_MODELS = [ - { - name: "gpt-4", - available: ENABLE_GPT4, - }, - { - name: "gpt-4-0314", - available: ENABLE_GPT4, - }, - { - name: "gpt-4-32k", - available: ENABLE_GPT4, - }, - { - name: "gpt-4-32k-0314", - available: ENABLE_GPT4, - }, - { - name: "gpt-3.5-turbo", - available: true, - }, - { - name: "gpt-3.5-turbo-0301", - available: true, - }, -]; - -export function limitNumber( - x: number, - min: number, - max: number, - defaultValue: number, -) { - if (typeof x !== "number" || isNaN(x)) { - return defaultValue; - } - - return Math.min(max, Math.max(min, x)); -} - -export function limitModel(name: string) { - return ALL_MODELS.some((m) => m.name === name && m.available) - ? name - : ALL_MODELS[4].name; -} - -export const ModalConfigValidator = { - model(x: string) { - return limitModel(x); - }, - max_tokens(x: number) { - return limitNumber(x, 0, 32000, 2000); - }, - presence_penalty(x: number) { - return limitNumber(x, -2, 2, 0); - }, - temperature(x: number) { - return limitNumber(x, 0, 2, 1); - }, -}; - -const DEFAULT_CONFIG: ChatConfig = { - historyMessageCount: 4, - compressMessageLengthThreshold: 1000, - sendBotMessages: true as boolean, - submitKey: SubmitKey.CtrlEnter as SubmitKey, - avatar: "1f603", - fontSize: 14, - theme: Theme.Auto as Theme, - tightBorder: false, - sendPreviewBubble: true, - sidebarWidth: 300, - - disablePromptHint: false, - - modelConfig: { - model: "gpt-3.5-turbo", - temperature: 1, - max_tokens: 2000, - presence_penalty: 0, - }, -}; - export interface ChatStat { tokenCount: number; wordCount: number; @@ -199,7 +78,6 @@ function createEmptySession(): ChatSession { } interface ChatStore { - config: ChatConfig; sessions: ChatSession[]; currentSessionIndex: number; clearSessions: () => void; @@ -223,9 +101,6 @@ interface ChatStore { getMessagesWithMemory: () => Message[]; getMemoryPrompt: () => Message; - getConfig: () => ChatConfig; - resetConfig: () => void; - updateConfig: (updater: (config: ChatConfig) => void) => void; clearAllData: () => void; } @@ -240,9 +115,6 @@ export const useChatStore = create()( (set, get) => ({ sessions: [createEmptySession()], currentSessionIndex: 0, - config: { - ...DEFAULT_CONFIG, - }, clearSessions() { set(() => ({ @@ -251,20 +123,6 @@ export const useChatStore = create()( })); }, - resetConfig() { - set(() => ({ config: { ...DEFAULT_CONFIG } })); - }, - - getConfig() { - return get().config; - }, - - updateConfig(updater) { - const config = get().config; - updater(config); - set(() => ({ config })); - }, - selectSession(index: number) { set({ currentSessionIndex: index, @@ -386,6 +244,8 @@ export const useChatStore = create()( const botMessage: Message = createMessage({ role: "assistant", streaming: true, + id: userMessage.id! + 1, + model: useAppConfig.getState().modelConfig.model, }); // get recent messages @@ -421,7 +281,7 @@ export const useChatStore = create()( onError(error, statusCode) { if (statusCode === 401) { botMessage.content = Locale.Error.Unauthorized; - } else { + } else if (!error.message.includes("aborted")) { botMessage.content += "\n\n" + Locale.Store.Error; } botMessage.streaming = false; @@ -438,8 +298,8 @@ export const useChatStore = create()( controller, ); }, - filterBot: !get().config.sendBotMessages, - modelConfig: get().config.modelConfig, + filterBot: !useAppConfig.getState().sendBotMessages, + modelConfig: useAppConfig.getState().modelConfig, }); }, @@ -455,12 +315,13 @@ export const useChatStore = create()( getMessagesWithMemory() { const session = get().currentSession(); - const config = get().config; + const config = useAppConfig.getState(); const messages = session.messages.filter((msg) => !msg.isError); const n = messages.length; const context = session.context.slice(); + // long term memory if ( session.sendMemory && session.memoryPrompt && @@ -470,9 +331,33 @@ export const useChatStore = create()( context.push(memoryPrompt); } - const recentMessages = context.concat( - messages.slice(Math.max(0, n - config.historyMessageCount)), + // get short term and unmemoried long term memory + const shortTermMemoryMessageIndex = Math.max( + 0, + n - config.historyMessageCount, ); + const longTermMemoryMessageIndex = session.lastSummarizeIndex; + const oldestIndex = Math.max( + shortTermMemoryMessageIndex, + longTermMemoryMessageIndex, + ); + const threshold = config.compressMessageLengthThreshold; + + // get recent messages as many as possible + const reversedRecentMessages = []; + for ( + let i = n - 1, count = 0; + i >= oldestIndex && count < threshold; + i -= 1 + ) { + const msg = messages[i]; + if (!msg || msg.isError) continue; + count += msg.content.length; + reversedRecentMessages.push(msg); + } + + // concat + const recentMessages = context.concat(reversedRecentMessages.reverse()); return recentMessages; }, @@ -505,24 +390,24 @@ export const useChatStore = create()( session.topic === DEFAULT_TOPIC && countMessages(session.messages) >= SUMMARIZE_MIN_LEN ) { - requestWithPrompt(session.messages, Locale.Store.Prompt.Topic).then( - (res) => { - get().updateCurrentSession( - (session) => - (session.topic = res ? trimTopic(res) : DEFAULT_TOPIC), - ); - }, - ); + requestWithPrompt(session.messages, Locale.Store.Prompt.Topic, { + model: "gpt-3.5-turbo", + }).then((res) => { + get().updateCurrentSession( + (session) => + (session.topic = res ? trimTopic(res) : DEFAULT_TOPIC), + ); + }); } - const config = get().config; + const config = useAppConfig.getState(); let toBeSummarizedMsgs = session.messages.slice( session.lastSummarizeIndex, ); const historyMsgLength = countMessages(toBeSummarizedMsgs); - if (historyMsgLength > get().config?.modelConfig?.max_tokens ?? 4000) { + if (historyMsgLength > config?.modelConfig?.max_tokens ?? 4000) { const n = toBeSummarizedMsgs.length; toBeSummarizedMsgs = toBeSummarizedMsgs.slice( Math.max(0, n - config.historyMessageCount), @@ -541,7 +426,10 @@ export const useChatStore = create()( config.compressMessageLengthThreshold, ); - if (historyMsgLength > config.compressMessageLengthThreshold) { + if ( + historyMsgLength > config.compressMessageLengthThreshold && + session.sendMemory + ) { requestChatStream( toBeSummarizedMsgs.concat({ role: "system", @@ -550,6 +438,7 @@ export const useChatStore = create()( }), { filterBot: false, + model: "gpt-3.5-turbo", onMessage(message, done) { session.memoryPrompt = message; if (done) { diff --git a/app/store/config.ts b/app/store/config.ts new file mode 100644 index 000000000..346f38da2 --- /dev/null +++ b/app/store/config.ts @@ -0,0 +1,135 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export enum SubmitKey { + Enter = "Enter", + CtrlEnter = "Ctrl + Enter", + ShiftEnter = "Shift + Enter", + AltEnter = "Alt + Enter", + MetaEnter = "Meta + Enter", +} + +export enum Theme { + Auto = "auto", + Dark = "dark", + Light = "light", +} + +const DEFAULT_CONFIG = { + historyMessageCount: 4, + compressMessageLengthThreshold: 1000, + sendBotMessages: true as boolean, + submitKey: SubmitKey.CtrlEnter as SubmitKey, + avatar: "1f603", + fontSize: 14, + theme: Theme.Auto as Theme, + tightBorder: false, + sendPreviewBubble: true, + sidebarWidth: 300, + + disablePromptHint: false, + + modelConfig: { + model: "gpt-3.5-turbo" as ModelType, + temperature: 1, + max_tokens: 2000, + presence_penalty: 0, + }, +}; + +export type ChatConfig = typeof DEFAULT_CONFIG; + +export type ChatConfigStore = ChatConfig & { + reset: () => void; + update: (updater: (config: ChatConfig) => void) => void; +}; + +export type ModelConfig = ChatConfig["modelConfig"]; + +const ENABLE_GPT4 = true; + +export const ALL_MODELS = [ + { + name: "gpt-4", + available: ENABLE_GPT4, + }, + { + name: "gpt-4-0314", + available: ENABLE_GPT4, + }, + { + name: "gpt-4-32k", + available: ENABLE_GPT4, + }, + { + name: "gpt-4-32k-0314", + available: ENABLE_GPT4, + }, + { + name: "gpt-3.5-turbo", + available: true, + }, + { + name: "gpt-3.5-turbo-0301", + available: true, + }, +] as const; + +export type ModelType = (typeof ALL_MODELS)[number]["name"]; + +export function limitNumber( + x: number, + min: number, + max: number, + defaultValue: number, +) { + if (typeof x !== "number" || isNaN(x)) { + return defaultValue; + } + + return Math.min(max, Math.max(min, x)); +} + +export function limitModel(name: string) { + return ALL_MODELS.some((m) => m.name === name && m.available) + ? name + : ALL_MODELS[4].name; +} + +export const ModalConfigValidator = { + model(x: string) { + return limitModel(x) as ModelType; + }, + max_tokens(x: number) { + return limitNumber(x, 0, 32000, 2000); + }, + presence_penalty(x: number) { + return limitNumber(x, -2, 2, 0); + }, + temperature(x: number) { + return limitNumber(x, 0, 2, 1); + }, +}; + +const CONFIG_KEY = "app-config"; + +export const useAppConfig = create()( + persist( + (set, get) => ({ + ...DEFAULT_CONFIG, + + reset() { + set(() => ({ ...DEFAULT_CONFIG })); + }, + + update(updater) { + const config = { ...get() }; + updater(config); + set(() => config); + }, + }), + { + name: CONFIG_KEY, + }, + ), +); diff --git a/app/store/index.ts b/app/store/index.ts index 3bdb58ca2..7b7bbd04d 100644 --- a/app/store/index.ts +++ b/app/store/index.ts @@ -1,3 +1,4 @@ export * from "./app"; export * from "./update"; export * from "./access"; +export * from "./config"; diff --git a/app/store/prompt.ts b/app/store/prompt.ts index d0dd454ac..8d754ff5d 100644 --- a/app/store/prompt.ts +++ b/app/store/prompt.ts @@ -5,62 +5,74 @@ import { getLang } from "../locales"; export interface Prompt { id?: number; + isUser?: boolean; title: string; content: string; } export interface PromptStore { + counter: number; latestId: number; - prompts: Map; + prompts: Record; add: (prompt: Prompt) => number; remove: (id: number) => void; search: (text: string) => Prompt[]; + + getUserPrompts: () => Prompt[]; + updateUserPrompts: (id: number, updater: (prompt: Prompt) => void) => void; } export const PROMPT_KEY = "prompt-store"; export const SearchService = { ready: false, - engine: new Fuse([], { keys: ["title"] }), + builtinEngine: new Fuse([], { keys: ["title"] }), + userEngine: new Fuse([], { keys: ["title"] }), count: { builtin: 0, }, - allBuiltInPrompts: [] as Prompt[], + allPrompts: [] as Prompt[], + builtinPrompts: [] as Prompt[], - init(prompts: Prompt[]) { + init(builtinPrompts: Prompt[], userPrompts: Prompt[]) { if (this.ready) { return; } - this.allBuiltInPrompts = prompts; - this.engine.setCollection(prompts); + this.allPrompts = userPrompts.concat(builtinPrompts); + this.builtinPrompts = builtinPrompts.slice(); + this.builtinEngine.setCollection(builtinPrompts); + this.userEngine.setCollection(userPrompts); this.ready = true; }, remove(id: number) { - this.engine.remove((doc) => doc.id === id); + this.userEngine.remove((doc) => doc.id === id); }, add(prompt: Prompt) { - this.engine.add(prompt); + this.userEngine.add(prompt); }, search(text: string) { - const results = this.engine.search(text); - return results.map((v) => v.item); + const userResults = this.userEngine.search(text); + const builtinResults = this.builtinEngine.search(text); + return userResults.concat(builtinResults).map((v) => v.item); }, }; export const usePromptStore = create()( persist( (set, get) => ({ + counter: 0, latestId: 0, - prompts: new Map(), + prompts: {}, add(prompt) { const prompts = get().prompts; prompt.id = get().latestId + 1; - prompts.set(prompt.id, prompt); + prompt.isUser = true; + prompts[prompt.id] = prompt; set(() => ({ latestId: prompt.id!, @@ -72,19 +84,40 @@ export const usePromptStore = create()( remove(id) { const prompts = get().prompts; - prompts.delete(id); + delete prompts[id]; SearchService.remove(id); set(() => ({ prompts, + counter: get().counter + 1, })); }, + getUserPrompts() { + const userPrompts = Object.values(get().prompts ?? {}); + userPrompts.sort((a, b) => (b.id && a.id ? b.id - a.id : 0)); + return userPrompts; + }, + + updateUserPrompts(id: number, updater) { + const prompt = get().prompts[id] ?? { + title: "", + content: "", + id, + }; + + SearchService.remove(id); + updater(prompt); + const prompts = get().prompts; + prompts[id] = prompt; + set(() => ({ prompts })); + SearchService.add(prompt); + }, + search(text) { if (text.length === 0) { - // return all prompts - const userPrompts = get().prompts?.values?.() ?? []; - return SearchService.allBuiltInPrompts.concat([...userPrompts]); + // return all rompts + return SearchService.allPrompts.concat([...get().getUserPrompts()]); } return SearchService.search(text) as Prompt[]; }, @@ -104,24 +137,27 @@ export const usePromptStore = create()( if (getLang() === "cn") { fetchPrompts = fetchPrompts.reverse(); } - const builtinPrompts = fetchPrompts - .map((promptList: PromptList) => { + const builtinPrompts = fetchPrompts.map( + (promptList: PromptList) => { return promptList.map( ([title, content]) => ({ + id: Math.random(), title, content, } as Prompt), ); - }) - .concat([...(state?.prompts?.values() ?? [])]); - - const allPromptsForSearch = builtinPrompts.reduce( - (pre, cur) => pre.concat(cur), - [], + }, ); + + const userPrompts = + usePromptStore.getState().getUserPrompts() ?? []; + + const allPromptsForSearch = builtinPrompts + .reduce((pre, cur) => pre.concat(cur), []) + .filter((v) => !!v.title && !!v.content); SearchService.count.builtin = res.en.length + res.cn.length; - SearchService.init(allPromptsForSearch); + SearchService.init(allPromptsForSearch, userPrompts); }); }, }, diff --git a/app/store/update.ts b/app/store/update.ts index efcdc8a7b..47b190b88 100644 --- a/app/store/update.ts +++ b/app/store/update.ts @@ -1,13 +1,19 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { FETCH_COMMIT_URL, FETCH_TAG_URL } from "../constant"; +import { requestUsage } from "../requests"; export interface UpdateStore { lastUpdate: number; remoteVersion: string; + used?: number; + subscription?: number; + lastUpdateUsage: number; + version: string; - getLatestVersion: (force: boolean) => Promise; + getLatestVersion: (force?: boolean) => Promise; + updateUsage: (force?: boolean) => Promise; } export const UPDATE_KEY = "chat-update"; @@ -26,22 +32,27 @@ function queryMeta(key: string, defaultValue?: string): string { return ret; } +const ONE_MINUTE = 60 * 1000; + export const useUpdateStore = create()( persist( (set, get) => ({ lastUpdate: 0, remoteVersion: "", + lastUpdateUsage: 0, + version: "unknown", async getLatestVersion(force = false) { - set(() => ({ version: queryMeta("version") })); + set(() => ({ version: queryMeta("version") ?? "unknown" })); - const overTenMins = Date.now() - get().lastUpdate > 10 * 60 * 1000; - const shouldFetch = force || overTenMins; - if (!shouldFetch) { - return get().version ?? "unknown"; - } + const overTenMins = Date.now() - get().lastUpdate > 10 * ONE_MINUTE; + if (!force && !overTenMins) return; + + set(() => ({ + lastUpdate: Date.now(), + })); try { // const data = await (await fetch(FETCH_TAG_URL)).json(); @@ -49,14 +60,26 @@ export const useUpdateStore = create()( const data = await (await fetch(FETCH_COMMIT_URL)).json(); const remoteId = (data[0].sha as string).substring(0, 7); set(() => ({ - lastUpdate: Date.now(), remoteVersion: remoteId, })); console.log("[Got Upstream] ", remoteId); - return remoteId; } catch (error) { console.error("[Fetch Upstream Commit Id]", error); - return get().version ?? ""; + } + }, + + async updateUsage(force = false) { + const overOneMinute = Date.now() - get().lastUpdateUsage >= ONE_MINUTE; + if (!overOneMinute && !force) return; + + set(() => ({ + lastUpdateUsage: Date.now(), + })); + + const usage = await requestUsage(); + + if (usage) { + set(() => usage); } }, }), diff --git a/app/styles/globals.scss b/app/styles/globals.scss index cf36ee92b..37c662288 100644 --- a/app/styles/globals.scss +++ b/app/styles/globals.scss @@ -140,6 +140,7 @@ label { input { text-align: center; + font-family: inherit; } input[type="checkbox"] { @@ -224,6 +225,7 @@ input[type="password"] { color: var(--black); padding: 0 10px; max-width: 50%; + font-family: inherit; } div.math { diff --git a/app/utils.ts b/app/utils.ts index 0e4a8eaea..dfec8d3e9 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -1,4 +1,5 @@ import { EmojiStyle } from "emoji-picker-react"; +import { useEffect, useState } from "react"; import { showToast } from "./components/ui-lib"; import Locale from "./locales"; @@ -47,7 +48,27 @@ export function isIOS() { return /iphone|ipad|ipod/.test(userAgent); } +export function useMobileScreen() { + const [isMobileScreen_, setIsMobileScreen] = useState(isMobileScreen()); + useEffect(() => { + const onResize = () => { + setIsMobileScreen(isMobileScreen()); + }; + + window.addEventListener("resize", onResize); + + return () => { + window.removeEventListener("resize", onResize); + }; + }, []); + + return isMobileScreen_; +} + export function isMobileScreen() { + if (typeof window === "undefined") { + return false; + } return window.innerWidth <= 600; } diff --git a/middleware.ts b/middleware.ts index c2e077706..d16a812d9 100644 --- a/middleware.ts +++ b/middleware.ts @@ -8,6 +8,17 @@ export const config = { const serverConfig = getServerSideConfig(); +function getIP(req: NextRequest) { + let ip = req.ip ?? req.headers.get("x-real-ip"); + const forwardedFor = req.headers.get("x-forwarded-for"); + + if (!ip && forwardedFor) { + ip = forwardedFor.split(",").at(0) ?? ""; + } + + return ip; +} + export function middleware(req: NextRequest) { const accessCode = req.headers.get("access-code"); const token = req.headers.get("token"); @@ -16,6 +27,8 @@ export function middleware(req: NextRequest) { console.log("[Auth] allowed hashed codes: ", [...serverConfig.codes]); console.log("[Auth] got access code:", accessCode); console.log("[Auth] hashed access code:", hashedCode); + console.log("[User IP] ", getIP(req)); + console.log("[Time] ", new Date().toLocaleString()); if (serverConfig.needCode && !serverConfig.codes.has(hashedCode) && !token) { return NextResponse.json( diff --git a/package.json b/package.json index 9fcb74e77..785005519 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "start": "next start", "lint": "next lint", "fetch": "node ./scripts/fetch-prompts.mjs", - "prepare": "husky install" + "prepare": "husky install", + "proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev" }, "dependencies": { "@hello-pangea/dnd": "^16.2.0", @@ -18,12 +19,13 @@ "emoji-picker-react": "^4.4.7", "eventsource-parser": "^0.1.0", "fuse.js": "^6.6.2", - "next": "^13.2.3", + "next": "^13.3.1-canary.8", "node-fetch": "^3.3.1", "openai": "^3.2.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^8.0.5", + "react-router-dom": "^6.10.0", "rehype-highlight": "^6.0.0", "rehype-katex": "^6.0.2", "remark-breaks": "^3.0.2", diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 000000000..80fe56c37 --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1 @@ +proxychains.conf diff --git a/scripts/fetch-prompts.mjs b/scripts/fetch-prompts.mjs index 7f6818d3b..02b52a31d 100644 --- a/scripts/fetch-prompts.mjs +++ b/scripts/fetch-prompts.mjs @@ -10,10 +10,20 @@ const RAW_EN_URL = "f/awesome-chatgpt-prompts/main/prompts.csv"; const EN_URL = MIRRORF_FILE_URL + RAW_EN_URL; const FILE = "./public/prompts.json"; +const timeoutPromise = (timeout) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('Request timeout')); + }, timeout); + }); +}; + async function fetchCN() { console.log("[Fetch] fetching cn prompts..."); try { - const raw = await (await fetch(CN_URL)).json(); + // const raw = await (await fetch(CN_URL)).json(); + const response = await Promise.race([fetch(CN_URL), timeoutPromise(5000)]); + const raw = await response.json(); return raw.map((v) => [v.act, v.prompt]); } catch (error) { console.error("[Fetch] failed to fetch cn prompts", error); @@ -24,13 +34,15 @@ async function fetchCN() { async function fetchEN() { console.log("[Fetch] fetching en prompts..."); try { - const raw = await (await fetch(EN_URL)).text(); + // const raw = await (await fetch(EN_URL)).text(); + const response = await Promise.race([fetch(EN_URL), timeoutPromise(5000)]); + const raw = await response.text(); return raw .split("\n") .slice(1) .map((v) => v.split('","').map((v) => v.replace('"', ""))); } catch (error) { - console.error("[Fetch] failed to fetch cn prompts", error); + console.error("[Fetch] failed to fetch en prompts", error); return []; } } diff --git a/scripts/init-proxy.sh b/scripts/init-proxy.sh new file mode 100644 index 000000000..acba064f4 --- /dev/null +++ b/scripts/init-proxy.sh @@ -0,0 +1,5 @@ +dir="$(dirname "$0")" +config=$dir/proxychains.conf +host_ip=$(grep nameserver /etc/resolv.conf | sed 's/nameserver //') +cp $dir/proxychains.template.conf $config +sed -i "\$s/.*/http $host_ip 7890/" $config diff --git a/scripts/proxychains.template.conf b/scripts/proxychains.template.conf new file mode 100644 index 000000000..e78b96a68 --- /dev/null +++ b/scripts/proxychains.template.conf @@ -0,0 +1,12 @@ +strict_chain +proxy_dns + +remote_dns_subnet 224 + +tcp_read_time_out 15000 +tcp_connect_time_out 8000 + +localnet 127.0.0.0/255.0.0.0 + +[ProxyList] +socks4 127.0.0.1 9050 diff --git a/scripts/setup.sh b/scripts/setup.sh index b96533398..751a9ac17 100644 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -29,13 +29,13 @@ esac if ! command -v node >/dev/null || ! command -v git >/dev/null || ! command -v yarn >/dev/null; then case "$(uname -s)" in Linux) - if [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=\"ubuntu\"" ]]; then + if [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=ubuntu" ]]; then sudo apt-get update sudo apt-get -y install nodejs git yarn - elif [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=\"centos\"" ]]; then + elif [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=centos" ]]; then sudo yum -y install epel-release sudo yum -y install nodejs git yarn - elif [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=\"arch\"" ]]; then + elif [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=arch" ]]; then sudo pacman -Syu -y sudo pacman -S -y nodejs git yarn else diff --git a/yarn.lock b/yarn.lock index 88aa59823..b7d9f8309 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1099,10 +1099,10 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" -"@next/env@13.2.4": - version "13.2.4" - resolved "https://registry.yarnpkg.com/@next/env/-/env-13.2.4.tgz#8b763700262b2445140a44a8c8d088cef676dbae" - integrity sha512-+Mq3TtpkeeKFZanPturjcXt+KHfKYnLlX6jMLyCrmpq6OOs4i1GqBOAauSkii9QeKCMTYzGppar21JU57b/GEA== +"@next/env@13.3.1-canary.8": + version "13.3.1-canary.8" + resolved "https://registry.yarnpkg.com/@next/env/-/env-13.3.1-canary.8.tgz#9f5cf57999e4f4b59ef6407924803a247cc4e451" + integrity sha512-xZfNu7yq3OfiC4rkGuGMcqb25se+ZHRqajSdny8dp+nZzkNSK1SHuNT3W8faI+KGk6dqzO/zAdHR9YrqnQlCAg== "@next/eslint-plugin-next@13.2.3": version "13.2.3" @@ -1111,70 +1111,50 @@ dependencies: glob "7.1.7" -"@next/swc-android-arm-eabi@13.2.4": - version "13.2.4" - resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-13.2.4.tgz#758d0403771e549f9cee71cbabc0cb16a6c947c0" - integrity sha512-DWlalTSkLjDU11MY11jg17O1gGQzpRccM9Oes2yTqj2DpHndajrXHGxj9HGtJ+idq2k7ImUdJVWS2h2l/EDJOw== +"@next/swc-darwin-arm64@13.3.1-canary.8": + version "13.3.1-canary.8" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.3.1-canary.8.tgz#66786ba76d37c210c184739624c6f84eaf2dc52b" + integrity sha512-BLbvhcaSzwuXbREOmJiqAdXVD7Jl9830hDY5ZTTNg7hXqEZgoMg2LxAEmtaaBMVZRfDQjd5bH3QPBV8fbG4UKg== -"@next/swc-android-arm64@13.2.4": - version "13.2.4" - resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-13.2.4.tgz#834d586523045110d5602e0c8aae9028835ac427" - integrity sha512-sRavmUImUCf332Gy+PjIfLkMhiRX1Ez4SI+3vFDRs1N5eXp+uNzjFUK/oLMMOzk6KFSkbiK/3Wt8+dHQR/flNg== +"@next/swc-darwin-x64@13.3.1-canary.8": + version "13.3.1-canary.8" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-13.3.1-canary.8.tgz#289296bd3cc55db7fef42037eb89ce4a6260ba31" + integrity sha512-n4tJKPIvFTZshS1TVWrsqaW7h9VW+BmguO/AlZ3Q3NJ9hWxC5L4lxn2T6CTQ4M30Gf+t5u+dPzYLQ5IDtJFnFQ== -"@next/swc-darwin-arm64@13.2.4": - version "13.2.4" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.2.4.tgz#5006fca179a36ef3a24d293abadec7438dbb48c6" - integrity sha512-S6vBl+OrInP47TM3LlYx65betocKUUlTZDDKzTiRDbsRESeyIkBtZ6Qi5uT2zQs4imqllJznVjFd1bXLx3Aa6A== +"@next/swc-linux-arm64-gnu@13.3.1-canary.8": + version "13.3.1-canary.8" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.3.1-canary.8.tgz#dc79e8005849b6482241b460abdce9334665c766" + integrity sha512-AxnsgZ56whwVAeejyEZMk8xc8Vapwzb3Zn0YdZzPCR42WKfkcSkM+AWfq33zUOZnjvCmQBDyfHIo4CURVweR6g== -"@next/swc-darwin-x64@13.2.4": - version "13.2.4" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-13.2.4.tgz#6549c7c04322766acc3264ccdb3e1b43fcaf7946" - integrity sha512-a6LBuoYGcFOPGd4o8TPo7wmv5FnMr+Prz+vYHopEDuhDoMSHOnC+v+Ab4D7F0NMZkvQjEJQdJS3rqgFhlZmKlw== +"@next/swc-linux-arm64-musl@13.3.1-canary.8": + version "13.3.1-canary.8" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.3.1-canary.8.tgz#f70873add4aad7ced36f760d1640adc008b7dc03" + integrity sha512-zc7rzhtrHMWZ/phvjCNplHGo+ZLembjtluI5J8Xl4iwQQCyZwAtnmQhs37/zkdi6dHZou+wcFBZWRz14awRDBw== -"@next/swc-freebsd-x64@13.2.4": - version "13.2.4" - resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-13.2.4.tgz#0bbe28979e3e868debc2cc06e45e186ce195b7f4" - integrity sha512-kkbzKVZGPaXRBPisoAQkh3xh22r+TD+5HwoC5bOkALraJ0dsOQgSMAvzMXKsN3tMzJUPS0tjtRf1cTzrQ0I5vQ== +"@next/swc-linux-x64-gnu@13.3.1-canary.8": + version "13.3.1-canary.8" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.3.1-canary.8.tgz#fe81b8033628c6cf74e154f2db8c8c7f1593008f" + integrity sha512-vNbFDiuZ9fWmcznlilDbflZLb04evWPUQlyDT7Tqjd964PlSIaaX3tr64pdYjJOljDaqTr2Kbx0YW74mWF/PEw== -"@next/swc-linux-arm-gnueabihf@13.2.4": - version "13.2.4" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-13.2.4.tgz#1d28d2203f5a7427d6e7119d7bcb5fc40959fb3e" - integrity sha512-7qA1++UY0fjprqtjBZaOA6cas/7GekpjVsZn/0uHvquuITFCdKGFCsKNBx3S0Rpxmx6WYo0GcmhNRM9ru08BGg== +"@next/swc-linux-x64-musl@13.3.1-canary.8": + version "13.3.1-canary.8" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.3.1-canary.8.tgz#ada4585046a7937f96f2d39fc4aaca12826dde5f" + integrity sha512-/FVBPJEBDZYCNraocRWtd5ObAgNi9VFnzJYGYDYIj4jKkFRWWm/CaWu9A7toQACC/JDy262uPyDPathXT9BAqQ== -"@next/swc-linux-arm64-gnu@13.2.4": - version "13.2.4" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.2.4.tgz#eb26448190948cdf4c44b8f34110a3ecea32f1d0" - integrity sha512-xzYZdAeq883MwXgcwc72hqo/F/dwUxCukpDOkx/j1HTq/J0wJthMGjinN9wH5bPR98Mfeh1MZJ91WWPnZOedOg== +"@next/swc-win32-arm64-msvc@13.3.1-canary.8": + version "13.3.1-canary.8" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.3.1-canary.8.tgz#21b4f6c4be61845759753df9313bd9bcbb241969" + integrity sha512-8jMwRCeI26yVZLPwG0AjOi4b1yqSeqYmbHA7r+dqiV0OgFdYjnbyHU1FmiKDaC5SnnJN6LWV2Qjer9GDD0Kcuw== -"@next/swc-linux-arm64-musl@13.2.4": - version "13.2.4" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.2.4.tgz#c4227c0acd94a420bb14924820710e6284d234d3" - integrity sha512-8rXr3WfmqSiYkb71qzuDP6I6R2T2tpkmf83elDN8z783N9nvTJf2E7eLx86wu2OJCi4T05nuxCsh4IOU3LQ5xw== +"@next/swc-win32-ia32-msvc@13.3.1-canary.8": + version "13.3.1-canary.8" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.3.1-canary.8.tgz#e23192e1d1b1a32b0eb805363b02360c5b523a77" + integrity sha512-kcYB9iSEikFhv0I9uQDdgQ2lm8i3O8LA+GhnED9e5VtURBwOSwED7c6ZpaRQBYSPgnEA9/xiJVChICE/I7Ig1g== -"@next/swc-linux-x64-gnu@13.2.4": - version "13.2.4" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.2.4.tgz#6bcb540944ee9b0209b33bfc23b240c2044dfc3e" - integrity sha512-Ngxh51zGSlYJ4EfpKG4LI6WfquulNdtmHg1yuOYlaAr33KyPJp4HeN/tivBnAHcZkoNy0hh/SbwDyCnz5PFJQQ== - -"@next/swc-linux-x64-musl@13.2.4": - version "13.2.4" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.2.4.tgz#ce21e43251eaf09a09df39372b2c3e38028c30ff" - integrity sha512-gOvwIYoSxd+j14LOcvJr+ekd9fwYT1RyMAHOp7znA10+l40wkFiMONPLWiZuHxfRk+Dy7YdNdDh3ImumvL6VwA== - -"@next/swc-win32-arm64-msvc@13.2.4": - version "13.2.4" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.2.4.tgz#68220063d8e5e082f5465498675640dedb670ff1" - integrity sha512-q3NJzcfClgBm4HvdcnoEncmztxrA5GXqKeiZ/hADvC56pwNALt3ngDC6t6qr1YW9V/EPDxCYeaX4zYxHciW4Dw== - -"@next/swc-win32-ia32-msvc@13.2.4": - version "13.2.4" - resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.2.4.tgz#7c120ab54a081be9566df310bed834f168252990" - integrity sha512-/eZ5ncmHUYtD2fc6EUmAIZlAJnVT2YmxDsKs1Ourx0ttTtvtma/WKlMV5NoUsyOez0f9ExLyOpeCoz5aj+MPXw== - -"@next/swc-win32-x64-msvc@13.2.4": - version "13.2.4" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.2.4.tgz#5abda92fe12b9829bf7951c4a221282c56041144" - integrity sha512-0MffFmyv7tBLlji01qc0IaPP/LVExzvj7/R5x1Jph1bTAIj4Vu81yFQWHHQAP6r4ff9Ukj1mBK6MDNVXm7Tcvw== +"@next/swc-win32-x64-msvc@13.3.1-canary.8": + version "13.3.1-canary.8" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.3.1-canary.8.tgz#a3f29404955cba2193de5e74fd5d9fcfdcb0ab51" + integrity sha512-UKrGHonKVWBNg+HI4J8pXE6Jjjl8GwjhygFau71s8M0+jSy99y5Y+nGH9EmMNWKNvrObukyYvrs6OsAusKdCqw== "@nodelib/fs.scandir@2.1.5": version "2.1.5" @@ -1209,6 +1189,11 @@ tiny-glob "^0.2.9" tslib "^2.4.0" +"@remix-run/router@1.5.0": + version "1.5.0" + resolved "https://registry.npmmirror.com/@remix-run/router/-/router-1.5.0.tgz#57618e57942a5f0131374a9fdb0167e25a117fdc" + integrity sha512-bkUDCp8o1MvFO+qxkODcbhSqRa6P2GXgrGZVpt0dCXNW2HCSCqYI0ZoAqEOSAjRWmmlKcYgFvN4B4S+zo/f8kg== + "@rushstack/eslint-patch@^1.1.3": version "1.2.0" resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz#8be36a1f66f3265389e90b5f9c9962146758f728" @@ -1730,6 +1715,13 @@ browserslist@^4.21.3, browserslist@^4.21.5: node-releases "^2.0.8" update-browserslist-db "^1.0.10" +busboy@1.6.0: + version "1.6.0" + resolved "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" @@ -3937,30 +3929,27 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -next@^13.2.3: - version "13.2.4" - resolved "https://registry.yarnpkg.com/next/-/next-13.2.4.tgz#2363330392b0f7da02ab41301f60857ffa7f67d6" - integrity sha512-g1I30317cThkEpvzfXujf0O4wtaQHtDCLhlivwlTJ885Ld+eOgcz7r3TGQzeU+cSRoNHtD8tsJgzxVdYojFssw== +next@^13.3.1-canary.8: + version "13.3.1-canary.8" + resolved "https://registry.yarnpkg.com/next/-/next-13.3.1-canary.8.tgz#f0846e5eada1491884326786a0749d5adc04c24d" + integrity sha512-z4QUgyAN+hSWSEqb4pvGvC3iRktE6NH2DVLU4AvfqNYpzP+prePiJC8HN/cJpFhGW9YbhyRLi5FliDC631OOag== dependencies: - "@next/env" "13.2.4" + "@next/env" "13.3.1-canary.8" "@swc/helpers" "0.4.14" + busboy "1.6.0" caniuse-lite "^1.0.30001406" postcss "8.4.14" styled-jsx "5.1.1" optionalDependencies: - "@next/swc-android-arm-eabi" "13.2.4" - "@next/swc-android-arm64" "13.2.4" - "@next/swc-darwin-arm64" "13.2.4" - "@next/swc-darwin-x64" "13.2.4" - "@next/swc-freebsd-x64" "13.2.4" - "@next/swc-linux-arm-gnueabihf" "13.2.4" - "@next/swc-linux-arm64-gnu" "13.2.4" - "@next/swc-linux-arm64-musl" "13.2.4" - "@next/swc-linux-x64-gnu" "13.2.4" - "@next/swc-linux-x64-musl" "13.2.4" - "@next/swc-win32-arm64-msvc" "13.2.4" - "@next/swc-win32-ia32-msvc" "13.2.4" - "@next/swc-win32-x64-msvc" "13.2.4" + "@next/swc-darwin-arm64" "13.3.1-canary.8" + "@next/swc-darwin-x64" "13.3.1-canary.8" + "@next/swc-linux-arm64-gnu" "13.3.1-canary.8" + "@next/swc-linux-arm64-musl" "13.3.1-canary.8" + "@next/swc-linux-x64-gnu" "13.3.1-canary.8" + "@next/swc-linux-x64-musl" "13.3.1-canary.8" + "@next/swc-win32-arm64-msvc" "13.3.1-canary.8" + "@next/swc-win32-ia32-msvc" "13.3.1-canary.8" + "@next/swc-win32-x64-msvc" "13.3.1-canary.8" node-domexception@^1.0.0: version "1.0.0" @@ -4312,6 +4301,21 @@ react-redux@^8.0.4: react-is "^18.0.0" use-sync-external-store "^1.0.0" +react-router-dom@^6.10.0: + version "6.10.0" + resolved "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.10.0.tgz#090ddc5c84dc41b583ce08468c4007c84245f61f" + integrity sha512-E5dfxRPuXKJqzwSe/qGcqdwa18QiWC6f3H3cWXM24qj4N0/beCIf/CWTipop2xm7mR0RCS99NnaqPNjHtrAzCg== + dependencies: + "@remix-run/router" "1.5.0" + react-router "6.10.0" + +react-router@6.10.0: + version "6.10.0" + resolved "https://registry.npmmirror.com/react-router/-/react-router-6.10.0.tgz#230f824fde9dd0270781b5cb497912de32c0a971" + integrity sha512-Nrg0BWpQqrC3ZFFkyewrflCud9dio9ME3ojHCF/WLsprJVzkq3q3UeEhMCAW1dobjeGbWgjNn/PVF6m46ANxXQ== + dependencies: + "@remix-run/router" "1.5.0" + react@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" @@ -4668,6 +4672,11 @@ stop-iteration-iterator@^1.0.0: dependencies: internal-slot "^1.0.4" +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + string-argv@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"