Merge branch 'main' into feat/voice-input

This commit is contained in:
fred-bf
2024-03-19 17:52:00 +08:00
committed by GitHub
22 changed files with 336 additions and 142 deletions

View File

@@ -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 [MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple
[Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu [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) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/ZBUEFA)

View File

@@ -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";

View File

@@ -0,0 +1,73 @@
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 [...key] = params.key;
// only allow to request to *.upstash.io
if (!endpoint || !new URL(endpoint).hostname.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 (params.action !== "get" && params.action !== "set") {
console.log("[Upstash Route] forbidden action ", params.action);
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + params.action,
},
{
status: 403,
},
);
}
const targetUrl = `${endpoint}/${params.action}/${params.key.join("/")}`;
const method = req.method;
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("[Upstash Proxy]", targetUrl, fetchOptions);
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";

View File

@@ -0,0 +1,112 @@
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);
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") {
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" &&
!new URL(endpointPath).pathname.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" &&
!new URL(endpointPath).pathname.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" &&
!new URL(endpointPath).pathname.endsWith(fileName)
) {
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + params.path.join("/"),
},
{
status: 403,
},
);
}
const targetUrl = `${endpoint + endpointPath}`;
const method = req.method;
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";

View File

@@ -116,7 +116,7 @@ export class ChatGPTApi implements LLMApi {
enumerable: true, enumerable: true,
configurable: true, configurable: true,
writable: true, writable: true,
value: Math.max(modelConfig.max_tokens, 4096), value: modelConfig.max_tokens,
}); });
} }

View File

