From bf711f2ad7a6702452bfb7bf8a173d1dc74424e8 Mon Sep 17 00:00:00 2001 From: greenjerry Date: Fri, 2 Feb 2024 13:58:06 +0800 Subject: [PATCH 01/26] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=BC=E5=87=BAjson?= =?UTF-8?q?=E5=92=8Cmarkdown=E6=97=B6=E4=B8=AD=E6=96=87=E5=8F=8A=E5=85=B6?= =?UTF-8?q?=E4=BB=96utf8=E5=AD=97=E7=AC=A6=E4=B9=B1=E7=A0=81=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/global.d.ts | 1 + app/utils.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/global.d.ts b/app/global.d.ts index e0a2c3f06..31e2b6e8a 100644 --- a/app/global.d.ts +++ b/app/global.d.ts @@ -19,6 +19,7 @@ declare interface Window { }; fs: { writeBinaryFile(path: string, data: Uint8Array): Promise; + writeTextFile(path: string, data: string): Promise; }; notification:{ requestPermission(): Promise; diff --git a/app/utils.ts b/app/utils.ts index ac7e80e7a..06c0142bb 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -52,9 +52,9 @@ export async function downloadAs(text: string, filename: string) { if (result !== null) { try { - await window.__TAURI__.fs.writeBinaryFile( + await window.__TAURI__.fs.writeTextFile( result, - new Uint8Array([...text].map((c) => c.charCodeAt(0))) + text ); showToast(Locale.Download.Success); } catch (error) { From 22baebaf8c49f81bb22c39a7753ac6695d515166 Mon Sep 17 00:00:00 2001 From: H0llyW00dzZ Date: Wed, 21 Feb 2024 04:19:12 +0700 Subject: [PATCH 02/26] [Cherry Pick] Fix [Utils] Regex trimTopic - [+] fix(utils.ts): update regular expressions in trimTopic function to handle asterisks --- app/utils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/utils.ts b/app/utils.ts index 33b8eccd2..b502eab36 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -9,8 +9,9 @@ export function trimTopic(topic: string) { // This will remove the specified punctuation from the end of the string // and also trim quotes from both the start and end if they exist. return topic - .replace(/^["“”]+|["“”]+$/g, "") - .replace(/[,。!?”“"、,.!?]*$/, ""); + // fix for gemini + .replace(/^["“”*]+|["“”*]+$/g, "") + .replace(/[,。!?”“"、,.!?*]*$/, ""); } export async function copyToClipboard(text: string) { From c197962851be820469bf2e8624f9b6edd37b13e1 Mon Sep 17 00:00:00 2001 From: fengzai6 Date: Tue, 27 Feb 2024 15:02:58 +0800 Subject: [PATCH 03/26] fix: No history message attached when for gemini-pro-vision --- app/components/chat.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 9144f9a5f..32431c693 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -459,6 +459,10 @@ export function ChatActions(props: { if (!show) { props.setAttachImages([]); props.setUploading(false); + } else { + // 为visionModel时不附带历史消息 + const newModelConfig = chatStore.currentSession().mask.modelConfig; + newModelConfig.historyMessageCount = 0; } // if current model is not available From 43e5dc22920c60bf87fc1b78bf95c441356bb1d8 Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 28 Feb 2024 11:33:43 +0800 Subject: [PATCH 04/26] fix: fix the method to detect vision model --- app/client/platforms/openai.ts | 2 +- app/utils.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/client/platforms/openai.ts b/app/client/platforms/openai.ts index 437aff582..629158843 100644 --- a/app/client/platforms/openai.ts +++ b/app/client/platforms/openai.ts @@ -116,7 +116,7 @@ export class ChatGPTApi implements LLMApi { enumerable: true, configurable: true, writable: true, - value: Math.max(modelConfig.max_tokens, 4096), + value: modelConfig.max_tokens, }); } diff --git a/app/utils.ts b/app/utils.ts index 33b8eccd2..0436a128a 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -292,8 +292,8 @@ export function getMessageImages(message: RequestMessage): string[] { export function isVisionModel(model: string) { return ( - model.startsWith("gpt-4-vision") || - model.startsWith("gemini-pro-vision") || - !DEFAULT_MODELS.find((m) => m.name == model) + // model.startsWith("gpt-4-vision") || + // model.startsWith("gemini-pro-vision") || + model.includes("vision") ); } From bd19e97cf84755e4ac20c731bae292c2a09b76b7 Mon Sep 17 00:00:00 2001 From: Snow Kawashiro Date: Wed, 28 Feb 2024 20:05:13 +0800 Subject: [PATCH 05/26] add_image_pasting --- app/components/chat.tsx | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 9144f9a5f..22acb8e4f 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -1100,6 +1100,45 @@ function _Chat() { }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + + const handlePaste = useCallback( + async (event: React.ClipboardEvent) => { + const items = (event.clipboardData || window.clipboardData).items; + for (const item of items) { + if (item.kind === "file" && item.type.startsWith("image/")) { + event.preventDefault(); + const file = item.getAsFile(); + if (file) { + const images: string[] = []; + images.push(...attachImages); + images.push( + ...(await new Promise((res, rej) => { + setUploading(true); + const imagesData: string[] = []; + compressImage(file, 256 * 1024) + .then((dataUrl) => { + imagesData.push(dataUrl); + setUploading(false); + res(imagesData); + }) + .catch((e) => { + setUploading(false); + rej(e); + }); + })), + ); + const imagesLength = images.length; + + if (imagesLength > 3) { + images.splice(3, imagesLength - 3); + } + setAttachImages(images); + } + } + } + }, + [attachImages], + ); async function uploadImage() { const images: string[] = []; @@ -1449,6 +1488,7 @@ function _Chat() { onKeyDown={onInputKeyDown} onFocus={scrollToBottom} onClick={scrollToBottom} + onPaste={handlePaste} rows={inputRows} autoFocus={autoFocus} style={{ From e7051353eb8aff0e89a5e0a5da13cfcc5bcb4b6f Mon Sep 17 00:00:00 2001 From: Snow Kawashiro Date: Wed, 28 Feb 2024 20:38:00 +0800 Subject: [PATCH 06/26] vision_model_only --- app/components/chat.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 22acb8e4f..d730a4a10 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -1102,6 +1102,8 @@ function _Chat() { }, []); const handlePaste = useCallback( + const currentModel = chatStore.currentSession().mask.modelConfig.model; + if(!isVisionModel(currentModel)){return;} async (event: React.ClipboardEvent) => { const items = (event.clipboardData || window.clipboardData).items; for (const item of items) { @@ -1137,7 +1139,7 @@ function _Chat() { } } }, - [attachImages], + [attachImages, chatStore], ); async function uploadImage() { From 9775660da7a7fd6b9edc616c42def0dc69b534d4 Mon Sep 17 00:00:00 2001 From: Snow Kawashiro Date: Wed, 28 Feb 2024 20:45:42 +0800 Subject: [PATCH 07/26] Update chat.tsx --- app/components/chat.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/chat.tsx b/app/components/chat.tsx index d730a4a10..bcd0e605d 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -1102,9 +1102,9 @@ function _Chat() { }, []); const handlePaste = useCallback( - const currentModel = chatStore.currentSession().mask.modelConfig.model; - if(!isVisionModel(currentModel)){return;} async (event: React.ClipboardEvent) => { + const currentModel = chatStore.currentSession().mask.modelConfig.model; + if(!isVisionModel(currentModel)){return;} const items = (event.clipboardData || window.clipboardData).items; for (const item of items) { if (item.kind === "file" && item.type.startsWith("image/")) { From 86ae4b2a75b421dbaa05e33579a55f33d7c88a76 Mon Sep 17 00:00:00 2001 From: aliceric27 Date: Sat, 2 Mar 2024 23:58:23 +0800 Subject: [PATCH 08/26] slightly polishes the tw text. --- app/locales/tw.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/app/locales/tw.ts b/app/locales/tw.ts index 80e1b054f..89e1879bb 100644 --- a/app/locales/tw.ts +++ b/app/locales/tw.ts @@ -7,8 +7,8 @@ const tw = { WIP: "該功能仍在開發中……", Error: { Unauthorized: isApp - ? "檢測到無效 API Key,請前往[設定](/#/settings)頁檢查 API Key 是否配置正確。" - : "訪問密碼不正確或為空,請前往[登錄](/#/auth)頁輸入正確的訪問密碼,或者在[設定](/#/settings)頁填入你自己的 OpenAI API Key。", + ? "檢測到無效 API Key,請前往[設定](/#/settings)頁檢查 API Key 是否設定正確。" + : "訪問密碼不正確或為空,請前往[登入](/#/auth)頁輸入正確的訪問密碼,或者在[設定](/#/settings)頁填入你自己的 OpenAI API Key。", }, Auth: { @@ -17,7 +17,7 @@ const tw = { SubTips: "或者輸入你的 OpenAI 或 Google API 密鑰", Input: "在此處填寫訪問碼", Confirm: "確認", - Later: "稍後再說", + Later: "稍候再說", }, ChatItem: { ChatItemCount: (count: number) => `${count} 則對話`, @@ -53,8 +53,8 @@ const tw = { del: "刪除聊天", }, InputActions: { - Stop: "停止響應", - ToBottom: "滾到最新", + Stop: "停止回應", + ToBottom: "移至最新", Theme: { auto: "自動主題", light: "亮色模式", @@ -107,7 +107,7 @@ const tw = { }, }, Select: { - Search: "搜索消息", + Search: "查詢消息", All: "選取全部", Latest: "最近幾條", Clear: "清除選中", @@ -133,15 +133,15 @@ const tw = { Danger: { Reset: { Title: "重置所有設定", - SubTitle: "重置所有設定項回默認值", + SubTitle: "重置所有設定項回預設值", Action: "立即重置", Confirm: "確認重置所有設定?", }, Clear: { - Title: "清除所有數據", - SubTitle: "清除所有聊天、設定數據", + Title: "清除所有資料", + SubTitle: "清除所有聊天、設定資料", Action: "立即清除", - Confirm: "確認清除所有聊天、設定數據?", + Confirm: "確認清除所有聊天、設定資料?", }, }, Lang: { @@ -182,14 +182,14 @@ const tw = { SubTitle: "根據對話內容生成合適的標題", }, Sync: { - CloudState: "雲端數據", + CloudState: "雲端資料", NotSyncYet: "還沒有進行過同步", Success: "同步成功", Fail: "同步失敗", Config: { Modal: { - Title: "配置雲端同步", + Title: "設定雲端同步", Check: "檢查可用性", }, SyncType: { @@ -218,7 +218,7 @@ const tw = { }, }, - LocalState: "本地數據", + LocalState: "本地資料", Overview: (overview: any) => { return `${overview.chat} 次對話,${overview.message} 條消息,${overview.prompt} 條提示詞,${overview.mask} 個面具`; }, @@ -440,8 +440,8 @@ const tw = { More: "搜尋更多", }, URLCommand: { - Code: "檢測到鏈接中已經包含訪問碼,是否自動填入?", - Settings: "檢測到鏈接中包含了預制設置,是否自動填入?", + Code: "檢測到連結中已經包含訪問碼,是否自動填入?", + Settings: "檢測到連結中包含了預設設定,是否自動填入?", }, UI: { Confirm: "確認", @@ -452,7 +452,7 @@ const tw = { Export: "導出", Import: "導入", Sync: "同步", - Config: "配置", + Config: "設定", }, Exporter: { Description: { From e1066434d06ce8a12f7219a0604c1d65fd40e0fb Mon Sep 17 00:00:00 2001 From: aliceric27 Date: Sun, 3 Mar 2024 00:23:00 +0800 Subject: [PATCH 09/26] fix some text --- app/locales/tw.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/locales/tw.ts b/app/locales/tw.ts index 89e1879bb..b20ff6c80 100644 --- a/app/locales/tw.ts +++ b/app/locales/tw.ts @@ -385,7 +385,7 @@ const tw = { Edit: "前置上下文和歷史記憶", Add: "新增一條", Clear: "上下文已清除", - Revert: "恢覆上下文", + Revert: "恢復上下文", }, Plugin: { Name: "外掛" }, FineTuned: { Sysmessage: "你是一個助手" }, From e71094d4a8f3650265789c580b301218c4ade7d3 Mon Sep 17 00:00:00 2001 From: fred-bf <157469842+fred-bf@users.noreply.github.com> Date: Tue, 5 Mar 2024 17:36:52 +0800 Subject: [PATCH 10/26] chore: update GTM_ID definition, close #4217 --- app/config/server.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/config/server.ts b/app/config/server.ts index c455d0b73..dffc2563e 100644 --- a/app/config/server.ts +++ b/app/config/server.ts @@ -30,6 +30,9 @@ declare global { // google only GOOGLE_API_KEY?: string; GOOGLE_URL?: string; + + // google tag manager + GTM_ID?: string; } } } From 5348d570574b6c1e3bb829df66fed696d9549e28 Mon Sep 17 00:00:00 2001 From: fengzai6 Date: Thu, 7 Mar 2024 15:36:19 +0800 Subject: [PATCH 11/26] Fix EmojiPicker mobile width adaptation and update avatar clicking behavior --- app/components/emoji.tsx | 1 + app/components/settings.tsx | 4 +++- app/components/ui-lib.module.scss | 9 ++++++++- app/components/ui-lib.tsx | 8 ++++---- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/components/emoji.tsx b/app/components/emoji.tsx index b24349307..3b1f5e751 100644 --- a/app/components/emoji.tsx +++ b/app/components/emoji.tsx @@ -21,6 +21,7 @@ export function AvatarPicker(props: { }) { return (
setShowEmojiPicker(true)} + onClick={() => { + setShowEmojiPicker(!showEmojiPicker); + }} >
diff --git a/app/components/ui-lib.module.scss b/app/components/ui-lib.module.scss index c67d352be..83c02f92a 100644 --- a/app/components/ui-lib.module.scss +++ b/app/components/ui-lib.module.scss @@ -14,17 +14,24 @@ .popover-content { position: absolute; + width: 350px; animation: slide-in 0.3s ease; right: 0; top: calc(100% + 10px); } - +@media screen and (max-width: 600px) { + .popover-content { + width: auto; + } +} .popover-mask { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; + background-color: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(5px); } .list-item { diff --git a/app/components/ui-lib.tsx b/app/components/ui-lib.tsx index f7e326fd3..da700c0fb 100644 --- a/app/components/ui-lib.tsx +++ b/app/components/ui-lib.tsx @@ -26,10 +26,10 @@ export function Popover(props: {
{props.children} {props.open && ( -
-
- {props.content} -
+
+ )} + {props.open && ( +
{props.content}
)}
); From c22153a4eb3c663fb87ec9a1d77cda69946309a5 Mon Sep 17 00:00:00 2001 From: fengzai6 Date: Thu, 7 Mar 2024 15:46:13 +0800 Subject: [PATCH 12/26] Revert "fix: No history message attached when for gemini-pro-vision" This reverts commit c197962851be820469bf2e8624f9b6edd37b13e1. --- app/components/chat.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 42ceff093..bcd0e605d 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -459,10 +459,6 @@ export function ChatActions(props: { if (!show) { props.setAttachImages([]); props.setUploading(false); - } else { - // 为visionModel时不附带历史消息 - const newModelConfig = chatStore.currentSession().mask.modelConfig; - newModelConfig.historyMessageCount = 0; } // if current model is not available From ad10a119032b039c0341994ea1a480eb62d5c30e Mon Sep 17 00:00:00 2001 From: fengzai6 Date: Thu, 7 Mar 2024 15:51:58 +0800 Subject: [PATCH 13/26] Add z-index to avatar --- app/components/settings.module.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/components/settings.module.scss b/app/components/settings.module.scss index 1eac17c16..c6aec4203 100644 --- a/app/components/settings.module.scss +++ b/app/components/settings.module.scss @@ -5,6 +5,8 @@ .avatar { cursor: pointer; + position: relative; + z-index: 1; } .edit-prompt-modal { From 844c2a26bc3528e9a2f6601fa6e2ca0c050f987f Mon Sep 17 00:00:00 2001 From: SukkaW Date: Wed, 13 Mar 2024 13:30:16 +0800 Subject: [PATCH 14/26] chore: specify yarn 1 in package.json --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b31d6a901..c92e0a084 100644 --- a/package.json +++ b/package.json @@ -63,5 +63,6 @@ }, "resolutions": { "lint-staged/yaml": "^2.2.2" - } -} \ No newline at end of file + }, + "packageManager": "yarn@1.22.19" +} From 9a8497299d11706f096a4fc10ff0ab5af43465c7 Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 13 Mar 2024 23:58:28 +0800 Subject: [PATCH 15/26] fix: adjust upstash api --- app/api/upstash/[action]/[...key]/route.ts | 72 ++++++++++++++++++++++ app/utils/cloud/upstash.ts | 14 +++-- 2 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 app/api/upstash/[action]/[...key]/route.ts diff --git a/app/api/upstash/[action]/[...key]/route.ts b/app/api/upstash/[action]/[...key]/route.ts new file mode 100644 index 000000000..bcbdeef9d --- /dev/null +++ b/app/api/upstash/[action]/[...key]/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; + +async function handle( + req: NextRequest, + { params }: { params: { action: string; key: string[] } }, +) { + const requestUrl = new URL(req.url); + const endpoint = requestUrl.searchParams.get("endpoint"); + + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + const [action, ...key] = params.key; + // only allow to request to *.upstash.io + if (!endpoint || !endpoint.endsWith("upstash.io")) { + return NextResponse.json( + { + error: true, + msg: "you are not allowed to request " + params.key.join("/"), + }, + { + status: 403, + }, + ); + } + + // only allow upstash get and set method + if (action !== "get" && action !== "set") { + return NextResponse.json( + { + error: true, + msg: "you are not allowed to request " + params.action, + }, + { + status: 403, + }, + ); + } + + const [protocol, ...subpath] = params.key; + 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", + }; + + const fetchResult = await fetch(targetUrl, fetchOptions); + + console.log("[Any Proxy]", targetUrl, { + status: fetchResult.status, + statusText: fetchResult.statusText, + }); + + return fetchResult; +} + +export const POST = handle; +export const GET = handle; +export const OPTIONS = handle; + +export const runtime = "edge"; diff --git a/app/utils/cloud/upstash.ts b/app/utils/cloud/upstash.ts index 5f5b9fc79..1739b5a05 100644 --- a/app/utils/cloud/upstash.ts +++ b/app/utils/cloud/upstash.ts @@ -85,17 +85,21 @@ export function createUpstashClient(store: SyncStore) { }; }, path(path: string) { - let url = config.endpoint; + // let url = config.endpoint; - if (!url.endsWith("/")) { - url += "/"; + if (!path.endsWith("/")) { + path += "/"; } - if (path.startsWith("/")) { path = path.slice(1); } - return url + path; + let url = new URL("/api/" + path); + + // add query params + url.searchParams.append("endpoint", config.endpoint); + + return url.toString(); }, }; } From 038fa3b301794050ec7e59325aa00f25b3ce3257 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 14 Mar 2024 00:33:26 +0800 Subject: [PATCH 16/26] fix: add webdav request filter --- app/api/webdav/[...path]/route.ts | 103 ++++++++++++++++++++++++++++++ app/utils/cloud/upstash.ts | 2 +- app/utils/cloud/webdav.ts | 12 ++-- 3 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 app/api/webdav/[...path]/route.ts diff --git a/app/api/webdav/[...path]/route.ts b/app/api/webdav/[...path]/route.ts new file mode 100644 index 000000000..1ddd37761 --- /dev/null +++ b/app/api/webdav/[...path]/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { STORAGE_KEY } from "../../../constant"; +async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + const folder = STORAGE_KEY; + const fileName = `${folder}/backup.json`; + + const requestUrl = new URL(req.url); + const endpoint = requestUrl.searchParams.get("endpoint"); + + const [protocol, ...subpath] = params.path; + + const endpointPath = subpath.join("/"); + + // only allow MKCOL, GET, PUT + if (req.method !== "MKCOL" && req.method !== "GET" && req.method !== "PUT") { + return NextResponse.json( + { + error: true, + msg: "you are not allowed to request " + params.path.join("/"), + }, + { + status: 403, + }, + ); + } + + // for MKCOL request, only allow request ${folder} + if (req.method == "MKCOL" && !endpointPath.endsWith(folder)) { + return NextResponse.json( + { + error: true, + msg: "you are not allowed to request " + params.path.join("/"), + }, + { + status: 403, + }, + ); + } + + // for GET request, only allow request ending with fileName + if (req.method == "GET" && !endpointPath.endsWith(fileName)) { + return NextResponse.json( + { + error: true, + msg: "you are not allowed to request " + params.path.join("/"), + }, + { + status: 403, + }, + ); + } + + // for PUT request, only allow request ending with fileName + if (req.method == "PUT" && !endpointPath.endsWith(fileName)) { + return NextResponse.json( + { + error: true, + msg: "you are not allowed to request " + params.path.join("/"), + }, + { + status: 403, + }, + ); + } + + const targetUrl = `${protocol}://${endpoint + endpointPath}`; + + 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", + }; + + const fetchResult = await fetch(targetUrl, fetchOptions); + + console.log("[Any Proxy]", targetUrl, { + status: fetchResult.status, + statusText: fetchResult.statusText, + }); + + return fetchResult; +} + +export const POST = handle; +export const GET = handle; +export const OPTIONS = handle; + +export const runtime = "edge"; diff --git a/app/utils/cloud/upstash.ts b/app/utils/cloud/upstash.ts index 1739b5a05..02af76633 100644 --- a/app/utils/cloud/upstash.ts +++ b/app/utils/cloud/upstash.ts @@ -94,7 +94,7 @@ export function createUpstashClient(store: SyncStore) { path = path.slice(1); } - let url = new URL("/api/" + path); + let url = new URL("/api/upstash/" + path); // add query params url.searchParams.append("endpoint", config.endpoint); diff --git a/app/utils/cloud/webdav.ts b/app/utils/cloud/webdav.ts index 3a1553c10..9efa80c69 100644 --- a/app/utils/cloud/webdav.ts +++ b/app/utils/cloud/webdav.ts @@ -60,16 +60,18 @@ export function createWebDavClient(store: SyncStore) { }; }, path(path: string) { - let url = config.endpoint; - - if (!url.endsWith("/")) { - url += "/"; + if (!path.endsWith("/")) { + path += "/"; } - if (path.startsWith("/")) { path = path.slice(1); } + let url = new URL("/api/webdav/" + path); + + // add query params + url.searchParams.append("endpoint", config.endpoint); + return url + path; }, }; From eebc334e02e9f5d9f83203c97fbf4622a9141d0a Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 14 Mar 2024 00:57:54 +0800 Subject: [PATCH 17/26] fix: remove corsFetch --- app/api/cors/[...path]/route.ts | 43 --------------------------------- app/utils/cloud/upstash.ts | 18 +++++++------- app/utils/cloud/webdav.ts | 17 +++++++------ app/utils/cors.ts | 34 -------------------------- 4 files changed, 18 insertions(+), 94 deletions(-) delete mode 100644 app/api/cors/[...path]/route.ts diff --git a/app/api/cors/[...path]/route.ts b/app/api/cors/[...path]/route.ts deleted file mode 100644 index 1f70d6630..000000000 --- a/app/api/cors/[...path]/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -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", - }; - - const fetchResult = await fetch(targetUrl, fetchOptions); - - console.log("[Any Proxy]", targetUrl, { - status: fetchResult.status, - statusText: fetchResult.statusText, - }); - - return fetchResult; -} - -export const POST = handle; -export const GET = handle; -export const OPTIONS = handle; - -export const runtime = "edge"; diff --git a/app/utils/cloud/upstash.ts b/app/utils/cloud/upstash.ts index 02af76633..831aa8902 100644 --- a/app/utils/cloud/upstash.ts +++ b/app/utils/cloud/upstash.ts @@ -1,6 +1,5 @@ import { STORAGE_KEY } from "@/app/constant"; import { SyncStore } from "@/app/store/sync"; -import { corsFetch } from "../cors"; import { chunks } from "../format"; export type UpstashConfig = SyncStore["upstash"]; @@ -18,10 +17,9 @@ export function createUpstashClient(store: SyncStore) { return { async check() { try { - const res = await corsFetch(this.path(`get/${storeKey}`), { + const res = await fetch(this.path(`get/${storeKey}`, proxyUrl), { method: "GET", headers: this.headers(), - proxyUrl, }); console.log("[Upstash] check", res.status, res.statusText); return [200].includes(res.status); @@ -32,10 +30,9 @@ export function createUpstashClient(store: SyncStore) { }, async redisGet(key: string) { - const res = await corsFetch(this.path(`get/${key}`), { + const res = await fetch(this.path(`get/${key}`, proxyUrl), { method: "GET", headers: this.headers(), - proxyUrl, }); console.log("[Upstash] get key = ", key, res.status, res.statusText); @@ -45,11 +42,10 @@ export function createUpstashClient(store: SyncStore) { }, async redisSet(key: string, value: string) { - const res = await corsFetch(this.path(`set/${key}`), { + const res = await fetch(this.path(`set/${key}`, proxyUrl), { method: "POST", headers: this.headers(), body: value, - proxyUrl, }); console.log("[Upstash] set key = ", key, res.status, res.statusText); @@ -84,7 +80,7 @@ export function createUpstashClient(store: SyncStore) { Authorization: `Bearer ${config.apiKey}`, }; }, - path(path: string) { + path(path: string, proxyUrl: string = "") { // let url = config.endpoint; if (!path.endsWith("/")) { @@ -94,7 +90,11 @@ export function createUpstashClient(store: SyncStore) { path = path.slice(1); } - let url = new URL("/api/upstash/" + path); + if (proxyUrl.length > 0 && !proxyUrl.endsWith("/")) { + proxyUrl += "/"; + } + + let url = new URL(proxyUrl + "/api/upstash/" + path); // add query params url.searchParams.append("endpoint", config.endpoint); diff --git a/app/utils/cloud/webdav.ts b/app/utils/cloud/webdav.ts index 9efa80c69..6874302b8 100644 --- a/app/utils/cloud/webdav.ts +++ b/app/utils/cloud/webdav.ts @@ -15,10 +15,9 @@ export function createWebDavClient(store: SyncStore) { return { async check() { try { - const res = await corsFetch(this.path(folder), { + const res = await fetch(this.path(folder, proxyUrl), { method: "MKCOL", headers: this.headers(), - proxyUrl, }); console.log("[WebDav] check", res.status, res.statusText); return [201, 200, 404, 301, 302, 307, 308].includes(res.status); @@ -30,10 +29,9 @@ export function createWebDavClient(store: SyncStore) { }, async get(key: string) { - const res = await corsFetch(this.path(fileName), { + const res = await fetch(this.path(fileName, proxyUrl), { method: "GET", headers: this.headers(), - proxyUrl, }); console.log("[WebDav] get key = ", key, res.status, res.statusText); @@ -42,11 +40,10 @@ export function createWebDavClient(store: SyncStore) { }, async set(key: string, value: string) { - const res = await corsFetch(this.path(fileName), { + const res = await fetch(this.path(fileName, proxyUrl), { method: "PUT", headers: this.headers(), body: value, - proxyUrl, }); console.log("[WebDav] set key = ", key, res.status, res.statusText); @@ -59,7 +56,7 @@ export function createWebDavClient(store: SyncStore) { authorization: `Basic ${auth}`, }; }, - path(path: string) { + path(path: string, proxyUrl: string = "") { if (!path.endsWith("/")) { path += "/"; } @@ -67,7 +64,11 @@ export function createWebDavClient(store: SyncStore) { path = path.slice(1); } - let url = new URL("/api/webdav/" + path); + if (proxyUrl.length > 0 && !proxyUrl.endsWith("/")) { + proxyUrl += "/"; + } + + let url = new URL(proxyUrl + "/api/webdav/" + path); // add query params url.searchParams.append("endpoint", config.endpoint); diff --git a/app/utils/cors.ts b/app/utils/cors.ts index 20b3e5160..93956a7b5 100644 --- a/app/utils/cors.ts +++ b/app/utils/cors.ts @@ -14,37 +14,3 @@ export function corsPath(path: string) { 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); -} From 86452146540a224a3242238dd07964a26b8df246 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 14 Mar 2024 01:22:50 +0800 Subject: [PATCH 18/26] fix: change matching pattern --- app/api/upstash/[action]/[...key]/route.ts | 2 +- app/api/webdav/[...path]/route.ts | 15 ++++++++++++--- app/utils/cloud/webdav.ts | 1 - 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/api/upstash/[action]/[...key]/route.ts b/app/api/upstash/[action]/[...key]/route.ts index bcbdeef9d..6be243c92 100644 --- a/app/api/upstash/[action]/[...key]/route.ts +++ b/app/api/upstash/[action]/[...key]/route.ts @@ -12,7 +12,7 @@ async function handle( } const [action, ...key] = params.key; // only allow to request to *.upstash.io - if (!endpoint || !endpoint.endsWith("upstash.io")) { + if (!endpoint || !new URL(endpoint).hostname.endsWith(".upstash.io")) { return NextResponse.json( { error: true, diff --git a/app/api/webdav/[...path]/route.ts b/app/api/webdav/[...path]/route.ts index 1ddd37761..cade9ab51 100644 --- a/app/api/webdav/[...path]/route.ts +++ b/app/api/webdav/[...path]/route.ts @@ -31,7 +31,10 @@ async function handle( } // for MKCOL request, only allow request ${folder} - if (req.method == "MKCOL" && !endpointPath.endsWith(folder)) { + if ( + req.method == "MKCOL" && + !new URL(endpointPath).pathname.endsWith(folder) + ) { return NextResponse.json( { error: true, @@ -44,7 +47,10 @@ async function handle( } // for GET request, only allow request ending with fileName - if (req.method == "GET" && !endpointPath.endsWith(fileName)) { + if ( + req.method == "GET" && + !new URL(endpointPath).pathname.endsWith(fileName) + ) { return NextResponse.json( { error: true, @@ -57,7 +63,10 @@ async function handle( } // for PUT request, only allow request ending with fileName - if (req.method == "PUT" && !endpointPath.endsWith(fileName)) { + if ( + req.method == "PUT" && + !new URL(endpointPath).pathname.endsWith(fileName) + ) { return NextResponse.json( { error: true, diff --git a/app/utils/cloud/webdav.ts b/app/utils/cloud/webdav.ts index 6874302b8..79fff9472 100644 --- a/app/utils/cloud/webdav.ts +++ b/app/utils/cloud/webdav.ts @@ -1,6 +1,5 @@ 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; From 133ce39a13cb90733bc0aac220ea179e34fd4430 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 14 Mar 2024 01:33:41 +0800 Subject: [PATCH 19/26] chore: update cors default path --- app/constant.ts | 2 +- app/utils/cloud/upstash.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/constant.ts b/app/constant.ts index c1f91d31c..904170687 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -23,7 +23,7 @@ export enum Path { } export enum ApiPath { - Cors = "/api/cors", + Cors = "", OpenAI = "/api/openai", } diff --git a/app/utils/cloud/upstash.ts b/app/utils/cloud/upstash.ts index 831aa8902..f5579cea0 100644 --- a/app/utils/cloud/upstash.ts +++ b/app/utils/cloud/upstash.ts @@ -81,8 +81,6 @@ export function createUpstashClient(store: SyncStore) { }; }, path(path: string, proxyUrl: string = "") { - // let url = config.endpoint; - if (!path.endsWith("/")) { path += "/"; } From 6aaf83f3c211b3efea63d20f39a58f0c1ab6fa17 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 14 Mar 2024 01:56:36 +0800 Subject: [PATCH 20/26] fix: fix upstash sync issue --- app/api/upstash/[action]/[...key]/route.ts | 11 ++++++----- app/api/webdav/[...path]/route.ts | 2 +- app/utils/cloud/upstash.ts | 16 ++++++++++------ app/utils/cloud/webdav.ts | 16 ++++++++++------ 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/app/api/upstash/[action]/[...key]/route.ts b/app/api/upstash/[action]/[...key]/route.ts index 6be243c92..fcfef4718 100644 --- a/app/api/upstash/[action]/[...key]/route.ts +++ b/app/api/upstash/[action]/[...key]/route.ts @@ -10,7 +10,7 @@ async function handle( if (req.method === "OPTIONS") { return NextResponse.json({ body: "OK" }, { status: 200 }); } - const [action, ...key] = params.key; + const [...key] = params.key; // only allow to request to *.upstash.io if (!endpoint || !new URL(endpoint).hostname.endsWith(".upstash.io")) { return NextResponse.json( @@ -25,7 +25,8 @@ async function handle( } // only allow upstash get and set method - if (action !== "get" && action !== "set") { + if (params.action !== "get" && params.action !== "set") { + console.log("[Upstash Route] forbidden action ", params.action); return NextResponse.json( { error: true, @@ -37,10 +38,9 @@ async function handle( ); } - const [protocol, ...subpath] = params.key; - const targetUrl = `${protocol}://${subpath.join("/")}`; + const targetUrl = `${endpoint}/${params.action}/${params.key.join("/")}`; - const method = req.headers.get("method") ?? undefined; + const method = req.method; const shouldNotHaveBody = ["get", "head"].includes( method?.toLowerCase() ?? "", ); @@ -55,6 +55,7 @@ async function handle( duplex: "half", }; + console.log("[Upstash Proxy]", targetUrl, fetchOptions); const fetchResult = await fetch(targetUrl, fetchOptions); console.log("[Any Proxy]", targetUrl, { diff --git a/app/api/webdav/[...path]/route.ts b/app/api/webdav/[...path]/route.ts index cade9ab51..826e2df01 100644 --- a/app/api/webdav/[...path]/route.ts +++ b/app/api/webdav/[...path]/route.ts @@ -80,7 +80,7 @@ async function handle( const targetUrl = `${protocol}://${endpoint + endpointPath}`; - const method = req.headers.get("method") ?? undefined; + const method = req.method; const shouldNotHaveBody = ["get", "head"].includes( method?.toLowerCase() ?? "", ); diff --git a/app/utils/cloud/upstash.ts b/app/utils/cloud/upstash.ts index f5579cea0..bf6147bd4 100644 --- a/app/utils/cloud/upstash.ts +++ b/app/utils/cloud/upstash.ts @@ -92,12 +92,16 @@ export function createUpstashClient(store: SyncStore) { proxyUrl += "/"; } - let url = new URL(proxyUrl + "/api/upstash/" + path); - - // add query params - url.searchParams.append("endpoint", config.endpoint); - - return url.toString(); + let url; + if (proxyUrl.length > 0 || proxyUrl === "/") { + let u = new URL(proxyUrl + "/api/upstash/" + path); + // add query params + u.searchParams.append("endpoint", config.endpoint); + url = u.toString(); + } else { + url = "/api/upstash/" + path + "?endpoint=" + config.endpoint; + } + return url; }, }; } diff --git a/app/utils/cloud/webdav.ts b/app/utils/cloud/webdav.ts index 79fff9472..bc569de0e 100644 --- a/app/utils/cloud/webdav.ts +++ b/app/utils/cloud/webdav.ts @@ -67,12 +67,16 @@ export function createWebDavClient(store: SyncStore) { proxyUrl += "/"; } - let url = new URL(proxyUrl + "/api/webdav/" + path); - - // add query params - url.searchParams.append("endpoint", config.endpoint); - - return url + path; + let url; + if (proxyUrl.length > 0 || proxyUrl === "/") { + let u = new URL(proxyUrl + "/api/webdav/" + path); + // add query params + u.searchParams.append("endpoint", config.endpoint); + url = u.toString(); + } else { + url = "/api/upstash/" + path + "?endpoint=" + config.endpoint; + } + return url; }, }; } From 99aa064319991b6ee53eb9c75bcfeb5a6b0188cb Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 14 Mar 2024 01:58:25 +0800 Subject: [PATCH 21/26] fix: fix webdav sync issue --- app/api/webdav/[...path]/route.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/api/webdav/[...path]/route.ts b/app/api/webdav/[...path]/route.ts index 826e2df01..c60ca18bb 100644 --- a/app/api/webdav/[...path]/route.ts +++ b/app/api/webdav/[...path]/route.ts @@ -11,11 +11,11 @@ async function handle( const fileName = `${folder}/backup.json`; const requestUrl = new URL(req.url); - const endpoint = requestUrl.searchParams.get("endpoint"); - - const [protocol, ...subpath] = params.path; - - const endpointPath = subpath.join("/"); + let endpoint = requestUrl.searchParams.get("endpoint"); + if (!endpoint?.endsWith("/")) { + endpoint += "/"; + } + const endpointPath = params.path.join("/"); // only allow MKCOL, GET, PUT if (req.method !== "MKCOL" && req.method !== "GET" && req.method !== "PUT") { @@ -78,7 +78,7 @@ async function handle( ); } - const targetUrl = `${protocol}://${endpoint + endpointPath}`; + const targetUrl = `${endpoint + endpointPath}`; const method = req.method; const shouldNotHaveBody = ["get", "head"].includes( From aec3c5d6cc598282e1f35b9e1de5081190a9c378 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 14 Mar 2024 02:29:31 +0800 Subject: [PATCH 22/26] feat: bump version --- src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 405d267ff..f03efb0fe 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -9,7 +9,7 @@ }, "package": { "productName": "NextChat", - "version": "2.11.2" + "version": "2.11.3" }, "tauri": { "allowlist": { From 066ca9e552f5f455bb9456994361c6ac9e08297c Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 14 Mar 2024 03:03:46 +0800 Subject: [PATCH 23/26] fix: auto migrate proxy config --- app/store/sync.ts | 11 ++++++++++- app/utils/cors.ts | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/store/sync.ts b/app/store/sync.ts index 5ff1cc6e5..674ff6744 100644 --- a/app/store/sync.ts +++ b/app/store/sync.ts @@ -118,7 +118,7 @@ export const useSyncStore = createPersistStore( }), { name: StoreKey.Sync, - version: 1.1, + version: 1.2, migrate(persistedState, version) { const newState = persistedState as typeof DEFAULT_SYNC_STATE; @@ -127,6 +127,15 @@ export const useSyncStore = createPersistStore( newState.upstash.username = STORAGE_KEY; } + if (version < 1.2) { + if ( + (persistedState as typeof DEFAULT_SYNC_STATE).proxyUrl === + "/api/cors/" + ) { + newState.proxyUrl = ""; + } + } + return newState as any; }, }, diff --git a/app/utils/cors.ts b/app/utils/cors.ts index 93956a7b5..fa348f9bf 100644 --- a/app/utils/cors.ts +++ b/app/utils/cors.ts @@ -4,6 +4,9 @@ import { ApiPath, DEFAULT_API_HOST } from "../constant"; export function corsPath(path: string) { const baseUrl = getClientConfig()?.isApp ? `${DEFAULT_API_HOST}` : ""; + if (baseUrl === "" && path === "") { + return ""; + } if (!path.startsWith("/")) { path = "/" + path; } From a4c54cae60820a8a026481b91a11a9e7a842699c Mon Sep 17 00:00:00 2001 From: H0llyW00dzZ Date: Fri, 15 Mar 2024 09:33:21 +0700 Subject: [PATCH 24/26] Improve [Utils] Check Vision Model - [+] refactor(utils.ts): improve isVisionModel function to use array.some instead of model.includes --- app/utils.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/utils.ts b/app/utils.ts index 8b755afea..b4fc1980c 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -292,9 +292,11 @@ export function getMessageImages(message: RequestMessage): string[] { } export function isVisionModel(model: string) { - return ( - // model.startsWith("gpt-4-vision") || - // model.startsWith("gemini-pro-vision") || - model.includes("vision") - ); + // Note: This is a better way using the TypeScript feature instead of `&&` or `||` (ts v5.5.0-dev.20240314 I've been using) + const visionKeywords = [ + "vision", + "claude-3", + ]; + + return visionKeywords.some(keyword => model.includes(keyword)); } From 028957fcdcb0ec8860622237fc0923a67445c38a Mon Sep 17 00:00:00 2001 From: Raax Date: Sat, 16 Mar 2024 21:55:16 +0800 Subject: [PATCH 25/26] Fix "Enter" bug Fix Chinese input method "Enter" on Safari --- app/components/chat.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/components/chat.tsx b/app/components/chat.tsx index bcd0e605d..b9750f285 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -219,6 +219,8 @@ function useSubmitHandler() { }, []); const shouldSubmit = (e: React.KeyboardEvent) => { + // Fix Chinese input method "Enter" on Safari + if (e.keyCode == 229) return false; if (e.key !== "Enter") return false; if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current)) return false; From 9fd750511c86ef7d45b9a8d304fc98495a2ec252 Mon Sep 17 00:00:00 2001 From: fred-bf <157469842+fred-bf@users.noreply.github.com> Date: Mon, 18 Mar 2024 18:24:48 +0800 Subject: [PATCH 26/26] feat: update vercel deploy env --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3ac537abc..0398a9bfe 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 [MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple [Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu -[![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&env=GOOGLE_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/ZBUEFA)