diff --git a/README_CN.md b/README_CN.md index e593e45da9c..b31e8b367ee 100644 --- a/README_CN.md +++ b/README_CN.md @@ -114,7 +114,7 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填 OPENAI_API_KEY= # 中国大陆用户,可以使用本项目自带的代理进行开发,你也可以自由选择其他代理地址 -BASE_URL=https://chatgpt2.nextweb.fun/api/proxy +BASE_URL=https://nb.nextweb.fun/api/proxy ``` ### 本地开发 diff --git a/app/api/cors/[...path]/route.ts b/app/api/cors/[...path]/route.ts index 90404cf892a..0217b12b08f 100644 --- a/app/api/cors/[...path]/route.ts +++ b/app/api/cors/[...path]/route.ts @@ -26,13 +26,18 @@ async function handle( duplex: "half", }; - console.log("[Any Proxy]", targetUrl); + const fetchResult = await fetch(targetUrl, fetchOptions); - const fetchResult = 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"; +export const runtime = "nodejs"; diff --git a/app/components/home.module.scss b/app/components/home.module.scss index 77f1c8538eb..b836d2bec93 100644 --- a/app/components/home.module.scss +++ b/app/components/home.module.scss @@ -6,7 +6,7 @@ color: var(--black); background-color: var(--white); min-width: 600px; - min-height: 480px; + min-height: 370px; max-width: 1200px; display: flex; diff --git a/app/components/settings.tsx b/app/components/settings.tsx index 8e43e1d1aec..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"; @@ -275,7 +275,7 @@ function CheckButton() { return ( 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/components/sidebar.tsx b/app/components/sidebar.tsx index 4519c4be941..3ca1678963e 100644 --- a/app/components/sidebar.tsx +++ b/app/components/sidebar.tsx @@ -17,6 +17,7 @@ import Locale from "../locales"; import { useAppConfig, useChatStore } from "../store"; import { + DEFAULT_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, NARROW_SIDEBAR_WIDTH, @@ -57,39 +58,57 @@ function useDragSideBar() { const config = useAppConfig(); const startX = useRef(0); - const startDragWidth = useRef(config.sidebarWidth ?? 300); + const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH); const lastUpdateTime = useRef(Date.now()); - const handleMouseMove = useRef((e: MouseEvent) => { - if (Date.now() < lastUpdateTime.current + 50) { - return; - } - lastUpdateTime.current = Date.now(); - const d = e.clientX - startX.current; - const nextWidth = limit(startDragWidth.current + d); + const toggleSideBar = () => { config.update((config) => { - if (nextWidth < MIN_SIDEBAR_WIDTH) { - config.sidebarWidth = NARROW_SIDEBAR_WIDTH; + if (config.sidebarWidth < MIN_SIDEBAR_WIDTH) { + config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH; } else { - config.sidebarWidth = nextWidth; + config.sidebarWidth = NARROW_SIDEBAR_WIDTH; } }); - }); - - const handleMouseUp = useRef(() => { - // In useRef the data is non-responsive, so `config.sidebarWidth` can't get the dynamic sidebarWidth - // startDragWidth.current = config.sidebarWidth ?? 300; - window.removeEventListener("mousemove", handleMouseMove.current); - window.removeEventListener("mouseup", handleMouseUp.current); - }); + }; - const onDragMouseDown = (e: MouseEvent) => { - startX.current = e.clientX; + const onDragStart = (e: MouseEvent) => { // Remembers the initial width each time the mouse is pressed + startX.current = e.clientX; startDragWidth.current = config.sidebarWidth; - window.addEventListener("mousemove", handleMouseMove.current); - window.addEventListener("mouseup", handleMouseUp.current); + const dragStartTime = Date.now(); + + const handleDragMove = (e: MouseEvent) => { + if (Date.now() < lastUpdateTime.current + 20) { + return; + } + lastUpdateTime.current = Date.now(); + const d = e.clientX - startX.current; + const nextWidth = limit(startDragWidth.current + d); + config.update((config) => { + if (nextWidth < MIN_SIDEBAR_WIDTH) { + config.sidebarWidth = NARROW_SIDEBAR_WIDTH; + } else { + config.sidebarWidth = nextWidth; + } + }); + }; + + const handleDragEnd = () => { + // In useRef the data is non-responsive, so `config.sidebarWidth` can't get the dynamic sidebarWidth + window.removeEventListener("pointermove", handleDragMove); + window.removeEventListener("pointerup", handleDragEnd); + + // if user click the drag icon, should toggle the sidebar + const shouldFireClick = Date.now() - dragStartTime < 300; + if (shouldFireClick) { + toggleSideBar(); + } + }; + + window.addEventListener("pointermove", handleDragMove); + window.addEventListener("pointerup", handleDragEnd); }; + const isMobileScreen = useMobileScreen(); const shouldNarrow = !isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH; @@ -97,13 +116,13 @@ function useDragSideBar() { useEffect(() => { const barWidth = shouldNarrow ? NARROW_SIDEBAR_WIDTH - : limit(config.sidebarWidth ?? 300); + : limit(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH); const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`; document.documentElement.style.setProperty("--sidebar-width", sideBarWidth); }, [config.sidebarWidth, isMobileScreen, shouldNarrow]); return { - onDragMouseDown, + onDragStart, shouldNarrow, }; } @@ -112,7 +131,7 @@ export function SideBar(props: { className?: string }) { const chatStore = useChatStore(); // drag side bar - const { onDragMouseDown, shouldNarrow } = useDragSideBar(); + const { onDragStart, shouldNarrow } = useDragSideBar(); const navigate = useNavigate(); const config = useAppConfig(); @@ -206,7 +225,7 @@ export function SideBar(props: { className?: string }) {
onDragMouseDown(e as any)} + onPointerDown={(e) => onDragStart(e as any)} >
diff --git a/app/constant.ts b/app/constant.ts index f76eb3a9794..9e23ed510e4 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -8,7 +8,7 @@ export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/c export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`; export const RUNTIME_CONFIG_DOM = "danger-runtime-config"; -export const DEFAULT_CORS_HOST = "https://chatgpt2.nextweb.fun"; +export const DEFAULT_CORS_HOST = "https://nb.nextweb.fun"; export const DEFAULT_API_HOST = `${DEFAULT_CORS_HOST}/api/proxy`; export enum Path { @@ -43,6 +43,7 @@ export enum StoreKey { Sync = "sync", } +export const DEFAULT_SIDEBAR_WIDTH = 300; export const MAX_SIDEBAR_WIDTH = 500; export const MIN_SIDEBAR_WIDTH = 230; export const NARROW_SIDEBAR_WIDTH = 100; diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 1b8850f4507..b2afc753457 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -187,6 +187,7 @@ const cn = { Config: { Modal: { Title: "配置云同步", + Check: "检查可用性", }, SyncType: { Title: "同步类型", @@ -206,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 ebbf1a37669..697d09d1f4e 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -189,6 +189,7 @@ const en: LocaleType = { Config: { Modal: { Title: "Config Sync", + Check: "Check Connection", }, SyncType: { Title: "Sync Type", @@ -209,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/config.ts b/app/store/config.ts index b0131954296..956e5f3eb81 100644 --- a/app/store/config.ts +++ b/app/store/config.ts @@ -1,6 +1,11 @@ import { LLMModel } from "../client/api"; import { getClientConfig } from "../config/client"; -import { DEFAULT_INPUT_TEMPLATE, DEFAULT_MODELS, StoreKey } from "../constant"; +import { + DEFAULT_INPUT_TEMPLATE, + DEFAULT_MODELS, + DEFAULT_SIDEBAR_WIDTH, + StoreKey, +} from "../constant"; import { createPersistStore } from "../utils/store"; export type ModelType = (typeof DEFAULT_MODELS)[number]["name"]; @@ -29,7 +34,7 @@ export const DEFAULT_CONFIG = { tightBorder: !!getClientConfig()?.isApp, sendPreviewBubble: true, enableAutoGenerateTitle: true, - sidebarWidth: 300, + sidebarWidth: DEFAULT_SIDEBAR_WIDTH, disablePromptHint: false, diff --git a/app/store/sync.ts b/app/store/sync.ts index 29b6a82c235..c194162fcb0 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, @@ -22,27 +22,29 @@ export interface WebDavConfig { export type SyncStore = GetStoreState; -export const useSyncStore = createPersistStore( - { - provider: ProviderType.WebDAV, - useProxy: true, - proxyUrl: corsPath(ApiPath.Cors), - - webdav: { - endpoint: "", - username: "", - password: "", - }, +const DEFAULT_SYNC_STATE = { + provider: ProviderType.WebDAV, + useProxy: true, + proxyUrl: corsPath(ApiPath.Cors), - upstash: { - endpoint: "", - username: "", - apiKey: "", - }, + webdav: { + endpoint: "", + username: "", + password: "", + }, - lastSyncTime: 0, - lastProvider: "", + upstash: { + endpoint: "", + username: STORAGE_KEY, + apiKey: "", }, + + lastSyncTime: 0, + lastProvider: "", +}; + +export const useSyncStore = createPersistStore( + DEFAULT_SYNC_STATE, (set, get) => ({ coundSync() { const config = get()[get().provider]; @@ -108,6 +110,16 @@ export const useSyncStore = createPersistStore( }), { name: StoreKey.Sync, - version: 1, + version: 1.1, + + migrate(persistedState, version) { + const newState = persistedState as typeof DEFAULT_SYNC_STATE; + + if (version < 1.1) { + newState.upstash.username = STORAGE_KEY; + } + + return newState as any; + }, }, ); 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 5386b4d1958..c87fdd71e1e 100644 --- a/app/utils/cloud/webdav.ts +++ b/app/utils/cloud/webdav.ts @@ -20,10 +20,8 @@ export function createWebDavClient(store: SyncStore) { headers: this.headers(), proxyUrl, }); - console.log("[WebDav] check", res.status, res.statusText); - - return [201, 200, 404].includes(res.status); + 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 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d8b677bf6a0..77b02a3bae8 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.6" + "version": "2.9.7" }, "tauri": { "allowlist": {