diff --git a/app/components/settings.tsx b/app/components/settings.tsx
index fb1d688f01c..8ed6b77383c 100644
--- a/app/components/settings.tsx
+++ b/app/components/settings.tsx
@@ -50,7 +50,7 @@ import Locale, {
} from "../locales";
import { copyToClipboard } from "../utils";
import Link from "next/link";
-import { Path, RELEASE_URL, UPDATE_URL } from "../constant";
+import { Path, RELEASE_URL, STORAGE_KEY, UPDATE_URL } from "../constant";
import { Prompt, SearchService, usePromptStore } from "../store/prompt";
import { ErrorBoundary } from "./error";
import { InputRange } from "./input-range";
@@ -413,7 +413,42 @@ function SyncConfigModal(props: { onClose?: () => void }) {
{syncStore.provider === ProviderType.UpStash && (
-
+
+ {
+ syncStore.update(
+ (config) =>
+ (config.upstash.endpoint = e.currentTarget.value),
+ );
+ }}
+ >
+
+
+
+ {
+ syncStore.update(
+ (config) =>
+ (config.upstash.username = e.currentTarget.value),
+ );
+ }}
+ >
+
+
+ {
+ syncStore.update(
+ (config) => (config.upstash.apiKey = e.currentTarget.value),
+ );
+ }}
+ >
+
)}
diff --git a/app/locales/cn.ts b/app/locales/cn.ts
index ac4a1777f5d..b2afc753457 100644
--- a/app/locales/cn.ts
+++ b/app/locales/cn.ts
@@ -207,6 +207,12 @@ const cn = {
UserName: "用户名",
Password: "密码",
},
+
+ UpStash: {
+ Endpoint: "UpStash Redis REST Url",
+ UserName: "备份名称",
+ Password: "UpStash Redis REST Token",
+ },
},
LocalState: "本地数据",
diff --git a/app/locales/en.ts b/app/locales/en.ts
index 3f3fa7ce920..697d09d1f4e 100644
--- a/app/locales/en.ts
+++ b/app/locales/en.ts
@@ -210,6 +210,12 @@ const en: LocaleType = {
UserName: "User Name",
Password: "Password",
},
+
+ UpStash: {
+ Endpoint: "UpStash Redis REST Url",
+ UserName: "Backup Name",
+ Password: "UpStash Redis REST Token",
+ },
},
LocalState: "Local Data",
diff --git a/app/store/sync.ts b/app/store/sync.ts
index 29b6a82c235..ff9f650c083 100644
--- a/app/store/sync.ts
+++ b/app/store/sync.ts
@@ -1,5 +1,5 @@
import { Updater } from "../typing";
-import { ApiPath, StoreKey } from "../constant";
+import { ApiPath, STORAGE_KEY, StoreKey } from "../constant";
import { createPersistStore } from "../utils/store";
import {
AppState,
@@ -36,7 +36,7 @@ export const useSyncStore = createPersistStore(
upstash: {
endpoint: "",
- username: "",
+ username: STORAGE_KEY,
apiKey: "",
},
diff --git a/app/utils/cloud/upstash.ts b/app/utils/cloud/upstash.ts
index 6f9b30f6b5e..5f5b9fc7925 100644
--- a/app/utils/cloud/upstash.ts
+++ b/app/utils/cloud/upstash.ts
@@ -1,25 +1,87 @@
+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"];
export type UpStashClient = ReturnType;
-export function createUpstashClient(config: UpstashConfig) {
+export function createUpstashClient(store: SyncStore) {
+ const config = store.upstash;
+ const storeKey = config.username.length === 0 ? STORAGE_KEY : config.username;
+ const chunkCountKey = `${storeKey}-chunk-count`;
+ const chunkIndexKey = (i: number) => `${storeKey}-chunk-${i}`;
+
+ const proxyUrl =
+ store.useProxy && store.proxyUrl.length > 0 ? store.proxyUrl : undefined;
+
return {
async check() {
- return true;
+ try {
+ const res = await corsFetch(this.path(`get/${storeKey}`), {
+ method: "GET",
+ headers: this.headers(),
+ proxyUrl,
+ });
+ console.log("[Upstash] check", res.status, res.statusText);
+ return [200].includes(res.status);
+ } catch (e) {
+ console.error("[Upstash] failed to check", e);
+ }
+ return false;
+ },
+
+ async redisGet(key: string) {
+ const res = await corsFetch(this.path(`get/${key}`), {
+ method: "GET",
+ headers: this.headers(),
+ proxyUrl,
+ });
+
+ console.log("[Upstash] get key = ", key, res.status, res.statusText);
+ const resJson = (await res.json()) as { result: string };
+
+ return resJson.result;
+ },
+
+ async redisSet(key: string, value: string) {
+ const res = await corsFetch(this.path(`set/${key}`), {
+ method: "POST",
+ headers: this.headers(),
+ body: value,
+ proxyUrl,
+ });
+
+ console.log("[Upstash] set key = ", key, res.status, res.statusText);
},
async get() {
- throw Error("[Sync] not implemented");
+ const chunkCount = Number(await this.redisGet(chunkCountKey));
+ if (!Number.isInteger(chunkCount)) return;
+
+ const chunks = await Promise.all(
+ new Array(chunkCount)
+ .fill(0)
+ .map((_, i) => this.redisGet(chunkIndexKey(i))),
+ );
+ console.log("[Upstash] get full chunks", chunks);
+ return chunks.join("");
},
- async set() {
- throw Error("[Sync] not implemented");
+ async set(_: string, value: string) {
+ // upstash limit the max request size which is 1Mb for “Free” and “Pay as you go”
+ // so we need to split the data to chunks
+ let index = 0;
+ for await (const chunk of chunks(value)) {
+ await this.redisSet(chunkIndexKey(index), chunk);
+ index += 1;
+ }
+ await this.redisSet(chunkCountKey, index.toString());
},
headers() {
return {
- Authorization: `Basic ${config.apiKey}`,
+ Authorization: `Bearer ${config.apiKey}`,
};
},
path(path: string) {
diff --git a/app/utils/cloud/webdav.ts b/app/utils/cloud/webdav.ts
index 6c96c9062a4..c87fdd71e1e 100644
--- a/app/utils/cloud/webdav.ts
+++ b/app/utils/cloud/webdav.ts
@@ -20,9 +20,7 @@ export function createWebDavClient(store: SyncStore) {
headers: this.headers(),
proxyUrl,
});
-
console.log("[WebDav] check", res.status, res.statusText);
-
return [201, 200, 404, 401].includes(res.status);
} catch (e) {
console.error("[WebDav] failed to check", e);
diff --git a/app/utils/format.ts b/app/utils/format.ts
index 450d66696d9..2e8a382b95a 100644
--- a/app/utils/format.ts
+++ b/app/utils/format.ts
@@ -11,3 +11,18 @@ export function prettyObject(msg: any) {
}
return ["```json", msg, "```"].join("\n");
}
+
+export function* chunks(s: string, maxBytes = 1000 * 1000) {
+ const decoder = new TextDecoder("utf-8");
+ let buf = new TextEncoder().encode(s);
+ while (buf.length) {
+ let i = buf.lastIndexOf(32, maxBytes + 1);
+ // If no space found, try forward search
+ if (i < 0) i = buf.indexOf(32, maxBytes);
+ // If there's no space at all, take all
+ if (i < 0) i = buf.length;
+ // This is a safe cut-off point; never half-way a multi-byte
+ yield decoder.decode(buf.slice(0, i));
+ buf = buf.slice(i + 1); // Skip space (if any)
+ }
+}
diff --git a/app/utils/sync.ts b/app/utils/sync.ts
index ab1f1f44918..1acfc1289de 100644
--- a/app/utils/sync.ts
+++ b/app/utils/sync.ts
@@ -69,6 +69,9 @@ const MergeStates: StateMerger = {
localState.sessions.forEach((s) => (localSessions[s.id] = s));
remoteState.sessions.forEach((remoteSession) => {
+ // skip empty chats
+ if (remoteSession.messages.length === 0) return;
+
const localSession = localSessions[remoteSession.id];
if (!localSession) {
// if remote session is new, just merge it