@@ -219,6 +219,8 @@ function useSubmitHandler() {
}, []); }, []);
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Fix Chinese input method "Enter" on Safari
if (e.keyCode == 229) return false;
if (e.key !== "Enter") return false; if (e.key !== "Enter") return false;
if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current)) if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current))
return false; return false;
@@ -1098,6 +1100,47 @@ function _Chat() {
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const handlePaste = useCallback(
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
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/")) {
event.preventDefault();
const file = item.getAsFile();
if (file) {
const images: string[] = [];
images.push(...attachImages);
images.push(
...(await new Promise<string[]>((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, chatStore],
);
async function uploadImage() { async function uploadImage() {
const images: string[] = []; const images: string[] = [];
@@ -1448,6 +1491,7 @@ function _Chat() {
onKeyDown={onInputKeyDown} onKeyDown={onInputKeyDown}
onFocus={scrollToBottom} onFocus={scrollToBottom}
onClick={scrollToBottom} onClick={scrollToBottom}
onPaste={handlePaste}
rows={inputRows} rows={inputRows}
autoFocus={autoFocus} autoFocus={autoFocus}
style={{ style={{

View File

@@ -21,6 +21,7 @@ export function AvatarPicker(props: {
}) { }) {
return ( return (
<EmojiPicker <EmojiPicker
width={"100%"}
lazyLoadEmojis lazyLoadEmojis
theme={EmojiTheme.AUTO} theme={EmojiTheme.AUTO}
getEmojiUrl={getEmojiUrl} getEmojiUrl={getEmojiUrl}

View File

@@ -5,6 +5,8 @@
.avatar { .avatar {
cursor: pointer; cursor: pointer;
position: relative;
z-index: 1;
} }
.edit-prompt-modal { .edit-prompt-modal {

View File

@@ -693,7 +693,9 @@ export function Settings() {
> >
<div <div
className={styles.avatar} className={styles.avatar}
onClick={() => setShowEmojiPicker(true)} onClick={() => {
setShowEmojiPicker(!showEmojiPicker);
}}
> >
<Avatar avatar={config.avatar} /> <Avatar avatar={config.avatar} />
</div> </div>

View File

@@ -14,17 +14,24 @@
.popover-content { .popover-content {
position: absolute; position: absolute;
width: 350px;
animation: slide-in 0.3s ease; animation: slide-in 0.3s ease;
right: 0; right: 0;
top: calc(100% + 10px); top: calc(100% + 10px);
} }
@media screen and (max-width: 600px) {
.popover-content {
width: auto;
}
}
.popover-mask { .popover-mask {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background-color: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(5px);
} }
.list-item { .list-item {

View File

@@ -26,10 +26,10 @@ export function Popover(props: {
<div className={styles.popover}> <div className={styles.popover}>
{props.children} {props.children}
{props.open && ( {props.open && (
<div className={styles["popover-content"]}> <div className={styles["popover-mask"]} onClick={props.onClose}></div>
<div className={styles["popover-mask"]} onClick={props.onClose}></div> )}
{props.content} {props.open && (
</div> <div className={styles["popover-content"]}>{props.content}</div>
)} )}
</div> </div>
); );

View File

@@ -31,6 +31,7 @@ declare global {
GOOGLE_API_KEY?: string; GOOGLE_API_KEY?: string;
GOOGLE_URL?: string; GOOGLE_URL?: string;
// google tag manager
GTM_ID?: string; GTM_ID?: string;
} }
} }

View File

@@ -23,7 +23,7 @@ export enum Path {
} }
export enum ApiPath { export enum ApiPath {
Cors = "/api/cors", Cors = "",
OpenAI = "/api/openai", OpenAI = "/api/openai",
} }

1
app/global.d.ts vendored
View File

@@ -19,6 +19,7 @@ declare interface Window {
}; };
fs: { fs: {
writeBinaryFile(path: string, data: Uint8Array): Promise<void>; writeBinaryFile(path: string, data: Uint8Array): Promise<void>;
writeTextFile(path: string, data: string): Promise<void>;
}; };
notification:{ notification:{
requestPermission(): Promise<Permission>; requestPermission(): Promise<Permission>;

View File

@@ -7,8 +7,8 @@ const tw = {
WIP: "該功能仍在開發中……", WIP: "該功能仍在開發中……",
Error: { Error: {
Unauthorized: isApp Unauthorized: isApp
? "檢測到無效 API Key請前往[設定](/#/settings)頁檢查 API Key 是否配置正確。" ? "檢測到無效 API Key請前往[設定](/#/settings)頁檢查 API Key 是否設定正確。"
: "訪問密碼不正確或為空,請前往[登](/#/auth)頁輸入正確的訪問密碼,或者在[設定](/#/settings)頁填入你自己的 OpenAI API Key。", : "訪問密碼不正確或為空,請前往[登](/#/auth)頁輸入正確的訪問密碼,或者在[設定](/#/settings)頁填入你自己的 OpenAI API Key。",
}, },
Auth: { Auth: {
@@ -17,7 +17,7 @@ const tw = {
SubTips: "或者輸入你的 OpenAI 或 Google API 密鑰", SubTips: "或者輸入你的 OpenAI 或 Google API 密鑰",
Input: "在此處填寫訪問碼", Input: "在此處填寫訪問碼",
Confirm: "確認", Confirm: "確認",
Later: "稍再說", Later: "稍再說",
}, },
ChatItem: { ChatItem: {
ChatItemCount: (count: number) => `${count} 則對話`, ChatItemCount: (count: number) => `${count} 則對話`,
@@ -53,8 +53,8 @@ const tw = {
del: "刪除聊天", del: "刪除聊天",
}, },
InputActions: { InputActions: {
Stop: "停止應", Stop: "停止應",
ToBottom: "滾到最新", ToBottom: "移至最新",
Theme: { Theme: {
auto: "自動主題", auto: "自動主題",
light: "亮色模式", light: "亮色模式",
@@ -107,7 +107,7 @@ const tw = {
}, },
}, },
Select: { Select: {
Search: "搜索消息", Search: "查詢消息",
All: "選取全部", All: "選取全部",
Latest: "最近幾條", Latest: "最近幾條",
Clear: "清除選中", Clear: "清除選中",
@@ -133,15 +133,15 @@ const tw = {
Danger: { Danger: {
Reset: { Reset: {
Title: "重置所有設定", Title: "重置所有設定",
SubTitle: "重置所有設定項回默認值", SubTitle: "重置所有設定項回預設值",
Action: "立即重置", Action: "立即重置",
Confirm: "確認重置所有設定?", Confirm: "確認重置所有設定?",
}, },
Clear: { Clear: {
Title: "清除所有數據", Title: "清除所有資料",
SubTitle: "清除所有聊天、設定數據", SubTitle: "清除所有聊天、設定資料",
Action: "立即清除", Action: "立即清除",
Confirm: "確認清除所有聊天、設定數據", Confirm: "確認清除所有聊天、設定資料",
}, },
}, },
Lang: { Lang: {
@@ -182,14 +182,14 @@ const tw = {
SubTitle: "根據對話內容生成合適的標題", SubTitle: "根據對話內容生成合適的標題",
}, },
Sync: { Sync: {
CloudState: "雲端數據", CloudState: "雲端資料",
NotSyncYet: "還沒有進行過同步", NotSyncYet: "還沒有進行過同步",
Success: "同步成功", Success: "同步成功",
Fail: "同步失敗", Fail: "同步失敗",
Config: { Config: {
Modal: { Modal: {
Title: "配置雲端同步", Title: "設定雲端同步",
Check: "檢查可用性", Check: "檢查可用性",
}, },
SyncType: { SyncType: {
@@ -218,7 +218,7 @@ const tw = {
}, },
}, },
LocalState: "本地數據", LocalState: "本地資料",
Overview: (overview: any) => { Overview: (overview: any) => {
return `${overview.chat} 次對話,${overview.message} 條消息,${overview.prompt} 條提示詞,${overview.mask} 個面具`; return `${overview.chat} 次對話,${overview.message} 條消息,${overview.prompt} 條提示詞,${overview.mask} 個面具`;
}, },
@@ -385,7 +385,7 @@ const tw = {
Edit: "前置上下文和歷史記憶", Edit: "前置上下文和歷史記憶",
Add: "新增一條", Add: "新增一條",
Clear: "上下文已清除", Clear: "上下文已清除",
Revert: "恢上下文", Revert: "恢上下文",
}, },
Plugin: { Name: "外掛" }, Plugin: { Name: "外掛" },
FineTuned: { Sysmessage: "你是一個助手" }, FineTuned: { Sysmessage: "你是一個助手" },
@@ -440,8 +440,8 @@ const tw = {
More: "搜尋更多", More: "搜尋更多",
}, },
URLCommand: { URLCommand: {
Code: "檢測到鏈接中已經包含訪問碼,是否自動填入?", Code: "檢測到連結中已經包含訪問碼,是否自動填入?",
Settings: "檢測到鏈接中包含了預制設置,是否自動填入?", Settings: "檢測到連結中包含了預設設定,是否自動填入?",
}, },
UI: { UI: {
Confirm: "確認", Confirm: "確認",
@@ -452,7 +452,7 @@ const tw = {
Export: "導出", Export: "導出",
Import: "導入", Import: "導入",
Sync: "同步", Sync: "同步",
Config: "配置", Config: "設定",
}, },
Exporter: { Exporter: {
Description: { Description: {

View File

@@ -118,7 +118,7 @@ export const useSyncStore = createPersistStore(
}), }),
{ {
name: StoreKey.Sync, name: StoreKey.Sync,
version: 1.1, version: 1.2,
migrate(persistedState, version) { migrate(persistedState, version) {
const newState = persistedState as typeof DEFAULT_SYNC_STATE; const newState = persistedState as typeof DEFAULT_SYNC_STATE;
@@ -127,6 +127,15 @@ export const useSyncStore = createPersistStore(
newState.upstash.username = STORAGE_KEY; 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; return newState as any;
}, },
}, },

View File

@@ -9,8 +9,9 @@ export function trimTopic(topic: string) {
// This will remove the specified punctuation from the end of the 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. // and also trim quotes from both the start and end if they exist.
return topic return topic
.replace(/^["“”]+|["“”]+$/g, "") // fix for gemini
.replace(/[,。!?”“"、,.!?]*$/, ""); .replace(/^["“”*]+|["“”*]+$/g, "")
.replace(/[,。!?”“"、,.!?*]*$/, "");
} }
export async function copyToClipboard(text: string) { export async function copyToClipboard(text: string) {
@@ -56,9 +57,9 @@ export async function downloadAs(text: string, filename: string) {
if (result !== null) { if (result !== null) {
try { try {
await window.__TAURI__.fs.writeBinaryFile( await window.__TAURI__.fs.writeTextFile(
result, result,
new Uint8Array([...text].map((c) => c.charCodeAt(0))), text
); );
showToast(Locale.Download.Success); showToast(Locale.Download.Success);
} catch (error) { } catch (error) {
@@ -291,9 +292,11 @@ export function getMessageImages(message: RequestMessage): string[] {
} }
export function isVisionModel(model: string) { export function isVisionModel(model: string) {
return ( // Note: This is a better way using the TypeScript feature instead of `&&` or `||` (ts v5.5.0-dev.20240314 I've been using)
model.startsWith("gpt-4-vision") || const visionKeywords = [
model.startsWith("gemini-pro-vision") || "vision",
!DEFAULT_MODELS.find((m) => m.name == model) "claude-3",
); ];
return visionKeywords.some(keyword => model.includes(keyword));
} }

View File

@@ -1,6 +1,5 @@
import { STORAGE_KEY } from "@/app/constant"; import { STORAGE_KEY } from "@/app/constant";
import { SyncStore } from "@/app/store/sync"; import { SyncStore } from "@/app/store/sync";
import { corsFetch } from "../cors";
import { chunks } from "../format"; import { chunks } from "../format";
export type UpstashConfig = SyncStore["upstash"]; export type UpstashConfig = SyncStore["upstash"];
@@ -18,10 +17,9 @@ export function createUpstashClient(store: SyncStore) {
return { return {
async check() { async check() {
try { try {
const res = await corsFetch(this.path(`get/${storeKey}`), { const res = await fetch(this.path(`get/${storeKey}`, proxyUrl), {
method: "GET", method: "GET",
headers: this.headers(), headers: this.headers(),
proxyUrl,
}); });
console.log("[Upstash] check", res.status, res.statusText); console.log("[Upstash] check", res.status, res.statusText);
return [200].includes(res.status); return [200].includes(res.status);
@@ -32,10 +30,9 @@ export function createUpstashClient(store: SyncStore) {
}, },
async redisGet(key: string) { async redisGet(key: string) {
const res = await corsFetch(this.path(`get/${key}`), { const res = await fetch(this.path(`get/${key}`, proxyUrl), {
method: "GET", method: "GET",
headers: this.headers(), headers: this.headers(),
proxyUrl,
}); });
console.log("[Upstash] get key = ", key, res.status, res.statusText); 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) { 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", method: "POST",
headers: this.headers(), headers: this.headers(),
body: value, body: value,
proxyUrl,
}); });
console.log("[Upstash] set key = ", key, res.status, res.statusText); console.log("[Upstash] set key = ", key, res.status, res.statusText);
@@ -84,18 +80,28 @@ export function createUpstashClient(store: SyncStore) {
Authorization: `Bearer ${config.apiKey}`, Authorization: `Bearer ${config.apiKey}`,
}; };
}, },
path(path: string) { path(path: string, proxyUrl: string = "") {
let url = config.endpoint; if (!path.endsWith("/")) {
path += "/";
if (!url.endsWith("/")) {
url += "/";
} }
if (path.startsWith("/")) { if (path.startsWith("/")) {
path = path.slice(1); path = path.slice(1);
} }
return url + path; if (proxyUrl.length > 0 && !proxyUrl.endsWith("/")) {
proxyUrl += "/";
}
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;
}, },
}; };
} }

View File

@@ -1,6 +1,5 @@
import { STORAGE_KEY } from "@/app/constant"; import { STORAGE_KEY } from "@/app/constant";
import { SyncStore } from "@/app/store/sync"; import { SyncStore } from "@/app/store/sync";
import { corsFetch } from "../cors";
export type WebDAVConfig = SyncStore["webdav"]; export type WebDAVConfig = SyncStore["webdav"];
export type WebDavClient = ReturnType<typeof createWebDavClient>; export type WebDavClient = ReturnType<typeof createWebDavClient>;
@@ -15,10 +14,9 @@ export function createWebDavClient(store: SyncStore) {
return { return {
async check() { async check() {
try { try {
const res = await corsFetch(this.path(folder), { const res = await fetch(this.path(folder, proxyUrl), {
method: "MKCOL", method: "MKCOL",
headers: this.headers(), headers: this.headers(),
proxyUrl,
}); });
console.log("[WebDav] check", res.status, res.statusText); console.log("[WebDav] check", res.status, res.statusText);
return [201, 200, 404, 301, 302, 307, 308].includes(res.status); return [201, 200, 404, 301, 302, 307, 308].includes(res.status);
@@ -30,10 +28,9 @@ export function createWebDavClient(store: SyncStore) {
}, },
async get(key: string) { async get(key: string) {
const res = await corsFetch(this.path(fileName), { const res = await fetch(this.path(fileName, proxyUrl), {
method: "GET", method: "GET",
headers: this.headers(), headers: this.headers(),
proxyUrl,
}); });
console.log("[WebDav] get key = ", key, res.status, res.statusText); console.log("[WebDav] get key = ", key, res.status, res.statusText);
@@ -42,11 +39,10 @@ export function createWebDavClient(store: SyncStore) {
}, },
async set(key: string, value: string) { async set(key: string, value: string) {
const res = await corsFetch(this.path(fileName), { const res = await fetch(this.path(fileName, proxyUrl), {
method: "PUT", method: "PUT",
headers: this.headers(), headers: this.headers(),
body: value, body: value,
proxyUrl,
}); });
console.log("[WebDav] set key = ", key, res.status, res.statusText); console.log("[WebDav] set key = ", key, res.status, res.statusText);
@@ -59,18 +55,28 @@ export function createWebDavClient(store: SyncStore) {
authorization: `Basic ${auth}`, authorization: `Basic ${auth}`,
}; };
}, },
path(path: string) { path(path: string, proxyUrl: string = "") {
let url = config.endpoint; if (!path.endsWith("/")) {
path += "/";
if (!url.endsWith("/")) {
url += "/";
} }
if (path.startsWith("/")) { if (path.startsWith("/")) {
path = path.slice(1); path = path.slice(1);
} }
return url + path; if (proxyUrl.length > 0 && !proxyUrl.endsWith("/")) {
proxyUrl += "/";
}
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;
}, },
}; };
} }

View File

@@ -4,6 +4,9 @@ import { ApiPath, DEFAULT_API_HOST } from "../constant";
export function corsPath(path: string) { export function corsPath(path: string) {
const baseUrl = getClientConfig()?.isApp ? `${DEFAULT_API_HOST}` : ""; const baseUrl = getClientConfig()?.isApp ? `${DEFAULT_API_HOST}` : "";
if (baseUrl === "" && path === "") {
return "";
}
if (!path.startsWith("/")) { if (!path.startsWith("/")) {
path = "/" + path; path = "/" + path;
} }
@@ -14,37 +17,3 @@ export function corsPath(path: string) {
return `${baseUrl}${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);
}

View File

@@ -67,4 +67,5 @@
"resolutions": { "resolutions": {
"lint-staged/yaml": "^2.2.2" "lint-staged/yaml": "^2.2.2"
} }
"packageManager": "yarn@1.22.19"
} }

View File

@@ -9,7 +9,7 @@
}, },
"package": { "package": {
"productName": "NextChat", "productName": "NextChat",
"version": "2.11.2" "version": "2.11.3"
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {