diff --git a/README_CN.md b/README_CN.md index 1111540e9..e593e45da 100644 --- a/README_CN.md +++ b/README_CN.md @@ -114,7 +114,7 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填 OPENAI_API_KEY= # 中国大陆用户,可以使用本项目自带的代理进行开发,你也可以自由选择其他代理地址 -BASE_URL=https://chatgpt1.nextweb.fun/api/proxy +BASE_URL=https://chatgpt2.nextweb.fun/api/proxy ``` ### 本地开发 diff --git a/app/api/cors/[...path]/route.ts b/app/api/cors/[...path]/route.ts new file mode 100644 index 000000000..90404cf89 --- /dev/null +++ b/app/api/cors/[...path]/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; + +async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + + const [protocol, ...subpath] = params.path; + const targetUrl = `${protocol}://${subpath.join("/")}`; + + const method = req.headers.get("method") ?? undefined; + const shouldNotHaveBody = ["get", "head"].includes( + method?.toLowerCase() ?? "", + ); + + const fetchOptions: RequestInit = { + headers: { + authorization: req.headers.get("authorization") ?? "", + }, + body: shouldNotHaveBody ? null : req.body, + method, + // @ts-ignore + duplex: "half", + }; + + console.log("[Any Proxy]", targetUrl); + + const fetchResult = fetch(targetUrl, fetchOptions); + + return fetchResult; +} + +export const POST = handle; + +export const runtime = "edge"; diff --git a/app/components/auth.tsx b/app/components/auth.tsx index 1ca83dcd3..9a5b0c655 100644 --- a/app/components/auth.tsx +++ b/app/components/auth.tsx @@ -15,6 +15,7 @@ export function AuthPage() { const access = useAccessStore(); const goHome = () => navigate(Path.Home); + const resetAccessCode = () => access.updateCode(""); // Reset access code to empty string useEffect(() => { if (getClientConfig()?.isApp) { @@ -48,7 +49,10 @@ export function AuthPage() { type="primary" onClick={goHome} /> - + { + resetAccessCode(); + goHome(); + }} /> ); diff --git a/app/components/chat.tsx b/app/components/chat.tsx index dfda4055b..6fb497303 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -80,6 +80,7 @@ import { MAX_RENDER_MSG_COUNT, Path, REQUEST_TIMEOUT_MS, + UNFINISHED_INPUT, } from "../constant"; import { Avatar } from "./emoji"; import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask"; @@ -935,7 +936,8 @@ function _Chat() { const isTouchTopEdge = e.scrollTop <= edgeThreshold; const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold; - const isHitBottom = bottomHeight >= e.scrollHeight - (isMobileScreen ? 0 : 10); + const isHitBottom = + bottomHeight >= e.scrollHeight - (isMobileScreen ? 0 : 10); const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE; const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE; @@ -1013,6 +1015,23 @@ function _Chat() { // edit / insert message modal const [isEditingMessage, setIsEditingMessage] = useState(false); + // remember unfinished input + useEffect(() => { + // try to load from local storage + const key = UNFINISHED_INPUT(session.id); + const mayBeUnfinishedInput = localStorage.getItem(key); + if (mayBeUnfinishedInput && userInput.length === 0) { + setUserInput(mayBeUnfinishedInput); + localStorage.removeItem(key); + } + + const dom = inputRef.current; + return () => { + localStorage.setItem(key, dom?.value ?? ""); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
diff --git a/app/components/error.tsx b/app/components/error.tsx index b38341e22..914740f96 100644 --- a/app/components/error.tsx +++ b/app/components/error.tsx @@ -4,8 +4,8 @@ import GithubIcon from "../icons/github.svg"; import ResetIcon from "../icons/reload.svg"; import { ISSUE_URL } from "../constant"; import Locale from "../locales"; -import { downloadAs } from "../utils"; import { showConfirm } from "./ui-lib"; +import { useSyncStore } from "../store/sync"; interface IErrorBoundaryState { hasError: boolean; @@ -26,10 +26,7 @@ export class ErrorBoundary extends React.Component { clearAndSaveData() { try { - downloadAs( - JSON.stringify(localStorage), - "chatgpt-next-web-snapshot.json", - ); + useSyncStore.getState().export(); } finally { localStorage.clear(); location.reload(); diff --git a/app/components/mask.tsx b/app/components/mask.tsx index 3d8ce3a26..1ee1c239a 100644 --- a/app/components/mask.tsx +++ b/app/components/mask.tsx @@ -410,7 +410,7 @@ export function MaskPage() { const closeMaskModal = () => setEditingMaskId(undefined); const downloadAll = () => { - downloadAs(JSON.stringify(masks), FileName.Masks); + downloadAs(JSON.stringify(masks.filter((v) => !v.builtin)), FileName.Masks); }; const importFromFile = () => { @@ -452,11 +452,13 @@ export function MaskPage() { icon={} bordered onClick={downloadAll} + text={Locale.UI.Export} />
} + text={Locale.UI.Import} bordered onClick={() => importFromFile()} /> @@ -604,7 +606,7 @@ export function MaskPage() { - maskStore.update(editingMaskId!, updater) + maskStore.updateMask(editingMaskId!, updater) } readonly={editingMask.builtin} /> diff --git a/app/components/settings.tsx b/app/components/settings.tsx index 4f50aabd6..5f85f7293 100644 --- a/app/components/settings.tsx +++ b/app/components/settings.tsx @@ -10,6 +10,15 @@ import ClearIcon from "../icons/clear.svg"; import LoadingIcon from "../icons/three-dots.svg"; import EditIcon from "../icons/edit.svg"; import EyeIcon from "../icons/eye.svg"; +import DownloadIcon from "../icons/download.svg"; +import UploadIcon from "../icons/upload.svg"; +import ConfigIcon from "../icons/config.svg"; +import ConfirmIcon from "../icons/confirm.svg"; + +import ConnectionIcon from "../icons/connection.svg"; +import CloudSuccessIcon from "../icons/cloud-success.svg"; +import CloudFailIcon from "../icons/cloud-fail.svg"; + import { Input, List, @@ -19,6 +28,7 @@ import { Popover, Select, showConfirm, + showToast, } from "./ui-lib"; import { ModelConfigList } from "./model-config"; @@ -49,6 +59,8 @@ import { Avatar, AvatarPicker } from "./emoji"; import { getClientConfig } from "../config/client"; import { useSyncStore } from "../store/sync"; import { nanoid } from "nanoid"; +import { useMaskStore } from "../store/mask"; +import { ProviderType } from "../utils/cloud"; function EditPromptModal(props: { id: string; onClose: () => void }) { const promptStore = usePromptStore(); @@ -75,7 +87,7 @@ function EditPromptModal(props: { id: string; onClose: () => void }) { readOnly={!prompt.isUser} className={styles["edit-prompt-title"]} onInput={(e) => - promptStore.update( + promptStore.updatePrompt( props.id, (prompt) => (prompt.title = e.currentTarget.value), ) @@ -87,7 +99,7 @@ function EditPromptModal(props: { id: string; onClose: () => void }) { className={styles["edit-prompt-content"]} rows={10} onInput={(e) => - promptStore.update( + promptStore.updatePrompt( props.id, (prompt) => (prompt.content = e.currentTarget.value), ) @@ -127,14 +139,15 @@ function UserPromptModal(props: { onClose?: () => void }) { actions={[ - promptStore.add({ + onClick={() => { + const promptId = promptStore.add({ id: nanoid(), createdAt: Date.now(), title: "Empty Prompt", content: "Empty Prompt Content", - }) - } + }); + setEditingPromptId(promptId); + }} icon={} bordered text={Locale.Settings.Prompt.Modal.Add} @@ -241,75 +254,262 @@ function DangerItems() { ); } -function SyncItems() { +function CheckButton() { const syncStore = useSyncStore(); - const webdav = syncStore.webDavConfig; - // not ready: https://github.com/Yidadaa/ChatGPT-Next-Web/issues/920#issuecomment-1609866332 - return null; + const couldCheck = useMemo(() => { + return syncStore.coundSync(); + }, [syncStore]); + + const [checkState, setCheckState] = useState< + "none" | "checking" | "success" | "failed" + >("none"); + + async function check() { + setCheckState("checking"); + const valid = await syncStore.check(); + setCheckState(valid ? "success" : "failed"); + } + + if (!couldCheck) return null; return ( - - + ) : checkState === "checking" ? ( + + ) : checkState === "success" ? ( + + ) : checkState === "failed" ? ( + + ) : ( + + ) + } + > + ); +} + +function SyncConfigModal(props: { onClose?: () => void }) { + const syncStore = useSyncStore(); + + return ( +
+ props.onClose?.()} + actions={[ + , + } + bordered + text={Locale.UI.Confirm} + />, + ]} > - } - text="同步" - onClick={() => { - syncStore.check().then(console.log); - }} - /> - + + + + - + + { + syncStore.update( + (config) => (config.useProxy = e.currentTarget.checked), + ); + }} + > + + {syncStore.useProxy ? ( + + { + syncStore.update( + (config) => (config.proxyUrl = e.currentTarget.value), + ); + }} + > + + ) : null} + - - { - syncStore.update( - (config) => (config.server = e.currentTarget.value), - ); - }} - /> - + {syncStore.provider === ProviderType.WebDAV && ( + <> + + + { + syncStore.update( + (config) => + (config.webdav.endpoint = e.currentTarget.value), + ); + }} + > + - - { - syncStore.update( - (config) => (config.username = e.currentTarget.value), - ); - }} - /> - + + { + syncStore.update( + (config) => + (config.webdav.username = e.currentTarget.value), + ); + }} + > + + + { + syncStore.update( + (config) => + (config.webdav.password = e.currentTarget.value), + ); + }} + > + + + + )} - - { - syncStore.update( - (config) => (config.password = e.currentTarget.value), - ); - }} - /> - - + {syncStore.provider === ProviderType.UpStash && ( + + + + )} + +
+ ); +} + +function SyncItems() { + const syncStore = useSyncStore(); + const chatStore = useChatStore(); + const promptStore = usePromptStore(); + const maskStore = useMaskStore(); + const couldSync = useMemo(() => { + return syncStore.coundSync(); + }, [syncStore]); + + const [showSyncConfigModal, setShowSyncConfigModal] = useState(false); + + const stateOverview = useMemo(() => { + const sessions = chatStore.sessions; + const messageCount = sessions.reduce((p, c) => p + c.messages.length, 0); + + return { + chat: sessions.length, + message: messageCount, + prompt: Object.keys(promptStore.prompts).length, + mask: Object.keys(maskStore.masks).length, + }; + }, [chatStore.sessions, maskStore.masks, promptStore.prompts]); + + return ( + <> + + +
+ } + text={Locale.UI.Config} + onClick={() => { + setShowSyncConfigModal(true); + }} + /> + {couldSync && ( + } + text={Locale.UI.Sync} + onClick={async () => { + try { + await syncStore.sync(); + showToast(Locale.Settings.Sync.Success); + } catch (e) { + showToast(Locale.Settings.Sync.Fail); + console.error("[Sync]", e); + } + }} + /> + )} +
+
+ + +
+ } + text={Locale.UI.Export} + onClick={() => { + syncStore.export(); + }} + /> + } + text={Locale.UI.Import} + onClick={() => { + syncStore.import(); + }} + /> +
+
+
+ + {showSyncConfigModal && ( + setShowSyncConfigModal(false)} /> + )} + ); } @@ -562,6 +762,8 @@ export function Settings() { + + - - "unfinished-input-" + id; + +export const STORAGE_KEY = "chatgpt-next-web"; export const REQUEST_TIMEOUT_MS = 60000; diff --git a/app/icons/cloud-fail.svg b/app/icons/cloud-fail.svg new file mode 100644 index 000000000..6e6a35fe5 --- /dev/null +++ b/app/icons/cloud-fail.svg @@ -0,0 +1 @@ + diff --git a/app/icons/cloud-success.svg b/app/icons/cloud-success.svg new file mode 100644 index 000000000..8c5f3d6fd --- /dev/null +++ b/app/icons/cloud-success.svg @@ -0,0 +1 @@ + diff --git a/app/icons/config.svg b/app/icons/config.svg new file mode 100644 index 000000000..7e1d23a27 --- /dev/null +++ b/app/icons/config.svg @@ -0,0 +1 @@ + diff --git a/app/icons/connection.svg b/app/icons/connection.svg new file mode 100644 index 000000000..036873020 --- /dev/null +++ b/app/icons/connection.svg @@ -0,0 +1 @@ + diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 7c0473bb3..1b8850f45 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -178,6 +178,42 @@ const cn = { Title: "自动生成标题", SubTitle: "根据对话内容生成合适的标题", }, + Sync: { + CloudState: "云端数据", + NotSyncYet: "还没有进行过同步", + Success: "同步成功", + Fail: "同步失败", + + Config: { + Modal: { + Title: "配置云同步", + }, + SyncType: { + Title: "同步类型", + SubTitle: "选择喜爱的同步服务器", + }, + Proxy: { + Title: "启用代理", + SubTitle: "在浏览器中同步时,必须启用代理以避免跨域限制", + }, + ProxyUrl: { + Title: "代理地址", + SubTitle: "仅适用于本项目自带的跨域代理", + }, + + WebDav: { + Endpoint: "WebDAV 地址", + UserName: "用户名", + Password: "密码", + }, + }, + + LocalState: "本地数据", + Overview: (overview: any) => { + return `${overview.chat} 次对话,${overview.message} 条消息,${overview.prompt} 条提示词,${overview.mask} 个面具`; + }, + ImportFailed: "导入失败", + }, Mask: { Splash: { Title: "面具启动页", @@ -355,6 +391,10 @@ const cn = { Close: "关闭", Create: "新建", Edit: "编辑", + Export: "导出", + Import: "导入", + Sync: "同步", + Config: "配置", }, Exporter: { Model: "模型", diff --git a/app/locales/en.ts b/app/locales/en.ts index 981357274..ebbf1a376 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -180,6 +180,43 @@ const en: LocaleType = { Title: "Auto Generate Title", SubTitle: "Generate a suitable title based on the conversation content", }, + Sync: { + CloudState: "Last Update", + NotSyncYet: "Not sync yet", + Success: "Sync Success", + Fail: "Sync Fail", + + Config: { + Modal: { + Title: "Config Sync", + }, + SyncType: { + Title: "Sync Type", + SubTitle: "Choose your favorite sync service", + }, + Proxy: { + Title: "Enable CORS Proxy", + SubTitle: "Enable a proxy to avoid cross-origin restrictions", + }, + ProxyUrl: { + Title: "Proxy Endpoint", + SubTitle: + "Only applicable to the built-in CORS proxy for this project", + }, + + WebDav: { + Endpoint: "WebDAV Endpoint", + UserName: "User Name", + Password: "Password", + }, + }, + + LocalState: "Local Data", + Overview: (overview: any) => { + return `${overview.chat} chats,${overview.message} messages,${overview.prompt} prompts,${overview.mask} masks`; + }, + ImportFailed: "Failed to import from file", + }, Mask: { Splash: { Title: "Mask Splash Screen", @@ -355,6 +392,10 @@ const en: LocaleType = { Close: "Close", Create: "Create", Edit: "Edit", + Export: "Export", + Import: "Import", + Sync: "Sync", + Config: "Config", }, Exporter: { Model: "Model", diff --git a/app/locales/jp.ts b/app/locales/jp.ts index c3e00fa09..b63e8ba3a 100644 --- a/app/locales/jp.ts +++ b/app/locales/jp.ts @@ -19,7 +19,11 @@ const jp: PartialLocaleType = { Copy: "コピー", Stop: "停止", Retry: "リトライ", + Pin: "ピン", + PinToastContent: "コンテキストプロンプトに1つのメッセージをピン留めしました", + PinToastAction: "表示", Delete: "削除", + Edit: "編集", }, Rename: "チャットの名前を変更", Typing: "入力中…", @@ -33,7 +37,7 @@ const jp: PartialLocaleType = { Send: "送信", Config: { Reset: "リセット", - SaveAs: "另存为面具", + SaveAs: "保存", }, }, Export: { diff --git a/app/store/access.ts b/app/store/access.ts index b60211631..9eaa81e5e 100644 --- a/app/store/access.ts +++ b/app/store/access.ts @@ -1,28 +1,7 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; import { DEFAULT_API_HOST, DEFAULT_MODELS, StoreKey } from "../constant"; import { getHeaders } from "../client/api"; -import { BOT_HELLO } from "./chat"; import { getClientConfig } from "../config/client"; - -export interface AccessControlStore { - accessCode: string; - token: string; - - needCode: boolean; - hideUserApiKey: boolean; - hideBalanceQuery: boolean; - disableGPT4: boolean; - - openaiUrl: string; - - updateToken: (_: string) => void; - updateCode: (_: string) => void; - updateOpenAiUrl: (_: string) => void; - enabledAccessControl: () => boolean; - isAuthorized: () => boolean; - fetch: () => void; -} +import { createPersistStore } from "../utils/store"; let fetchState = 0; // 0 not fetch, 1 fetching, 2 done @@ -30,72 +9,74 @@ const DEFAULT_OPENAI_URL = getClientConfig()?.buildMode === "export" ? DEFAULT_API_HOST : "/api/openai/"; console.log("[API] default openai url", DEFAULT_OPENAI_URL); -export const useAccessStore = create()( - persist( - (set, get) => ({ - token: "", - accessCode: "", - needCode: true, - hideUserApiKey: false, - hideBalanceQuery: false, - disableGPT4: false, +const DEFAULT_ACCESS_STATE = { + token: "", + accessCode: "", + needCode: true, + hideUserApiKey: false, + hideBalanceQuery: false, + disableGPT4: false, - openaiUrl: DEFAULT_OPENAI_URL, + openaiUrl: DEFAULT_OPENAI_URL, +}; - enabledAccessControl() { - get().fetch(); +export const useAccessStore = createPersistStore( + { ...DEFAULT_ACCESS_STATE }, - return get().needCode; - }, - updateCode(code: string) { - set(() => ({ accessCode: code?.trim() })); - }, - updateToken(token: string) { - set(() => ({ token: token?.trim() })); - }, - updateOpenAiUrl(url: string) { - set(() => ({ openaiUrl: url?.trim() })); - }, - isAuthorized() { - get().fetch(); + (set, get) => ({ + enabledAccessControl() { + this.fetch(); - // has token or has code or disabled access control - return ( - !!get().token || !!get().accessCode || !get().enabledAccessControl() - ); - }, - fetch() { - if (fetchState > 0 || getClientConfig()?.buildMode === "export") return; - fetchState = 1; - fetch("/api/config", { - method: "post", - body: null, - headers: { - ...getHeaders(), - }, - }) - .then((res) => res.json()) - .then((res: DangerConfig) => { - console.log("[Config] got config from server", res); - set(() => ({ ...res })); - - if (res.disableGPT4) { - DEFAULT_MODELS.forEach( - (m: any) => (m.available = !m.name.startsWith("gpt-4")), - ); - } - }) - .catch(() => { - console.error("[Config] failed to fetch config"); - }) - .finally(() => { - fetchState = 2; - }); - }, - }), - { - name: StoreKey.Access, - version: 1, + return get().needCode; }, - ), + updateCode(code: string) { + set(() => ({ accessCode: code?.trim() })); + }, + updateToken(token: string) { + set(() => ({ token: token?.trim() })); + }, + updateOpenAiUrl(url: string) { + set(() => ({ openaiUrl: url?.trim() })); + }, + isAuthorized() { + this.fetch(); + + // has token or has code or disabled access control + return ( + !!get().token || !!get().accessCode || !this.enabledAccessControl() + ); + }, + fetch() { + if (fetchState > 0 || getClientConfig()?.buildMode === "export") return; + fetchState = 1; + fetch("/api/config", { + method: "post", + body: null, + headers: { + ...getHeaders(), + }, + }) + .then((res) => res.json()) + .then((res: DangerConfig) => { + console.log("[Config] got config from server", res); + set(() => ({ ...res })); + + if (res.disableGPT4) { + DEFAULT_MODELS.forEach( + (m: any) => (m.available = !m.name.startsWith("gpt-4")), + ); + } + }) + .catch(() => { + console.error("[Config] failed to fetch config"); + }) + .finally(() => { + fetchState = 2; + }); + }, + }), + { + name: StoreKey.Access, + version: 1, + }, ); diff --git a/app/store/chat.ts b/app/store/chat.ts index 20603fe48..269cc4a33 100644 --- a/app/store/chat.ts +++ b/app/store/chat.ts @@ -18,6 +18,7 @@ import { ChatControllerPool } from "../client/controller"; import { prettyObject } from "../utils/format"; import { estimateTokenLength } from "../utils/token"; import { nanoid } from "nanoid"; +import { createPersistStore } from "../utils/store"; export type ChatMessage = RequestMessage & { date: string; @@ -140,12 +141,22 @@ function fillTemplateWith(input: string, modelConfig: ModelConfig) { return output; } -export const useChatStore = create()( - persist( - (set, get) => ({ - sessions: [createEmptySession()], - currentSessionIndex: 0, +const DEFAULT_CHAT_STATE = { + sessions: [createEmptySession()], + currentSessionIndex: 0, +}; +export const useChatStore = createPersistStore( + DEFAULT_CHAT_STATE, + (set, _get) => { + function get() { + return { + ..._get(), + ...methods, + }; + } + + const methods = { clearSessions() { set(() => ({ sessions: [createEmptySession()], @@ -184,7 +195,7 @@ export const useChatStore = create()( }); }, - newSession(mask) { + newSession(mask?: Mask) { const session = createEmptySession(); if (mask) { @@ -207,14 +218,14 @@ export const useChatStore = create()( })); }, - nextSession(delta) { + nextSession(delta: number) { const n = get().sessions.length; const limit = (x: number) => (x + n) % n; const i = get().currentSessionIndex; get().selectSession(limit(i + delta)); }, - deleteSession(index) { + deleteSession(index: number) { const deletingLastSession = get().sessions.length === 1; const deletedSession = get().sessions.at(index); @@ -271,7 +282,7 @@ export const useChatStore = create()( return session; }, - onNewMessage(message) { + onNewMessage(message: ChatMessage) { get().updateCurrentSession((session) => { session.messages = session.messages.concat(); session.lastUpdate = Date.now(); @@ -280,7 +291,7 @@ export const useChatStore = create()( get().summarizeSession(); }, - async onUserInput(content) { + async onUserInput(content: string) { const session = get().currentSession(); const modelConfig = session.mask.modelConfig; @@ -580,14 +591,14 @@ export const useChatStore = create()( } }, - updateStat(message) { + updateStat(message: ChatMessage) { get().updateCurrentSession((session) => { session.stat.charCount += message.content.length; // TODO: should update chat count and word count }); }, - updateCurrentSession(updater) { + updateCurrentSession(updater: (session: ChatSession) => void) { const sessions = get().sessions; const index = get().currentSessionIndex; updater(sessions[index]); @@ -598,56 +609,60 @@ export const useChatStore = create()( localStorage.clear(); location.reload(); }, - }), - { - name: StoreKey.Chat, - version: 3.1, - migrate(persistedState, version) { - const state = persistedState as any; - const newState = JSON.parse(JSON.stringify(state)) as ChatStore; + }; - if (version < 2) { - newState.sessions = []; + return methods; + }, + { + name: StoreKey.Chat, + version: 3.1, + migrate(persistedState, version) { + const state = persistedState as any; + const newState = JSON.parse( + JSON.stringify(state), + ) as typeof DEFAULT_CHAT_STATE; - const oldSessions = state.sessions; - for (const oldSession of oldSessions) { - const newSession = createEmptySession(); - newSession.topic = oldSession.topic; - newSession.messages = [...oldSession.messages]; - newSession.mask.modelConfig.sendMemory = true; - newSession.mask.modelConfig.historyMessageCount = 4; - newSession.mask.modelConfig.compressMessageLengthThreshold = 1000; - newState.sessions.push(newSession); + if (version < 2) { + newState.sessions = []; + + const oldSessions = state.sessions; + for (const oldSession of oldSessions) { + const newSession = createEmptySession(); + newSession.topic = oldSession.topic; + newSession.messages = [...oldSession.messages]; + newSession.mask.modelConfig.sendMemory = true; + newSession.mask.modelConfig.historyMessageCount = 4; + newSession.mask.modelConfig.compressMessageLengthThreshold = 1000; + newState.sessions.push(newSession); + } + } + + if (version < 3) { + // migrate id to nanoid + newState.sessions.forEach((s) => { + s.id = nanoid(); + s.messages.forEach((m) => (m.id = nanoid())); + }); + } + + // Enable `enableInjectSystemPrompts` attribute for old sessions. + // Resolve issue of old sessions not automatically enabling. + if (version < 3.1) { + newState.sessions.forEach((s) => { + if ( + // Exclude those already set by user + !s.mask.modelConfig.hasOwnProperty("enableInjectSystemPrompts") + ) { + // Because users may have changed this configuration, + // the user's current configuration is used instead of the default + const config = useAppConfig.getState(); + s.mask.modelConfig.enableInjectSystemPrompts = + config.modelConfig.enableInjectSystemPrompts; } - } + }); + } - if (version < 3) { - // migrate id to nanoid - newState.sessions.forEach((s) => { - s.id = nanoid(); - s.messages.forEach((m) => (m.id = nanoid())); - }); - } - - // Enable `enableInjectSystemPrompts` attribute for old sessions. - // Resolve issue of old sessions not automatically enabling. - if (version < 3.1) { - newState.sessions.forEach((s) => { - if ( - // Exclude those already set by user - !s.mask.modelConfig.hasOwnProperty("enableInjectSystemPrompts") - ) { - // Because users may have changed this configuration, - // the user's current configuration is used instead of the default - const config = useAppConfig.getState(); - s.mask.modelConfig.enableInjectSystemPrompts = - config.modelConfig.enableInjectSystemPrompts; - } - }); - } - - return newState; - }, + return newState as any; }, - ), + }, ); diff --git a/app/store/config.ts b/app/store/config.ts index 7070ea05e..b01319542 100644 --- a/app/store/config.ts +++ b/app/store/config.ts @@ -1,8 +1,7 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; import { LLMModel } from "../client/api"; import { getClientConfig } from "../config/client"; import { DEFAULT_INPUT_TEMPLATE, DEFAULT_MODELS, StoreKey } from "../constant"; +import { createPersistStore } from "../utils/store"; export type ModelType = (typeof DEFAULT_MODELS)[number]["name"]; @@ -21,6 +20,8 @@ export enum Theme { } export const DEFAULT_CONFIG = { + lastUpdate: Date.now(), // timestamp, to merge state + submitKey: SubmitKey.CtrlEnter as SubmitKey, avatar: "1f603", fontSize: 14, @@ -55,13 +56,6 @@ export const DEFAULT_CONFIG = { export type ChatConfig = typeof DEFAULT_CONFIG; -export type ChatConfigStore = ChatConfig & { - reset: () => void; - update: (updater: (config: ChatConfig) => void) => void; - mergeModels: (newModels: LLMModel[]) => void; - allModels: () => LLMModel[]; -}; - export type ModelConfig = ChatConfig["modelConfig"]; export function limitNumber( @@ -98,85 +92,80 @@ export const ModalConfigValidator = { }, }; -export const useAppConfig = create()( - persist( - (set, get) => ({ - ...DEFAULT_CONFIG, - - reset() { - set(() => ({ ...DEFAULT_CONFIG })); - }, - - update(updater) { - const config = { ...get() }; - updater(config); - set(() => config); - }, - - mergeModels(newModels) { - if (!newModels || newModels.length === 0) { - return; - } - - const oldModels = get().models; - const modelMap: Record = {}; - - for (const model of oldModels) { - model.available = false; - modelMap[model.name] = model; - } - - for (const model of newModels) { - model.available = true; - modelMap[model.name] = model; - } - - set(() => ({ - models: Object.values(modelMap), - })); - }, - - allModels() { - const customModels = get() - .customModels.split(",") - .filter((v) => !!v && v.length > 0) - .map((m) => ({ name: m, available: true })); - - const models = get().models.concat(customModels); - return models; - }, - }), - { - name: StoreKey.Config, - version: 3.7, - migrate(persistedState, version) { - const state = persistedState as ChatConfig; - - if (version < 3.4) { - state.modelConfig.sendMemory = true; - state.modelConfig.historyMessageCount = 4; - state.modelConfig.compressMessageLengthThreshold = 1000; - state.modelConfig.frequency_penalty = 0; - state.modelConfig.top_p = 1; - state.modelConfig.template = DEFAULT_INPUT_TEMPLATE; - state.dontShowMaskSplashScreen = false; - state.hideBuiltinMasks = false; - } - - if (version < 3.5) { - state.customModels = "claude,claude-100k"; - } - - if (version < 3.6) { - state.modelConfig.enableInjectSystemPrompts = true; - } - - if (version < 3.7) { - state.enableAutoGenerateTitle = true; - } - - return state as any; - }, +export const useAppConfig = createPersistStore( + { ...DEFAULT_CONFIG }, + (set, get) => ({ + reset() { + set(() => ({ ...DEFAULT_CONFIG })); }, - ), + + mergeModels(newModels: LLMModel[]) { + if (!newModels || newModels.length === 0) { + return; + } + + const oldModels = get().models; + const modelMap: Record = {}; + + for (const model of oldModels) { + model.available = false; + modelMap[model.name] = model; + } + + for (const model of newModels) { + model.available = true; + modelMap[model.name] = model; + } + + set(() => ({ + models: Object.values(modelMap), + })); + }, + + allModels() { + const customModels = get() + .customModels.split(",") + .filter((v) => !!v && v.length > 0) + .map((m) => ({ name: m, available: true })); + + const models = get().models.concat(customModels); + return models; + }, + }), + { + name: StoreKey.Config, + version: 3.8, + migrate(persistedState, version) { + const state = persistedState as ChatConfig; + + if (version < 3.4) { + state.modelConfig.sendMemory = true; + state.modelConfig.historyMessageCount = 4; + state.modelConfig.compressMessageLengthThreshold = 1000; + state.modelConfig.frequency_penalty = 0; + state.modelConfig.top_p = 1; + state.modelConfig.template = DEFAULT_INPUT_TEMPLATE; + state.dontShowMaskSplashScreen = false; + state.hideBuiltinMasks = false; + } + + if (version < 3.5) { + state.customModels = "claude,claude-100k"; + } + + if (version < 3.6) { + state.modelConfig.enableInjectSystemPrompts = true; + } + + if (version < 3.7) { + state.enableAutoGenerateTitle = true; + } + + if (version < 3.8) { + state.lastUpdate = Date.now(); + } + + return state as any; + }, + }, ); diff --git a/app/store/mask.ts b/app/store/mask.ts index 02132b77d..dfd4089b7 100644 --- a/app/store/mask.ts +++ b/app/store/mask.ts @@ -1,11 +1,10 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; import { BUILTIN_MASKS } from "../masks"; import { getLang, Lang } from "../locales"; import { DEFAULT_TOPIC, ChatMessage } from "./chat"; import { ModelConfig, useAppConfig } from "./config"; import { StoreKey } from "../constant"; import { nanoid } from "nanoid"; +import { createPersistStore } from "../utils/store"; export type Mask = { id: string; @@ -25,14 +24,6 @@ export const DEFAULT_MASK_STATE = { }; export type MaskState = typeof DEFAULT_MASK_STATE; -type MaskStore = MaskState & { - create: (mask?: Partial) => Mask; - update: (id: string, updater: (mask: Mask) => void) => void; - delete: (id: string) => void; - search: (text: string) => Mask[]; - get: (id?: string) => Mask | null; - getAll: () => Mask[]; -}; export const DEFAULT_MASK_AVATAR = "gpt-bot"; export const createEmptyMask = () => @@ -46,89 +37,90 @@ export const createEmptyMask = () => lang: getLang(), builtin: false, createdAt: Date.now(), - } as Mask); + }) as Mask; -export const useMaskStore = create()( - persist( - (set, get) => ({ - ...DEFAULT_MASK_STATE, +export const useMaskStore = createPersistStore( + { ...DEFAULT_MASK_STATE }, - create(mask) { - const masks = get().masks; - const id = nanoid(); - masks[id] = { - ...createEmptyMask(), - ...mask, - id, - builtin: false, - }; + (set, get) => ({ + create(mask?: Partial) { + const masks = get().masks; + const id = nanoid(); + masks[id] = { + ...createEmptyMask(), + ...mask, + id, + builtin: false, + }; - set(() => ({ masks })); + set(() => ({ masks })); + get().markUpdate(); - return masks[id]; - }, - update(id, updater) { - const masks = get().masks; - const mask = masks[id]; - if (!mask) return; - const updateMask = { ...mask }; - updater(updateMask); - masks[id] = updateMask; - set(() => ({ masks })); - }, - delete(id) { - const masks = get().masks; - delete masks[id]; - set(() => ({ masks })); - }, - - get(id) { - return get().masks[id ?? 1145141919810]; - }, - getAll() { - const userMasks = Object.values(get().masks).sort( - (a, b) => b.createdAt - a.createdAt, - ); - const config = useAppConfig.getState(); - if (config.hideBuiltinMasks) return userMasks; - const buildinMasks = BUILTIN_MASKS.map( - (m) => - ({ - ...m, - modelConfig: { - ...config.modelConfig, - ...m.modelConfig, - }, - } as Mask), - ); - return userMasks.concat(buildinMasks); - }, - search(text) { - return Object.values(get().masks); - }, - }), - { - name: StoreKey.Mask, - version: 3.1, - - migrate(state, version) { - const newState = JSON.parse(JSON.stringify(state)) as MaskState; - - // migrate mask id to nanoid - if (version < 3) { - Object.values(newState.masks).forEach((m) => (m.id = nanoid())); - } - - if (version < 3.1) { - const updatedMasks: Record = {}; - Object.values(newState.masks).forEach((m) => { - updatedMasks[m.id] = m; - }); - newState.masks = updatedMasks; - } - - return newState as any; - }, + return masks[id]; }, - ), + updateMask(id: string, updater: (mask: Mask) => void) { + const masks = get().masks; + const mask = masks[id]; + if (!mask) return; + const updateMask = { ...mask }; + updater(updateMask); + masks[id] = updateMask; + set(() => ({ masks })); + get().markUpdate(); + }, + delete(id: string) { + const masks = get().masks; + delete masks[id]; + set(() => ({ masks })); + get().markUpdate(); + }, + + get(id?: string) { + return get().masks[id ?? 1145141919810]; + }, + getAll() { + const userMasks = Object.values(get().masks).sort( + (a, b) => b.createdAt - a.createdAt, + ); + const config = useAppConfig.getState(); + if (config.hideBuiltinMasks) return userMasks; + const buildinMasks = BUILTIN_MASKS.map( + (m) => + ({ + ...m, + modelConfig: { + ...config.modelConfig, + ...m.modelConfig, + }, + }) as Mask, + ); + return userMasks.concat(buildinMasks); + }, + search(text: string) { + return Object.values(get().masks); + }, + }), + { + name: StoreKey.Mask, + version: 3.1, + + migrate(state, version) { + const newState = JSON.parse(JSON.stringify(state)) as MaskState; + + // migrate mask id to nanoid + if (version < 3) { + Object.values(newState.masks).forEach((m) => (m.id = nanoid())); + } + + if (version < 3.1) { + const updatedMasks: Record = {}; + Object.values(newState.masks).forEach((m) => { + updatedMasks[m.id] = m; + }); + newState.masks = updatedMasks; + } + + return newState as any; + }, + }, ); diff --git a/app/store/prompt.ts b/app/store/prompt.ts index e743f914c..c6cff1a65 100644 --- a/app/store/prompt.ts +++ b/app/store/prompt.ts @@ -1,9 +1,8 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; import Fuse from "fuse.js"; import { getLang } from "../locales"; import { StoreKey } from "../constant"; import { nanoid } from "nanoid"; +import { createPersistStore } from "../utils/store"; export interface Prompt { id: string; @@ -13,19 +12,6 @@ export interface Prompt { createdAt: number; } -export interface PromptStore { - counter: number; - prompts: Record; - - add: (prompt: Prompt) => string; - get: (id: string) => Prompt | undefined; - remove: (id: string) => void; - search: (text: string) => Prompt[]; - update: (id: string, updater: (prompt: Prompt) => void) => void; - - getUserPrompts: () => Prompt[]; -} - export const SearchService = { ready: false, builtinEngine: new Fuse([], { keys: ["title"] }), @@ -62,130 +48,136 @@ export const SearchService = { }, }; -export const usePromptStore = create()( - persist( - (set, get) => ({ - counter: 0, - latestId: 0, - prompts: {}, +export const usePromptStore = createPersistStore( + { + counter: 0, + prompts: {} as Record, + }, - add(prompt) { - const prompts = get().prompts; - prompt.id = nanoid(); - prompt.isUser = true; - prompt.createdAt = Date.now(); - prompts[prompt.id] = prompt; + (set, get) => ({ + add(prompt: Prompt) { + const prompts = get().prompts; + prompt.id = nanoid(); + prompt.isUser = true; + prompt.createdAt = Date.now(); + prompts[prompt.id] = prompt; - set(() => ({ - latestId: prompt.id!, - prompts: prompts, - })); + set(() => ({ + prompts: prompts, + })); - return prompt.id!; - }, - - get(id) { - const targetPrompt = get().prompts[id]; - - if (!targetPrompt) { - return SearchService.builtinPrompts.find((v) => v.id === id); - } - - return targetPrompt; - }, - - remove(id) { - const prompts = get().prompts; - 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.createdAt - a.createdAt : 0, - ); - return userPrompts; - }, - - update(id, 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 rompts - return get().getUserPrompts().concat(SearchService.builtinPrompts); - } - return SearchService.search(text) as Prompt[]; - }, - }), - { - name: StoreKey.Prompt, - version: 3, - - migrate(state, version) { - const newState = JSON.parse(JSON.stringify(state)) as PromptStore; - - if (version < 3) { - Object.values(newState.prompts).forEach((p) => (p.id = nanoid())); - } - - return newState; - }, - - onRehydrateStorage(state) { - const PROMPT_URL = "./prompts.json"; - - type PromptList = Array<[string, string]>; - - fetch(PROMPT_URL) - .then((res) => res.json()) - .then((res) => { - let fetchPrompts = [res.en, res.cn]; - if (getLang() === "cn") { - fetchPrompts = fetchPrompts.reverse(); - } - const builtinPrompts = fetchPrompts.map( - (promptList: PromptList) => { - return promptList.map( - ([title, content]) => - ({ - id: nanoid(), - title, - content, - createdAt: Date.now(), - } as Prompt), - ); - }, - ); - - 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, userPrompts); - }); - }, + return prompt.id!; }, - ), + + get(id: string) { + const targetPrompt = get().prompts[id]; + + if (!targetPrompt) { + return SearchService.builtinPrompts.find((v) => v.id === id); + } + + return targetPrompt; + }, + + remove(id: string) { + const prompts = get().prompts; + delete prompts[id]; + + Object.entries(prompts).some(([key, prompt]) => { + if (prompt.id === id) { + delete prompts[key]; + return true; + } + return false; + }); + + 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.createdAt - a.createdAt : 0, + ); + return userPrompts; + }, + + updatePrompt(id: string, updater: (prompt: Prompt) => void) { + 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: string) { + if (text.length === 0) { + // return all rompts + return this.getUserPrompts().concat(SearchService.builtinPrompts); + } + return SearchService.search(text) as Prompt[]; + }, + }), + { + name: StoreKey.Prompt, + version: 3, + + migrate(state, version) { + const newState = JSON.parse(JSON.stringify(state)) as { + prompts: Record; + }; + + if (version < 3) { + Object.values(newState.prompts).forEach((p) => (p.id = nanoid())); + } + + return newState as any; + }, + + onRehydrateStorage(state) { + const PROMPT_URL = "./prompts.json"; + + type PromptList = Array<[string, string]>; + + fetch(PROMPT_URL) + .then((res) => res.json()) + .then((res) => { + let fetchPrompts = [res.en, res.cn]; + if (getLang() === "cn") { + fetchPrompts = fetchPrompts.reverse(); + } + const builtinPrompts = fetchPrompts.map((promptList: PromptList) => { + return promptList.map( + ([title, content]) => + ({ + id: nanoid(), + title, + content, + createdAt: Date.now(), + }) as Prompt, + ); + }); + + 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, userPrompts); + }); + }, + }, ); diff --git a/app/store/sync.ts b/app/store/sync.ts index 1a111f75a..29b6a82c2 100644 --- a/app/store/sync.ts +++ b/app/store/sync.ts @@ -1,7 +1,18 @@ import { Updater } from "../typing"; -import { create } from "zustand"; -import { persist } from "zustand/middleware"; -import { StoreKey } from "../constant"; +import { ApiPath, StoreKey } from "../constant"; +import { createPersistStore } from "../utils/store"; +import { + AppState, + getLocalAppState, + GetStoreState, + mergeAppState, + setLocalAppState, +} from "../utils/sync"; +import { downloadAs, readFromFile } from "../utils"; +import { showToast } from "../components/ui-lib"; +import Locale from "../locales"; +import { createSyncClient, ProviderType } from "../utils/cloud"; +import { corsPath } from "../utils/cors"; export interface WebDavConfig { server: string; @@ -9,79 +20,94 @@ export interface WebDavConfig { password: string; } -export interface SyncStore { - webDavConfig: WebDavConfig; - lastSyncTime: number; +export type SyncStore = GetStoreState; - update: Updater; - check: () => Promise; +export const useSyncStore = createPersistStore( + { + provider: ProviderType.WebDAV, + useProxy: true, + proxyUrl: corsPath(ApiPath.Cors), - path: (path: string) => string; - headers: () => { Authorization: string }; -} - -const FILE = { - root: "/chatgpt-next-web/", -}; - -export const useSyncStore = create()( - persist( - (set, get) => ({ - webDavConfig: { - server: "", - username: "", - password: "", - }, - - lastSyncTime: 0, - - update(updater) { - const config = { ...get().webDavConfig }; - updater(config); - set({ webDavConfig: config }); - }, - - async check() { - try { - const res = await fetch(this.path(""), { - method: "PROFIND", - headers: this.headers(), - }); - console.log(res); - return res.status === 207; - } catch (e) { - console.error("[Sync] ", e); - return false; - } - }, - - path(path: string) { - let url = get().webDavConfig.server; - - if (!url.endsWith("/")) { - url += "/"; - } - - if (path.startsWith("/")) { - path = path.slice(1); - } - - return url + path; - }, - - headers() { - const auth = btoa( - [get().webDavConfig.username, get().webDavConfig.password].join(":"), - ); - - return { - Authorization: `Basic ${auth}`, - }; - }, - }), - { - name: StoreKey.Sync, - version: 1, + webdav: { + endpoint: "", + username: "", + password: "", }, - ), + + upstash: { + endpoint: "", + username: "", + apiKey: "", + }, + + lastSyncTime: 0, + lastProvider: "", + }, + (set, get) => ({ + coundSync() { + const config = get()[get().provider]; + return Object.values(config).every((c) => c.toString().length > 0); + }, + + markSyncTime() { + set({ lastSyncTime: Date.now(), lastProvider: get().provider }); + }, + + export() { + const state = getLocalAppState(); + const fileName = `Backup-${new Date().toLocaleString()}.json`; + downloadAs(JSON.stringify(state), fileName); + }, + + async import() { + const rawContent = await readFromFile(); + + try { + const remoteState = JSON.parse(rawContent) as AppState; + const localState = getLocalAppState(); + mergeAppState(localState, remoteState); + setLocalAppState(localState); + location.reload(); + } catch (e) { + console.error("[Import]", e); + showToast(Locale.Settings.Sync.ImportFailed); + } + }, + + getClient() { + const provider = get().provider; + const client = createSyncClient(provider, get()); + return client; + }, + + async sync() { + const localState = getLocalAppState(); + const provider = get().provider; + const config = get()[provider]; + const client = this.getClient(); + + try { + const remoteState = JSON.parse( + await client.get(config.username), + ) as AppState; + mergeAppState(localState, remoteState); + setLocalAppState(localState); + } catch (e) { + console.log("[Sync] failed to get remoate state", e); + } + + await client.set(config.username, JSON.stringify(localState)); + + this.markSyncTime(); + }, + + async check() { + const client = this.getClient(); + return await client.check(); + }, + }), + { + name: StoreKey.Sync, + version: 1, + }, ); diff --git a/app/store/update.ts b/app/store/update.ts index dd4d3c724..42b86586c 100644 --- a/app/store/update.ts +++ b/app/store/update.ts @@ -1,24 +1,7 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; import { FETCH_COMMIT_URL, FETCH_TAG_URL, StoreKey } from "../constant"; import { api } from "../client/api"; import { getClientConfig } from "../config/client"; - -export interface UpdateStore { - versionType: "date" | "tag"; - lastUpdate: number; - version: string; - remoteVersion: string; - - used?: number; - subscription?: number; - lastUpdateUsage: number; - - getLatestVersion: (force?: boolean) => Promise; - updateUsage: (force?: boolean) => Promise; - - formatVersion: (version: string) => string; -} +import { createPersistStore } from "../utils/store"; const ONE_MINUTE = 60 * 1000; @@ -35,7 +18,9 @@ function formatVersionDate(t: string) { ].join(""); } -async function getVersion(type: "date" | "tag") { +type VersionType = "date" | "tag"; + +async function getVersion(type: VersionType) { if (type === "date") { const data = (await (await fetch(FETCH_COMMIT_URL)).json()) as { commit: { @@ -55,75 +40,76 @@ async function getVersion(type: "date" | "tag") { } } -export const useUpdateStore = create()( - persist( - (set, get) => ({ - versionType: "tag", - lastUpdate: 0, - version: "unknown", - remoteVersion: "", +export const useUpdateStore = createPersistStore( + { + versionType: "tag" as VersionType, + lastUpdate: 0, + version: "unknown", + remoteVersion: "", + used: 0, + subscription: 0, - lastUpdateUsage: 0, - - formatVersion(version: string) { - if (get().versionType === "date") { - version = formatVersionDate(version); - } - return version; - }, - - async getLatestVersion(force = false) { - const versionType = get().versionType; - let version = - versionType === "date" - ? getClientConfig()?.commitDate - : getClientConfig()?.version; - - set(() => ({ version })); - - const shouldCheck = Date.now() - get().lastUpdate > 2 * 60 * ONE_MINUTE; - if (!force && !shouldCheck) return; - - set(() => ({ - lastUpdate: Date.now(), - })); - - try { - const remoteId = await getVersion(versionType); - set(() => ({ - remoteVersion: remoteId, - })); - console.log("[Got Upstream] ", remoteId); - } catch (error) { - console.error("[Fetch Upstream Commit Id]", error); - } - }, - - async updateUsage(force = false) { - const overOneMinute = Date.now() - get().lastUpdateUsage >= ONE_MINUTE; - if (!overOneMinute && !force) return; - - set(() => ({ - lastUpdateUsage: Date.now(), - })); - - try { - const usage = await api.llm.usage(); - - if (usage) { - set(() => ({ - used: usage.used, - subscription: usage.total, - })); - } - } catch (e) { - console.error((e as Error).message); - } - }, - }), - { - name: StoreKey.Update, - version: 1, + lastUpdateUsage: 0, + }, + (set, get) => ({ + formatVersion(version: string) { + if (get().versionType === "date") { + version = formatVersionDate(version); + } + return version; }, - ), + + async getLatestVersion(force = false) { + const versionType = get().versionType; + let version = + versionType === "date" + ? getClientConfig()?.commitDate + : getClientConfig()?.version; + + set(() => ({ version })); + + const shouldCheck = Date.now() - get().lastUpdate > 2 * 60 * ONE_MINUTE; + if (!force && !shouldCheck) return; + + set(() => ({ + lastUpdate: Date.now(), + })); + + try { + const remoteId = await getVersion(versionType); + set(() => ({ + remoteVersion: remoteId, + })); + console.log("[Got Upstream] ", remoteId); + } catch (error) { + console.error("[Fetch Upstream Commit Id]", error); + } + }, + + async updateUsage(force = false) { + const overOneMinute = Date.now() - get().lastUpdateUsage >= ONE_MINUTE; + if (!overOneMinute && !force) return; + + set(() => ({ + lastUpdateUsage: Date.now(), + })); + + try { + const usage = await api.llm.usage(); + + if (usage) { + set(() => ({ + used: usage.used, + subscription: usage.total, + })); + } + } catch (e) { + console.error((e as Error).message); + } + }, + }), + { + name: StoreKey.Update, + version: 1, + }, ); diff --git a/app/utils/clone.ts b/app/utils/clone.ts new file mode 100644 index 000000000..2958b6b9c --- /dev/null +++ b/app/utils/clone.ts @@ -0,0 +1,3 @@ +export function deepClone(obj: T) { + return JSON.parse(JSON.stringify(obj)); +} diff --git a/app/utils/cloud/index.ts b/app/utils/cloud/index.ts new file mode 100644 index 000000000..63908249e --- /dev/null +++ b/app/utils/cloud/index.ts @@ -0,0 +1,33 @@ +import { createWebDavClient } from "./webdav"; +import { createUpstashClient } from "./upstash"; + +export enum ProviderType { + WebDAV = "webdav", + UpStash = "upstash", +} + +export const SyncClients = { + [ProviderType.UpStash]: createUpstashClient, + [ProviderType.WebDAV]: createWebDavClient, +} as const; + +type SyncClientConfig = { + [K in keyof typeof SyncClients]: (typeof SyncClients)[K] extends ( + _: infer C, + ) => any + ? C + : never; +}; + +export type SyncClient = { + get: (key: string) => Promise; + set: (key: string, value: string) => Promise; + check: () => Promise; +}; + +export function createSyncClient( + provider: T, + config: SyncClientConfig[T], +): SyncClient { + return SyncClients[provider](config as any) as any; +} diff --git a/app/utils/cloud/upstash.ts b/app/utils/cloud/upstash.ts new file mode 100644 index 000000000..6f9b30f6b --- /dev/null +++ b/app/utils/cloud/upstash.ts @@ -0,0 +1,39 @@ +import { SyncStore } from "@/app/store/sync"; + +export type UpstashConfig = SyncStore["upstash"]; +export type UpStashClient = ReturnType; + +export function createUpstashClient(config: UpstashConfig) { + return { + async check() { + return true; + }, + + async get() { + throw Error("[Sync] not implemented"); + }, + + async set() { + throw Error("[Sync] not implemented"); + }, + + headers() { + return { + Authorization: `Basic ${config.apiKey}`, + }; + }, + path(path: string) { + let url = config.endpoint; + + if (!url.endsWith("/")) { + url += "/"; + } + + if (path.startsWith("/")) { + path = path.slice(1); + } + + return url + path; + }, + }; +} diff --git a/app/utils/cloud/webdav.ts b/app/utils/cloud/webdav.ts new file mode 100644 index 000000000..5386b4d19 --- /dev/null +++ b/app/utils/cloud/webdav.ts @@ -0,0 +1,78 @@ +import { STORAGE_KEY } from "@/app/constant"; +import { SyncStore } from "@/app/store/sync"; +import { corsFetch } from "../cors"; + +export type WebDAVConfig = SyncStore["webdav"]; +export type WebDavClient = ReturnType; + +export function createWebDavClient(store: SyncStore) { + const folder = STORAGE_KEY; + const fileName = `${folder}/backup.json`; + const config = store.webdav; + const proxyUrl = + store.useProxy && store.proxyUrl.length > 0 ? store.proxyUrl : undefined; + + return { + async check() { + try { + const res = await corsFetch(this.path(folder), { + method: "MKCOL", + headers: this.headers(), + proxyUrl, + }); + + console.log("[WebDav] check", res.status, res.statusText); + + return [201, 200, 404].includes(res.status); + } catch (e) { + console.error("[WebDav] failed to check", e); + } + + return false; + }, + + async get(key: string) { + const res = await corsFetch(this.path(fileName), { + method: "GET", + headers: this.headers(), + proxyUrl, + }); + + console.log("[WebDav] get key = ", key, res.status, res.statusText); + + return await res.text(); + }, + + async set(key: string, value: string) { + const res = await corsFetch(this.path(fileName), { + method: "PUT", + headers: this.headers(), + body: value, + proxyUrl, + }); + + console.log("[WebDav] set key = ", key, res.status, res.statusText); + }, + + headers() { + const auth = btoa(config.username + ":" + config.password); + + return { + authorization: `Basic ${auth}`, + }; + }, + path(path: string) { + let url = config.endpoint; + + if (!url.endsWith("/")) { + url += "/"; + } + + if (path.startsWith("/")) { + path = path.slice(1); + } + + return url + path; + }, + }; +} diff --git a/app/utils/cors.ts b/app/utils/cors.ts new file mode 100644 index 000000000..773f152aa --- /dev/null +++ b/app/utils/cors.ts @@ -0,0 +1,50 @@ +import { getClientConfig } from "../config/client"; +import { ApiPath, DEFAULT_CORS_HOST } from "../constant"; + +export function corsPath(path: string) { + const baseUrl = getClientConfig()?.isApp ? `${DEFAULT_CORS_HOST}` : ""; + + if (!path.startsWith("/")) { + path = "/" + path; + } + + if (!path.endsWith("/")) { + path += "/"; + } + + return `${baseUrl}${path}`; +} + +export function corsFetch( + url: string, + options: RequestInit & { + proxyUrl?: string; + }, +) { + if (!url.startsWith("http")) { + throw Error("[CORS Fetch] url must starts with http/https"); + } + + let proxyUrl = options.proxyUrl ?? corsPath(ApiPath.Cors); + if (!proxyUrl.endsWith("/")) { + proxyUrl += "/"; + } + + url = url.replace("://", "/"); + + const corsOptions = { + ...options, + method: "POST", + headers: options.method + ? { + ...options.headers, + method: options.method, + } + : options.headers, + }; + + const corsUrl = proxyUrl + url; + console.info("[CORS] target = ", corsUrl); + + return fetch(corsUrl, corsOptions); +} diff --git a/app/utils/store.ts b/app/utils/store.ts new file mode 100644 index 000000000..cd151dc49 --- /dev/null +++ b/app/utils/store.ts @@ -0,0 +1,55 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { Updater } from "../typing"; +import { deepClone } from "./clone"; + +type SecondParam = T extends ( + _f: infer _F, + _s: infer S, + ...args: infer _U +) => any + ? S + : never; + +type MakeUpdater = { + lastUpdateTime: number; + + markUpdate: () => void; + update: Updater; +}; + +type SetStoreState = ( + partial: T | Partial | ((state: T) => T | Partial), + replace?: boolean | undefined, +) => void; + +export function createPersistStore( + defaultState: T, + methods: ( + set: SetStoreState>, + get: () => T & MakeUpdater, + ) => M, + persistOptions: SecondParam>>, +) { + return create>()( + persist((set, get) => { + return { + ...defaultState, + ...methods(set as any, get), + + lastUpdateTime: 0, + markUpdate() { + set({ lastUpdateTime: Date.now() } as Partial< + T & M & MakeUpdater + >); + }, + update(updater) { + const state = deepClone(get()); + updater(state); + get().markUpdate(); + set(state); + }, + }; + }, persistOptions), + ); +} diff --git a/app/utils/sync.ts b/app/utils/sync.ts new file mode 100644 index 000000000..ab1f1f449 --- /dev/null +++ b/app/utils/sync.ts @@ -0,0 +1,162 @@ +import { + ChatSession, + useAccessStore, + useAppConfig, + useChatStore, +} from "../store"; +import { useMaskStore } from "../store/mask"; +import { usePromptStore } from "../store/prompt"; +import { StoreKey } from "../constant"; +import { merge } from "./merge"; + +type NonFunctionKeys = { + [K in keyof T]: T[K] extends (...args: any[]) => any ? never : K; +}[keyof T]; +type NonFunctionFields = Pick>; + +export function getNonFunctionFileds(obj: T) { + const ret: any = {}; + + Object.entries(obj).map(([k, v]) => { + if (typeof v !== "function") { + ret[k] = v; + } + }); + + return ret as NonFunctionFields; +} + +export type GetStoreState = T extends { getState: () => infer U } + ? NonFunctionFields + : never; + +const LocalStateSetters = { + [StoreKey.Chat]: useChatStore.setState, + [StoreKey.Access]: useAccessStore.setState, + [StoreKey.Config]: useAppConfig.setState, + [StoreKey.Mask]: useMaskStore.setState, + [StoreKey.Prompt]: usePromptStore.setState, +} as const; + +const LocalStateGetters = { + [StoreKey.Chat]: () => getNonFunctionFileds(useChatStore.getState()), + [StoreKey.Access]: () => getNonFunctionFileds(useAccessStore.getState()), + [StoreKey.Config]: () => getNonFunctionFileds(useAppConfig.getState()), + [StoreKey.Mask]: () => getNonFunctionFileds(useMaskStore.getState()), + [StoreKey.Prompt]: () => getNonFunctionFileds(usePromptStore.getState()), +} as const; + +export type AppState = { + [k in keyof typeof LocalStateGetters]: ReturnType< + (typeof LocalStateGetters)[k] + >; +}; + +type Merger = ( + localState: U, + remoteState: U, +) => U; + +type StateMerger = { + [K in keyof AppState]: Merger; +}; + +// we merge remote state to local state +const MergeStates: StateMerger = { + [StoreKey.Chat]: (localState, remoteState) => { + // merge sessions + const localSessions: Record = {}; + localState.sessions.forEach((s) => (localSessions[s.id] = s)); + + remoteState.sessions.forEach((remoteSession) => { + const localSession = localSessions[remoteSession.id]; + if (!localSession) { + // if remote session is new, just merge it + localState.sessions.push(remoteSession); + } else { + // if both have the same session id, merge the messages + const localMessageIds = new Set(localSession.messages.map((v) => v.id)); + remoteSession.messages.forEach((m) => { + if (!localMessageIds.has(m.id)) { + localSession.messages.push(m); + } + }); + + // sort local messages with date field in asc order + localSession.messages.sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), + ); + } + }); + + // sort local sessions with date field in desc order + localState.sessions.sort( + (a, b) => + new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime(), + ); + + return localState; + }, + [StoreKey.Prompt]: (localState, remoteState) => { + localState.prompts = { + ...remoteState.prompts, + ...localState.prompts, + }; + return localState; + }, + [StoreKey.Mask]: (localState, remoteState) => { + localState.masks = { + ...remoteState.masks, + ...localState.masks, + }; + return localState; + }, + [StoreKey.Config]: mergeWithUpdate, + [StoreKey.Access]: mergeWithUpdate, +}; + +export function getLocalAppState() { + const appState = Object.fromEntries( + Object.entries(LocalStateGetters).map(([key, getter]) => { + return [key, getter()]; + }), + ) as AppState; + + return appState; +} + +export function setLocalAppState(appState: AppState) { + Object.entries(LocalStateSetters).forEach(([key, setter]) => { + setter(appState[key as keyof AppState]); + }); +} + +export function mergeAppState(localState: AppState, remoteState: AppState) { + Object.keys(localState).forEach((k: string) => { + const key = k as T; + const localStoreState = localState[key]; + const remoteStoreState = remoteState[key]; + MergeStates[key](localStoreState, remoteStoreState); + }); + + return localState; +} + +/** + * Merge state with `lastUpdateTime`, older state will be override + */ +export function mergeWithUpdate( + localState: T, + remoteState: T, +) { + const localUpdateTime = localState.lastUpdateTime ?? 0; + const remoteUpdateTime = localState.lastUpdateTime ?? 1; + + if (localUpdateTime < remoteUpdateTime) { + merge(remoteState, localState); + return { ...remoteState }; + } else { + merge(localState, remoteState); + return { ...localState }; + } +} diff --git a/docs/cloudflare-pages-en.md b/docs/cloudflare-pages-en.md index ee8ff6a6b..2279ff232 100644 --- a/docs/cloudflare-pages-en.md +++ b/docs/cloudflare-pages-en.md @@ -12,7 +12,7 @@ Fork this project on GitHub, then log in to dash.cloudflare.com and go to Pages. 7. In "Build Settings", choose the "Framework presets" option and select "Next.js". 8. Do not use the default "Build command" due to a node:buffer bug. Instead, use the following command: ``` - npx https://prerelease-registry.devprod.cloudflare.dev/next-on-pages/runs/4930842298/npm-package-next-on-pages-230 --experimental-minify + npx @cloudflare/next-on-pages --experimental-minify ``` 9. For "Build output directory", use the default value and do not modify it. 10. Do not modify "Root Directory". @@ -35,4 +35,4 @@ Fork this project on GitHub, then log in to dash.cloudflare.com and go to Pages. 14. Go to "Build settings", "Functions", and find "Compatibility flags". 15. Fill in "nodejs_compat" for both "Configure Production compatibility flag" and "Configure Preview compatibility flag". 16. Go to "Deployments" and click "Retry deployment". -17. Enjoy. \ No newline at end of file +17. Enjoy. diff --git a/next.config.mjs b/next.config.mjs index c8f17de8c..4faa63e54 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -35,27 +35,29 @@ const nextConfig = { }, }; +const CorsHeaders = [ + { key: "Access-Control-Allow-Credentials", value: "true" }, + { key: "Access-Control-Allow-Origin", value: "*" }, + { + key: "Access-Control-Allow-Methods", + value: "*", + }, + { + key: "Access-Control-Allow-Headers", + value: "*", + }, + { + key: "Access-Control-Max-Age", + value: "86400", + }, +]; + if (mode !== "export") { nextConfig.headers = async () => { return [ { source: "/api/:path*", - headers: [ - { key: "Access-Control-Allow-Credentials", value: "true" }, - { key: "Access-Control-Allow-Origin", value: "*" }, - { - key: "Access-Control-Allow-Methods", - value: "*", - }, - { - key: "Access-Control-Allow-Headers", - value: "*", - }, - { - key: "Access-Control-Max-Age", - value: "86400", - }, - ], + headers: CorsHeaders, }, ]; }; @@ -76,15 +78,6 @@ if (mode !== "export") { }, ]; - const apiUrl = process.env.API_URL; - if (apiUrl) { - console.log("[Next] using api url ", apiUrl); - ret.push({ - source: "/api/:path*", - destination: `${apiUrl}/:path*`, - }); - } - return { beforeFiles: ret, }; diff --git a/package.json b/package.json index 6610083bd..0eed3bd9b 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@hello-pangea/dnd": "^16.3.0", "@svgr/webpack": "^6.5.1", "@vercel/analytics": "^0.1.11", - "emoji-picker-react": "^4.4.7", + "emoji-picker-react": "^4.5.1", "fuse.js": "^6.6.2", "html-to-image": "^1.11.11", "mermaid": "^10.3.1", @@ -30,7 +30,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^8.0.7", - "react-router-dom": "^6.14.1", + "react-router-dom": "^6.15.0", "rehype-highlight": "^6.0.0", "rehype-katex": "^6.0.3", "remark-breaks": "^3.0.2", @@ -49,14 +49,14 @@ "@types/react-katex": "^3.0.0", "@types/spark-md5": "^3.0.2", "cross-env": "^7.0.3", - "eslint": "^8.44.0", + "eslint": "^8.49.0", "eslint-config-next": "13.4.19", "eslint-config-prettier": "^8.8.0", "eslint-plugin-prettier": "^4.2.1", "husky": "^8.0.0", "lint-staged": "^13.2.2", "prettier": "^3.0.2", - "typescript": "4.9.5", + "typescript": "5.2.2", "webpack": "^5.88.1" }, "resolutions": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2256d5b34..d8b677bf6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -9,7 +9,7 @@ }, "package": { "productName": "ChatGPT Next Web", - "version": "2.9.5" + "version": "2.9.6" }, "tauri": { "allowlist": { diff --git a/yarn.lock b/yarn.lock index cbce2ef17..8adbf7dc2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1012,15 +1012,15 @@ dependencies: eslint-visitor-keys "^3.3.0" -"@eslint-community/regexpp@^4.4.0": - version "4.5.0" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.5.0.tgz#f6f729b02feee2c749f57e334b7a1b5f40a81724" - integrity sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ== +"@eslint-community/regexpp@^4.6.1": + version "4.8.0" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.8.0.tgz#11195513186f68d42fbf449f9a7136b2c0c92005" + integrity sha512-JylOEEzDiOryeUnFbQz+oViCXS0KsvR1mvHkoMiu5+UiBvy+RYX7tzlIIIEstF/gVa2tj9AQXk3dgnxv6KxhFg== -"@eslint/eslintrc@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.0.tgz#82256f164cc9e0b59669efc19d57f8092706841d" - integrity sha512-Lj7DECXqIVCqnqjjHMPna4vn6GJcMgul/wuS0je9OZ9gsL0zzDpKPVtcG1HaDVc+9y+qgXneTeUMbCqXJNpH1A== +"@eslint/eslintrc@^2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.2.tgz#c6936b4b328c64496692f76944e755738be62396" + integrity sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g== dependencies: ajv "^6.12.4" debug "^4.3.2" @@ -1032,10 +1032,10 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.44.0": - version "8.44.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.44.0.tgz#961a5903c74139390478bdc808bcde3fc45ab7af" - integrity sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw== +"@eslint/js@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.49.0.tgz#86f79756004a97fa4df866835093f1df3d03c333" + integrity sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w== "@fortaine/fetch-event-source@^3.0.6": version "3.0.6" @@ -1055,10 +1055,10 @@ redux "^4.2.1" use-memo-one "^1.1.3" -"@humanwhocodes/config-array@^0.11.10": - version "0.11.10" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.10.tgz#5a3ffe32cc9306365fb3fd572596cd602d5e12d2" - integrity sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ== +"@humanwhocodes/config-array@^0.11.11": + version "0.11.11" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.11.tgz#88a04c570dbbc7dd943e4712429c3df09bc32844" + integrity sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA== dependencies: "@humanwhocodes/object-schema" "^1.2.1" debug "^4.1.1" @@ -1221,10 +1221,10 @@ tiny-glob "^0.2.9" tslib "^2.4.0" -"@remix-run/router@1.7.1": - version "1.7.1" - resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.7.1.tgz#fea7ac35ae4014637c130011f59428f618730498" - integrity sha512-bgVQM4ZJ2u2CM8k1ey70o1ePFXsEzYVZoWghh6WjM8p59jQ7HxzbHW4SbnWFG7V9ig9chLawQxDTZ3xzOF8MkQ== +"@remix-run/router@1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.8.0.tgz#e848d2f669f601544df15ce2a313955e4bf0bafc" + integrity sha512-mrfKqIHnSZRyIzBcanNJmVQELTnX+qagEDlcKO90RgRBVOZGSGvZKeDihTRfWcqoDn5N/NkUcwWTccnpN18Tfg== "@rushstack/eslint-patch@^1.1.3": version "1.2.0" @@ -1779,7 +1779,7 @@ ajv-keywords@^3.5.2: resolved "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== -ajv@^6.10.0, ajv@^6.12.4, ajv@^6.12.5: +ajv@^6.12.4, ajv@^6.12.5: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -2762,10 +2762,10 @@ elkjs@^0.8.2: resolved "https://registry.npmmirror.com/elkjs/-/elkjs-0.8.2.tgz#c37763c5a3e24e042e318455e0147c912a7c248e" integrity sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ== -emoji-picker-react@^4.4.7: - version "4.4.8" - resolved "https://registry.yarnpkg.com/emoji-picker-react/-/emoji-picker-react-4.4.8.tgz#cd18e942720d0d01e3d488a008f5e79aa315ec87" - integrity sha512-5bbj0PCvpjB64PZj31wZ35EoebF2mKoHqEEx9u2ZLghx7sGoD1MgyDhse851rqROypjhmK9IUY15QBa7mCLP0g== +emoji-picker-react@^4.5.1: + version "4.5.1" + resolved "https://registry.yarnpkg.com/emoji-picker-react/-/emoji-picker-react-4.5.1.tgz#341f27dc86ad09340a316e0632484fcb9aff7195" + integrity sha512-zpm0ui0TWkXZDUIevyNM0rC9Jyqc08RvVXH0KgsbSkDr+VgMQmYLu6UeI4SIWMZKsKMjQwujPpncRCFlEeykjw== dependencies: clsx "^1.2.1" @@ -3050,40 +3050,40 @@ eslint-scope@5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-scope@^7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.0.tgz#f21ebdafda02352f103634b96dd47d9f81ca117b" - integrity sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw== +eslint-scope@^7.2.2: + version "7.2.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" + integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== dependencies: esrecurse "^4.3.0" estraverse "^5.2.0" -eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1: - version "3.4.1" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz#c22c48f48942d08ca824cc526211ae400478a994" - integrity sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA== +eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint@^8.44.0: - version "8.44.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.44.0.tgz#51246e3889b259bbcd1d7d736a0c10add4f0e500" - integrity sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A== +eslint@^8.49.0: + version "8.49.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.49.0.tgz#09d80a89bdb4edee2efcf6964623af1054bf6d42" + integrity sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ== dependencies: "@eslint-community/eslint-utils" "^4.2.0" - "@eslint-community/regexpp" "^4.4.0" - "@eslint/eslintrc" "^2.1.0" - "@eslint/js" "8.44.0" - "@humanwhocodes/config-array" "^0.11.10" + "@eslint-community/regexpp" "^4.6.1" + "@eslint/eslintrc" "^2.1.2" + "@eslint/js" "8.49.0" + "@humanwhocodes/config-array" "^0.11.11" "@humanwhocodes/module-importer" "^1.0.1" "@nodelib/fs.walk" "^1.2.8" - ajv "^6.10.0" + ajv "^6.12.4" chalk "^4.0.0" cross-spawn "^7.0.2" debug "^4.3.2" doctrine "^3.0.0" escape-string-regexp "^4.0.0" - eslint-scope "^7.2.0" - eslint-visitor-keys "^3.4.1" - espree "^9.6.0" + eslint-scope "^7.2.2" + eslint-visitor-keys "^3.4.3" + espree "^9.6.1" esquery "^1.4.2" esutils "^2.0.2" fast-deep-equal "^3.1.3" @@ -3093,7 +3093,6 @@ eslint@^8.44.0: globals "^13.19.0" graphemer "^1.4.0" ignore "^5.2.0" - import-fresh "^3.0.0" imurmurhash "^0.1.4" is-glob "^4.0.0" is-path-inside "^3.0.3" @@ -3105,13 +3104,12 @@ eslint@^8.44.0: natural-compare "^1.4.0" optionator "^0.9.3" strip-ansi "^6.0.1" - strip-json-comments "^3.1.0" text-table "^0.2.0" -espree@^9.6.0: - version "9.6.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.0.tgz#80869754b1c6560f32e3b6929194a3fe07c5b82f" - integrity sha512-1FH/IiruXZ84tpUlm0aCUEwMl2Ho5ilqVh0VvQXw+byAz/4SAciyHLlfmL5WYqsvD38oymdUwBss0LtK8m4s/A== +espree@^9.6.0, espree@^9.6.1: + version "9.6.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" + integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== dependencies: acorn "^8.9.0" acorn-jsx "^5.3.2" @@ -3635,7 +3633,7 @@ immutable@^4.0.0: resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.0.tgz#eb1738f14ffb39fd068b1dbe1296117484dd34be" integrity sha512-0AOCmOip+xgJwEVTQj1EfiDDOkPmuyllDuTuEX+DDXUgapLAsBIfkg3sxCYyCEA8mQqZrrxPUGjcOQ2JS3WLkg== -import-fresh@^3.0.0, import-fresh@^3.2.1: +import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== @@ -5092,20 +5090,20 @@ react-redux@^8.1.1: react-is "^18.0.0" use-sync-external-store "^1.0.0" -react-router-dom@^6.14.1: - version "6.14.1" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.14.1.tgz#0ad7ba7abdf75baa61169d49f096f0494907a36f" - integrity sha512-ssF6M5UkQjHK70fgukCJyjlda0Dgono2QGwqGvuk7D+EDGHdacEN3Yke2LTMjkrpHuFwBfDFsEjGVXBDmL+bWw== +react-router-dom@^6.15.0: + version "6.15.0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.15.0.tgz#6da7db61e56797266fbbef0d5e324d6ac443ee40" + integrity sha512-aR42t0fs7brintwBGAv2+mGlCtgtFQeOzK0BM1/OiqEzRejOZtpMZepvgkscpMUnKb8YO84G7s3LsHnnDNonbQ== dependencies: - "@remix-run/router" "1.7.1" - react-router "6.14.1" + "@remix-run/router" "1.8.0" + react-router "6.15.0" -react-router@6.14.1: - version "6.14.1" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.14.1.tgz#5e82bcdabf21add859dc04b1859f91066b3a5810" - integrity sha512-U4PfgvG55LdvbQjg5Y9QRWyVxIdO1LlpYT7x+tMAxd9/vmiPuJhIwdxZuIQLN/9e3O4KFDHYfR9gzGeYMasW8g== +react-router@6.15.0: + version "6.15.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.15.0.tgz#bf2cb5a4a7ed57f074d4ea88db0d95033f39cac8" + integrity sha512-NIytlzvzLwJkCQj2HLefmeakxxWHWAP+02EGqWEZy+DgfHHKQMUoBBjUQLOtFInBMhWtb3hiUy6MfFgwLjXhqg== dependencies: - "@remix-run/router" "1.7.1" + "@remix-run/router" "1.8.0" react@^18.2.0: version "18.2.0" @@ -5588,7 +5586,7 @@ strip-final-newline@^3.0.0: resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd" integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== -strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: +strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== @@ -5786,10 +5784,10 @@ typed-array-length@^1.0.4: for-each "^0.3.3" is-typed-array "^1.1.9" -typescript@4.9.5: - version "4.9.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +typescript@5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" + integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== unbox-primitive@^1.0.2: version "1.0.2"