diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8ac96f19356..65ae617f55c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,26 +19,26 @@ jobs: with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - - + + - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v4 with: - images: yidadaa/chatgpt-next-web + images: ${{ secrets.DOCKER_USERNAME }}/chatgpt-next-web tags: | type=raw,value=latest type=ref,event=tag - - - + + - name: Set up QEMU uses: docker/setup-qemu-action@v2 - - + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - - - + + - name: Build and push Docker image uses: docker/build-push-action@v4 with: @@ -49,4 +49,4 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max - + diff --git a/app/command.ts b/app/command.ts index bea4e06f381..0e4bba85afe 100644 --- a/app/command.ts +++ b/app/command.ts @@ -35,6 +35,7 @@ export function useCommand(commands: Commands = {}) { interface ChatCommands { new?: Command; newm?: Command; + copy?: Command; next?: Command; prev?: Command; clear?: Command; diff --git a/app/components/chat.tsx b/app/components/chat.tsx index dafb9846421..4cff0d1d838 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -67,6 +67,7 @@ import { getMessageImages, isVisionModel, isDalle3, + removeOutdatedEntries, showPlugins, safeLocalStorage, } from "../utils"; @@ -974,6 +975,7 @@ function _Chat() { const chatCommands = useChatCommand({ new: () => chatStore.newSession(), newm: () => navigate(Path.NewChat), + copy: () => chatStore.copySession(), prev: () => chatStore.nextSession(-1), next: () => chatStore.nextSession(1), clear: () => @@ -1104,10 +1106,20 @@ function _Chat() { }; const deleteMessage = (msgId?: string) => { - chatStore.updateCurrentSession( - (session) => - (session.messages = session.messages.filter((m) => m.id !== msgId)), - ); + chatStore.updateCurrentSession((session) => { + session.deletedMessageIds && + removeOutdatedEntries(session.deletedMessageIds); + session.messages = session.messages.filter((m) => { + if (m.id !== msgId) { + return true; + } + if (!session.deletedMessageIds) { + session.deletedMessageIds = {} as Record; + } + session.deletedMessageIds[m.id] = Date.now(); + return false; + }); + }); }; const onDelete = (msgId: string) => { diff --git a/app/components/settings.tsx b/app/components/settings.tsx index ca0a5a18796..b265d78ded2 100644 --- a/app/components/settings.tsx +++ b/app/components/settings.tsx @@ -357,6 +357,21 @@ function SyncConfigModal(props: { onClose?: () => void }) { + + { + syncStore.update( + (config) => (config.enableAutoSync = e.currentTarget.checked), + ); + }} + > + + - + diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 92e81bcb1ba..6af0d84338d 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -48,6 +48,7 @@ const cn = { Commands: { new: "新建聊天", newm: "从面具新建聊天", + copy: "复制当前聊天", next: "下一个聊天", prev: "上一个聊天", clear: "清除上下文", @@ -215,6 +216,10 @@ const cn = { Title: "同步类型", SubTitle: "选择喜爱的同步服务器", }, + EnableAutoSync: { + Title: "自动同步设置", + SubTitle: "在回复完成或删除消息后自动同步数据", + }, Proxy: { Title: "启用代理", SubTitle: "在浏览器中同步时,必须启用代理以避免跨域限制", diff --git a/app/locales/en.ts b/app/locales/en.ts index 09b76f1fa12..32cd2d58b58 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -49,6 +49,7 @@ const en: LocaleType = { Commands: { new: "Start a new chat", newm: "Start a new chat with mask", + copy: "Copy the current Chat", next: "Next Chat", prev: "Previous Chat", clear: "Clear Context", @@ -217,6 +218,11 @@ const en: LocaleType = { Title: "Sync Type", SubTitle: "Choose your favorite sync service", }, + EnableAutoSync: { + Title: "Auto Sync Settings", + SubTitle: + "Automatically synchronize data after replying or deleting messages", + }, Proxy: { Title: "Enable CORS Proxy", SubTitle: "Enable a proxy to avoid cross-origin restrictions", diff --git a/app/store/access.ts b/app/store/access.ts index a1014610e39..fff0c3b244c 100644 --- a/app/store/access.ts +++ b/app/store/access.ts @@ -210,7 +210,7 @@ export const useAccessStore = createPersistStore( }) .then((res: DangerConfig) => { console.log("[Config] got config from server", res); - set(() => ({ ...res })); + set(() => ({ lastUpdateTime: Date.now(), ...res })); }) .catch(() => { console.error("[Config] failed to fetch config"); diff --git a/app/store/chat.ts b/app/store/chat.ts index 58c105e7ef7..bbb914710cd 100644 --- a/app/store/chat.ts +++ b/app/store/chat.ts @@ -1,4 +1,8 @@ -import { trimTopic, getMessageTextContent } from "../utils"; +import { + trimTopic, + getMessageTextContent, + removeOutdatedEntries, +} from "../utils"; import Locale, { getLang } from "../locales"; import { showToast } from "../components/ui-lib"; @@ -27,6 +31,7 @@ import { createPersistStore } from "../utils/store"; import { collectModelsWithDefaultModel } from "../utils/model"; import { useAccessStore } from "./access"; import { isDalle3, safeLocalStorage } from "../utils"; +import { useSyncStore } from "./sync"; import { indexedDBStorage } from "@/app/utils/indexedDB-storage"; const localStorage = safeLocalStorage(); @@ -78,6 +83,7 @@ export interface ChatSession { lastUpdate: number; lastSummarizeIndex: number; clearContextIndex?: number; + deletedMessageIds?: Record; mask: Mask; } @@ -101,6 +107,7 @@ function createEmptySession(): ChatSession { }, lastUpdate: Date.now(), lastSummarizeIndex: 0, + deletedMessageIds: {}, mask: createEmptyMask(), }; @@ -178,9 +185,19 @@ function fillTemplateWith(input: string, modelConfig: ModelConfig) { return output; } +let cloudSyncTimer: any = null; +function noticeCloudSync(): void { + const syncStore = useSyncStore.getState(); + cloudSyncTimer && clearTimeout(cloudSyncTimer); + cloudSyncTimer = setTimeout(() => { + syncStore.autoSync(); + }, 500); +} + const DEFAULT_CHAT_STATE = { sessions: [createEmptySession()], currentSessionIndex: 0, + deletedSessionIds: {} as Record, lastInput: "", }; @@ -208,6 +225,28 @@ export const useChatStore = createPersistStore( }); }, + copySession() { + set((state) => { + const { sessions, currentSessionIndex } = state; + const emptySession = createEmptySession(); + + // copy the session + const curSession = JSON.parse( + JSON.stringify(sessions[currentSessionIndex]), + ); + curSession.id = emptySession.id; + curSession.lastUpdate = emptySession.lastUpdate; + + const newSessions = [...sessions]; + newSessions.splice(0, 0, curSession); + + return { + currentSessionIndex: 0, + sessions: newSessions, + }; + }); + }, + moveSession(from: number, to: number) { set((state) => { const { sessions, currentSessionIndex: oldIndex } = state; @@ -270,7 +309,18 @@ export const useChatStore = createPersistStore( if (!deletedSession) return; const sessions = get().sessions.slice(); - sessions.splice(index, 1); + const deletedSessionIds = { ...get().deletedSessionIds }; + + removeOutdatedEntries(deletedSessionIds); + + const hasDelSessions = sessions.splice(index, 1); + if (hasDelSessions?.length) { + hasDelSessions.forEach((session) => { + if (session.messages.length > 0) { + deletedSessionIds[session.id] = Date.now(); + } + }); + } const currentIndex = get().currentSessionIndex; let nextIndex = Math.min( @@ -287,19 +337,24 @@ export const useChatStore = createPersistStore( const restoreState = { currentSessionIndex: get().currentSessionIndex, sessions: get().sessions.slice(), + deletedSessionIds: get().deletedSessionIds, }; set(() => ({ currentSessionIndex: nextIndex, sessions, + deletedSessionIds, })); + noticeCloudSync(); + showToast( Locale.Home.DeleteToast, { text: Locale.Home.Revert, onClick() { set(() => restoreState); + noticeCloudSync(); }, }, 5000, @@ -320,6 +375,24 @@ export const useChatStore = createPersistStore( return session; }, + sortSessions() { + const currentSession = get().currentSession(); + const sessions = get().sessions.slice(); + + sessions.sort( + (a, b) => + new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime(), + ); + const currentSessionIndex = sessions.findIndex((session) => { + return session && currentSession && session.id === currentSession.id; + }); + + set((state) => ({ + currentSessionIndex, + sessions, + })); + }, + onNewMessage(message: ChatMessage) { get().updateCurrentSession((session) => { session.messages = session.messages.concat(); @@ -327,6 +400,8 @@ export const useChatStore = createPersistStore( }); get().updateStat(message); get().summarizeSession(); + get().sortSessions(); + noticeCloudSync(); }, async onUserInput(content: string, attachImages?: string[]) { diff --git a/app/store/sync.ts b/app/store/sync.ts index d3582e3c935..ca3f3283d09 100644 --- a/app/store/sync.ts +++ b/app/store/sync.ts @@ -26,6 +26,7 @@ export type SyncStore = GetStoreState; const DEFAULT_SYNC_STATE = { provider: ProviderType.WebDAV, + enableAutoSync: true, useProxy: true, proxyUrl: corsPath(ApiPath.Cors), @@ -45,6 +46,8 @@ const DEFAULT_SYNC_STATE = { lastProvider: "", }; +let lastSyncTime = 0; + export const useSyncStore = createPersistStore( DEFAULT_SYNC_STATE, (set, get) => ({ @@ -91,6 +94,16 @@ export const useSyncStore = createPersistStore( }, async sync() { + if (lastSyncTime && lastSyncTime >= Date.now() - 800) { + return; + } + lastSyncTime = Date.now(); + + const enableAutoSync = get().enableAutoSync; + if (!enableAutoSync) { + return; + } + const localState = getLocalAppState(); const provider = get().provider; const config = get()[provider]; @@ -100,15 +113,15 @@ export const useSyncStore = createPersistStore( const remoteState = await client.get(config.username); if (!remoteState || remoteState === "") { await client.set(config.username, JSON.stringify(localState)); - console.log("[Sync] Remote state is empty, using local state instead."); - return + console.log( + "[Sync] Remote state is empty, using local state instead.", + ); + return; } else { - const parsedRemoteState = JSON.parse( - await client.get(config.username), - ) as AppState; + const parsedRemoteState = JSON.parse(remoteState) as AppState; mergeAppState(localState, parsedRemoteState); setLocalAppState(localState); - } + } } catch (e) { console.log("[Sync] failed to get remote state", e); throw e; @@ -123,6 +136,14 @@ export const useSyncStore = createPersistStore( const client = this.getClient(); return await client.check(); }, + + async autoSync() { + const { lastSyncTime, provider } = get(); + const syncStore = useSyncStore.getState(); + if (lastSyncTime && syncStore.cloudSync()) { + syncStore.sync(); + } + }, }), { name: StoreKey.Sync, diff --git a/app/utils.ts b/app/utils.ts index bf745092913..ef0f4963abc 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -274,6 +274,19 @@ export function isDalle3(model: string) { return "dall-e-3" === model; } +export function removeOutdatedEntries( + timeMap: Record, +): Record { + const oneMonthAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; + // Delete data from a month ago + Object.keys(timeMap).forEach((id) => { + if (timeMap[id] < oneMonthAgo) { + delete timeMap[id]; + } + }); + return timeMap; +} + export function showPlugins(provider: ServiceProvider, model: string) { if ( provider == ServiceProvider.OpenAI || diff --git a/app/utils/sync.ts b/app/utils/sync.ts index 1acfc1289de..db2cbd37e68 100644 --- a/app/utils/sync.ts +++ b/app/utils/sync.ts @@ -8,6 +8,7 @@ import { useMaskStore } from "../store/mask"; import { usePromptStore } from "../store/prompt"; import { StoreKey } from "../constant"; import { merge } from "./merge"; +import { removeOutdatedEntries } from "@/app/utils"; type NonFunctionKeys = { [K in keyof T]: T[K] extends (...args: any[]) => any ? never : K; @@ -65,7 +66,10 @@ type StateMerger = { const MergeStates: StateMerger = { [StoreKey.Chat]: (localState, remoteState) => { // merge sessions + const currentSession = useChatStore.getState().currentSession(); + const localSessions: Record = {}; + const localDeletedSessionIds = localState.deletedSessionIds || {}; localState.sessions.forEach((s) => (localSessions[s.id] = s)); remoteState.sessions.forEach((remoteSession) => { @@ -75,29 +79,98 @@ const MergeStates: StateMerger = { const localSession = localSessions[remoteSession.id]; if (!localSession) { // if remote session is new, just merge it - localState.sessions.push(remoteSession); + if ( + (localDeletedSessionIds[remoteSession.id] || -1) < + remoteSession.lastUpdate + ) { + localState.sessions.push(remoteSession); + } } else { // if both have the same session id, merge the messages const localMessageIds = new Set(localSession.messages.map((v) => v.id)); + const localDeletedMessageIds = localSession.deletedMessageIds || {}; remoteSession.messages.forEach((m) => { if (!localMessageIds.has(m.id)) { - localSession.messages.push(m); + if ( + !localDeletedMessageIds[m.id] || + new Date(localDeletedMessageIds[m.id]).toLocaleString() < m.date + ) { + localSession.messages.push(m); + } } }); + const remoteDeletedMessageIds = remoteSession.deletedMessageIds || {}; + localSession.messages = localSession.messages.filter((localMessage) => { + return ( + !remoteDeletedMessageIds[localMessage.id] || + new Date(localDeletedMessageIds[localMessage.id]).toLocaleString() < + localMessage.date + ); + }); + // sort local messages with date field in asc order localSession.messages.sort( (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), ); + localSession.lastUpdate = Math.max( + remoteSession.lastUpdate, + localSession.lastUpdate, + ); + + const deletedMessageIds = { + ...remoteDeletedMessageIds, + ...localDeletedMessageIds, + }; + removeOutdatedEntries(deletedMessageIds); + localSession.deletedMessageIds = deletedMessageIds; } }); + const remoteDeletedSessionIds = remoteState.deletedSessionIds || {}; + + const finalIds: Record = {}; + localState.sessions = localState.sessions.filter((localSession) => { + // 去除掉重复的会话 + if (finalIds[localSession.id]) { + return false; + } + finalIds[localSession.id] = true; + + // 去除掉非首个空会话,避免多个空会话在中间,不方便管理 + if ( + localSession.messages.length === 0 && + localSession != localState.sessions[0] + ) { + return false; + } + + // 去除云端删除并且删除时间小于本地修改时间的会话 + return ( + (remoteDeletedSessionIds[localSession.id] || -1) <= + localSession.lastUpdate + ); + }); + // sort local sessions with date field in desc order localState.sessions.sort( (a, b) => new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime(), ); + const deletedSessionIds = { + ...remoteDeletedSessionIds, + ...localDeletedSessionIds, + }; + removeOutdatedEntries(deletedSessionIds); + localState.deletedSessionIds = deletedSessionIds; + + localState.currentSessionIndex = localState.sessions.findIndex( + (session) => { + return session && currentSession && session.id === currentSession.id; + }, + ); + return localState; }, [StoreKey.Prompt]: (localState, remoteState) => { @@ -153,9 +226,9 @@ export function mergeWithUpdate( remoteState: T, ) { const localUpdateTime = localState.lastUpdateTime ?? 0; - const remoteUpdateTime = localState.lastUpdateTime ?? 1; + const remoteUpdateTime = remoteState.lastUpdateTime ?? 1; - if (localUpdateTime < remoteUpdateTime) { + if (localUpdateTime >= remoteUpdateTime) { merge(remoteState, localState); return { ...remoteState }; } else {