forked from XiaoMo/ChatGPT-Next-Web
feat: add upstash redis cloud sync
This commit is contained in:
parent
59fbadd9eb
commit
83fed42997
@ -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 && (
|
||||
<List>
|
||||
<ListItem title={Locale.WIP}></ListItem>
|
||||
<ListItem title={Locale.Settings.Sync.Config.UpStash.Endpoint}>
|
||||
<input
|
||||
type="text"
|
||||
value={syncStore.upstash.endpoint}
|
||||
onChange={(e) => {
|
||||
syncStore.update(
|
||||
(config) =>
|
||||
(config.upstash.endpoint = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={Locale.Settings.Sync.Config.UpStash.UserName}>
|
||||
<input
|
||||
type="text"
|
||||
value={syncStore.upstash.username}
|
||||
placeholder={STORAGE_KEY}
|
||||
onChange={(e) => {
|
||||
syncStore.update(
|
||||
(config) =>
|
||||
(config.upstash.username = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
<ListItem title={Locale.Settings.Sync.Config.UpStash.Password}>
|
||||
<PasswordInput
|
||||
value={syncStore.upstash.apiKey}
|
||||
onChange={(e) => {
|
||||
syncStore.update(
|
||||
(config) => (config.upstash.apiKey = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
></PasswordInput>
|
||||
</ListItem>
|
||||
</List>
|
||||
)}
|
||||
</Modal>
|
||||
|
@ -207,6 +207,12 @@ const cn = {
|
||||
UserName: "用户名",
|
||||
Password: "密码",
|
||||
},
|
||||
|
||||
UpStash: {
|
||||
Endpoint: "UpStash Redis REST Url",
|
||||
UserName: "备份名称",
|
||||
Password: "UpStash Redis REST Token",
|
||||
},
|
||||
},
|
||||
|
||||
LocalState: "本地数据",
|
||||
|
@ -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",
|
||||
|
@ -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: "",
|
||||
},
|
||||
|
||||
|
@ -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<typeof createUpstashClient>;
|
||||
|
||||
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) {
|
||||
|
@ -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);
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user