mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-11-18 06:53:41 +08:00
Compare commits
54 Commits
v2.15.0
...
35f52886c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35f52886c4 | ||
|
|
35f77f45a2 | ||
|
|
992c3a5d3a | ||
|
|
d51d7b6797 | ||
|
|
23ac2efd89 | ||
|
|
daeffb2dc6 | ||
|
|
db58ca6c1d | ||
|
|
2ff292cbfa | ||
|
|
5a81393863 | ||
|
|
116a73d398 | ||
|
|
cf0c057164 | ||
|
|
9551f5dfc6 | ||
|
|
370ce3eeca | ||
|
|
c1b74201e4 | ||
|
|
5ae4921ee0 | ||
|
|
6f3d7530b9 | ||
|
|
6dc868154d | ||
|
|
ccacfec918 | ||
|
|
c204031ea7 | ||
|
|
2bf72d0324 | ||
|
|
e8c7ac0c45 | ||
|
|
0638db146e | ||
|
|
2d68f179d7 | ||
|
|
f1d69cb312 | ||
|
|
31baa10363 | ||
|
|
2fdb35bcc8 | ||
|
|
5c51fd2ed8 | ||
|
|
d0b7ddc1d6 | ||
|
|
0745b6498d | ||
|
|
fc97c4b06f | ||
|
|
fdb89af355 | ||
|
|
e515f0f957 | ||
|
|
31f282970b | ||
|
|
b2336f5ed9 | ||
|
|
0a6ddda992 | ||
|
|
5e1064a5c8 | ||
|
|
2ee2d50ae6 | ||
|
|
eae593d660 | ||
|
|
621b1480c2 | ||
|
|
4b22aaf979 | ||
|
|
93bfb55822 | ||
|
|
648e60028d | ||
|
|
4f876f3e65 | ||
|
|
faac0d9817 | ||
|
|
22c79595fb | ||
|
|
5065091b74 | ||
|
|
22f61295bc | ||
|
|
c440637ad0 | ||
|
|
284d33bcdf | ||
|
|
d9573973ca | ||
|
|
cd354cf045 | ||
|
|
1cce87acaa | ||
|
|
78c4084501 | ||
|
|
1d0a40b9e8 |
18
.github/workflows/docker.yml
vendored
18
.github/workflows/docker.yml
vendored
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ function getModels(remoteModelRes: OpenAIListModelResponse) {
|
||||
|
||||
if (config.disableGPT4) {
|
||||
remoteModelRes.data = remoteModelRes.data.filter(
|
||||
(m) => !m.id.startsWith("gpt-4") || m.id.startsWith("gpt-4o-mini"),
|
||||
(m) =>
|
||||
!(m.id.startsWith("gpt-4") || m.id.startsWith("chatgpt-4o")) ||
|
||||
m.id.startsWith("gpt-4o-mini"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ export class ClaudeApi implements LLMApi {
|
||||
const [tools, funcs] = usePluginStore
|
||||
.getState()
|
||||
.getAsTools(
|
||||
useChatStore.getState().currentSession().mask?.plugin as string[],
|
||||
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||
);
|
||||
return stream(
|
||||
path,
|
||||
|
||||
@@ -125,7 +125,7 @@ export class MoonshotApi implements LLMApi {
|
||||
const [tools, funcs] = usePluginStore
|
||||
.getState()
|
||||
.getAsTools(
|
||||
useChatStore.getState().currentSession().mask?.plugin as string[],
|
||||
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||
);
|
||||
return stream(
|
||||
chatPath,
|
||||
|
||||
@@ -244,7 +244,7 @@ export class ChatGPTApi implements LLMApi {
|
||||
const [tools, funcs] = usePluginStore
|
||||
.getState()
|
||||
.getAsTools(
|
||||
useChatStore.getState().currentSession().mask?.plugin as string[],
|
||||
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||
);
|
||||
// console.log("getAsTools", tools, funcs);
|
||||
stream(
|
||||
@@ -407,7 +407,9 @@ export class ChatGPTApi implements LLMApi {
|
||||
});
|
||||
|
||||
const resJson = (await res.json()) as OpenAIListModelResponse;
|
||||
const chatModels = resJson.data?.filter((m) => m.id.startsWith("gpt-"));
|
||||
const chatModels = resJson.data?.filter(
|
||||
(m) => m.id.startsWith("gpt-") || m.id.startsWith("chatgpt-"),
|
||||
);
|
||||
console.log("[Models]", chatModels);
|
||||
|
||||
if (!chatModels) {
|
||||
|
||||
@@ -35,6 +35,7 @@ export function useCommand(commands: Commands = {}) {
|
||||
interface ChatCommands {
|
||||
new?: Command;
|
||||
newm?: Command;
|
||||
copy?: Command;
|
||||
next?: Command;
|
||||
prev?: Command;
|
||||
clear?: Command;
|
||||
|
||||
@@ -66,7 +66,9 @@ import {
|
||||
getMessageImages,
|
||||
isVisionModel,
|
||||
isDalle3,
|
||||
removeOutdatedEntries,
|
||||
showPlugins,
|
||||
safeLocalStorage,
|
||||
} from "../utils";
|
||||
|
||||
import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
|
||||
@@ -109,6 +111,8 @@ import { getClientConfig } from "../config/client";
|
||||
import { useAllModels } from "../utils/hooks";
|
||||
import { MultimodalContent } from "../client/api";
|
||||
|
||||
const localStorage = safeLocalStorage();
|
||||
|
||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||
loading: () => <LoadingIcon />,
|
||||
});
|
||||
@@ -898,6 +902,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: () =>
|
||||
@@ -941,7 +946,7 @@ function _Chat() {
|
||||
.onUserInput(userInput, attachImages)
|
||||
.then(() => setIsLoading(false));
|
||||
setAttachImages([]);
|
||||
localStorage.setItem(LAST_INPUT_KEY, userInput);
|
||||
chatStore.setLastInput(userInput);
|
||||
setUserInput("");
|
||||
setPromptHints([]);
|
||||
if (!isMobileScreen) inputRef.current?.focus();
|
||||
@@ -1007,7 +1012,7 @@ function _Chat() {
|
||||
userInput.length <= 0 &&
|
||||
!(e.metaKey || e.altKey || e.ctrlKey)
|
||||
) {
|
||||
setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
|
||||
setUserInput(chatStore.lastInput ?? "");
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -1028,10 +1033,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<string, number>;
|
||||
}
|
||||
session.deletedMessageIds[m.id] = Date.now();
|
||||
return false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onDelete = (msgId: string) => {
|
||||
|
||||
@@ -36,7 +36,8 @@ export function Avatar(props: { model?: ModelType; avatar?: string }) {
|
||||
if (props.model) {
|
||||
return (
|
||||
<div className="no-dark">
|
||||
{props.model?.startsWith("gpt-4") ? (
|
||||
{props.model?.startsWith("gpt-4") ||
|
||||
props.model?.startsWith("chatgpt-4o") ? (
|
||||
<BlackBotIcon className="user-avatar" />
|
||||
) : (
|
||||
<BotIcon className="user-avatar" />
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ISSUE_URL } from "../constant";
|
||||
import Locale from "../locales";
|
||||
import { showConfirm } from "./ui-lib";
|
||||
import { useSyncStore } from "../store/sync";
|
||||
import { useChatStore } from "../store/chat";
|
||||
|
||||
interface IErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
@@ -30,8 +31,7 @@ export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
|
||||
try {
|
||||
useSyncStore.getState().export();
|
||||
} finally {
|
||||
localStorage.clear();
|
||||
location.reload();
|
||||
useChatStore.getState().clearAllData();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ export function PreCode(props: { children: any }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CustomCode(props: { children: any }) {
|
||||
function CustomCode(props: { children: any; className?: string }) {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [showToggle, setShowToggle] = useState(false);
|
||||
@@ -182,6 +182,7 @@ function CustomCode(props: { children: any }) {
|
||||
return (
|
||||
<>
|
||||
<code
|
||||
className={props?.className}
|
||||
ref={ref}
|
||||
style={{
|
||||
maxHeight: collapsed ? "400px" : "none",
|
||||
|
||||
@@ -426,16 +426,7 @@ export function MaskPage() {
|
||||
const maskStore = useMaskStore();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const [filterLang, setFilterLang] = useState<Lang | undefined>(
|
||||
() => localStorage.getItem("Mask-language") as Lang | undefined,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (filterLang) {
|
||||
localStorage.setItem("Mask-language", filterLang);
|
||||
} else {
|
||||
localStorage.removeItem("Mask-language");
|
||||
}
|
||||
}, [filterLang]);
|
||||
const filterLang = maskStore.language;
|
||||
|
||||
const allMasks = maskStore
|
||||
.getAll()
|
||||
@@ -542,9 +533,9 @@ export function MaskPage() {
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
if (value === Locale.Settings.Lang.All) {
|
||||
setFilterLang(undefined);
|
||||
maskStore.setLanguage(undefined);
|
||||
} else {
|
||||
setFilterLang(value as Lang);
|
||||
maskStore.setLanguage(value as Lang);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -357,6 +357,21 @@ function SyncConfigModal(props: { onClose?: () => void }) {
|
||||
</select>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Sync.Config.EnableAutoSync.Title}
|
||||
subTitle={Locale.Settings.Sync.Config.EnableAutoSync.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={syncStore.enableAutoSync}
|
||||
onChange={(e) => {
|
||||
syncStore.update(
|
||||
(config) => (config.enableAutoSync = e.currentTarget.checked),
|
||||
);
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Sync.Config.Proxy.Title}
|
||||
subTitle={Locale.Settings.Sync.Config.Proxy.SubTitle}
|
||||
|
||||
@@ -120,12 +120,15 @@ export const getServerSideConfig = () => {
|
||||
if (disableGPT4) {
|
||||
if (customModels) customModels += ",";
|
||||
customModels += DEFAULT_MODELS.filter(
|
||||
(m) => m.name.startsWith("gpt-4") && !m.name.startsWith("gpt-4o-mini"),
|
||||
(m) =>
|
||||
(m.name.startsWith("gpt-4") || m.name.startsWith("chatgpt-4o")) &&
|
||||
!m.name.startsWith("gpt-4o-mini"),
|
||||
)
|
||||
.map((m) => "-" + m.name)
|
||||
.join(",");
|
||||
if (
|
||||
defaultModel.startsWith("gpt-4") &&
|
||||
(defaultModel.startsWith("gpt-4") ||
|
||||
defaultModel.startsWith("chatgpt-4o")) &&
|
||||
!defaultModel.startsWith("gpt-4o-mini")
|
||||
)
|
||||
defaultModel = "";
|
||||
|
||||
@@ -246,6 +246,7 @@ export const KnowledgeCutOffDate: Record<string, string> = {
|
||||
"gpt-4o": "2023-10",
|
||||
"gpt-4o-2024-05-13": "2023-10",
|
||||
"gpt-4o-2024-08-06": "2023-10",
|
||||
"chatgpt-4o-latest": "2023-10",
|
||||
"gpt-4o-mini": "2023-10",
|
||||
"gpt-4o-mini-2024-07-18": "2023-10",
|
||||
"gpt-4-vision-preview": "2023-04",
|
||||
@@ -268,6 +269,7 @@ const openaiModels = [
|
||||
"gpt-4o",
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-2024-08-06",
|
||||
"chatgpt-4o-latest",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
"gpt-4-vision-preview",
|
||||
|
||||
@@ -41,7 +41,11 @@ export default function RootLayout({
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<link rel="manifest" href="/site.webmanifest"></link>
|
||||
<link
|
||||
rel="manifest"
|
||||
href="/site.webmanifest"
|
||||
crossOrigin="use-credentials"
|
||||
></link>
|
||||
<script src="/serviceWorkerRegister.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -47,6 +47,7 @@ const cn = {
|
||||
Commands: {
|
||||
new: "新建聊天",
|
||||
newm: "从面具新建聊天",
|
||||
copy: "复制当前聊天",
|
||||
next: "下一个聊天",
|
||||
prev: "上一个聊天",
|
||||
clear: "清除上下文",
|
||||
@@ -206,6 +207,10 @@ const cn = {
|
||||
Title: "同步类型",
|
||||
SubTitle: "选择喜爱的同步服务器",
|
||||
},
|
||||
EnableAutoSync: {
|
||||
Title: "自动同步设置",
|
||||
SubTitle: "在回复完成或删除消息后自动同步数据",
|
||||
},
|
||||
Proxy: {
|
||||
Title: "启用代理",
|
||||
SubTitle: "在浏览器中同步时,必须启用代理以避免跨域限制",
|
||||
|
||||
@@ -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",
|
||||
@@ -209,6 +210,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",
|
||||
|
||||
@@ -18,10 +18,13 @@ import ar from "./ar";
|
||||
import bn from "./bn";
|
||||
import sk from "./sk";
|
||||
import { merge } from "../utils/merge";
|
||||
import { safeLocalStorage } from "@/app/utils";
|
||||
|
||||
import type { LocaleType } from "./cn";
|
||||
export type { LocaleType, PartialLocaleType } from "./cn";
|
||||
|
||||
const localStorage = safeLocalStorage();
|
||||
|
||||
const ALL_LANGS = {
|
||||
cn,
|
||||
en,
|
||||
@@ -82,17 +85,11 @@ merge(fallbackLang, targetLang);
|
||||
export default fallbackLang as LocaleType;
|
||||
|
||||
function getItem(key: string) {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
function setItem(key: string, value: string) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {}
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
function getLanguage() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
@@ -26,9 +30,12 @@ import { nanoid } from "nanoid";
|
||||
import { createPersistStore } from "../utils/store";
|
||||
import { collectModelsWithDefaultModel } from "../utils/model";
|
||||
import { useAccessStore } from "./access";
|
||||
import { isDalle3 } from "../utils";
|
||||
import { isDalle3, safeLocalStorage } from "../utils";
|
||||
import { useSyncStore } from "./sync";
|
||||
import { indexedDBStorage } from "@/app/utils/indexedDB-storage";
|
||||
|
||||
const localStorage = safeLocalStorage();
|
||||
|
||||
export type ChatMessageTool = {
|
||||
id: string;
|
||||
index?: number;
|
||||
@@ -76,6 +83,7 @@ export interface ChatSession {
|
||||
lastUpdate: number;
|
||||
lastSummarizeIndex: number;
|
||||
clearContextIndex?: number;
|
||||
deletedMessageIds?: Record<string, number>;
|
||||
|
||||
mask: Mask;
|
||||
}
|
||||
@@ -99,6 +107,7 @@ function createEmptySession(): ChatSession {
|
||||
},
|
||||
lastUpdate: Date.now(),
|
||||
lastSummarizeIndex: 0,
|
||||
deletedMessageIds: {},
|
||||
|
||||
mask: createEmptyMask(),
|
||||
};
|
||||
@@ -106,7 +115,7 @@ function createEmptySession(): ChatSession {
|
||||
|
||||
function getSummarizeModel(currentModel: string) {
|
||||
// if it is using gpt-* models, force to use 4o-mini to summarize
|
||||
if (currentModel.startsWith("gpt")) {
|
||||
if (currentModel.startsWith("gpt") || currentModel.startsWith("chatgpt")) {
|
||||
const configStore = useAppConfig.getState();
|
||||
const accessStore = useAccessStore.getState();
|
||||
const allModel = collectModelsWithDefaultModel(
|
||||
@@ -176,9 +185,20 @@ 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<string, number>,
|
||||
lastInput: "",
|
||||
};
|
||||
|
||||
export const useChatStore = createPersistStore(
|
||||
@@ -205,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;
|
||||
@@ -267,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(
|
||||
@@ -284,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,
|
||||
@@ -317,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();
|
||||
@@ -324,6 +400,8 @@ export const useChatStore = createPersistStore(
|
||||
});
|
||||
get().updateStat(message);
|
||||
get().summarizeSession();
|
||||
get().sortSessions();
|
||||
noticeCloudSync();
|
||||
},
|
||||
|
||||
async onUserInput(content: string, attachImages?: string[]) {
|
||||
@@ -476,7 +554,8 @@ export const useChatStore = createPersistStore(
|
||||
// system prompts, to get close to OpenAI Web ChatGPT
|
||||
const shouldInjectSystemPrompts =
|
||||
modelConfig.enableInjectSystemPrompts &&
|
||||
session.mask.modelConfig.model.startsWith("gpt-");
|
||||
(session.mask.modelConfig.model.startsWith("gpt-") ||
|
||||
session.mask.modelConfig.model.startsWith("chatgpt-"));
|
||||
|
||||
var systemPrompts: ChatMessage[] = [];
|
||||
systemPrompts = shouldInjectSystemPrompts
|
||||
@@ -700,6 +779,11 @@ export const useChatStore = createPersistStore(
|
||||
localStorage.clear();
|
||||
location.reload();
|
||||
},
|
||||
setLastInput(lastInput: string) {
|
||||
set({
|
||||
lastInput,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return methods;
|
||||
|
||||
@@ -23,9 +23,12 @@ export type Mask = {
|
||||
|
||||
export const DEFAULT_MASK_STATE = {
|
||||
masks: {} as Record<string, Mask>,
|
||||
language: undefined as Lang | undefined,
|
||||
};
|
||||
|
||||
export type MaskState = typeof DEFAULT_MASK_STATE;
|
||||
export type MaskState = typeof DEFAULT_MASK_STATE & {
|
||||
language?: Lang | undefined;
|
||||
};
|
||||
|
||||
export const DEFAULT_MASK_AVATAR = "gpt-bot";
|
||||
export const createEmptyMask = () =>
|
||||
@@ -102,6 +105,11 @@ export const useMaskStore = createPersistStore(
|
||||
search(text: string) {
|
||||
return Object.values(get().masks);
|
||||
},
|
||||
setLanguage(language: Lang | undefined) {
|
||||
set({
|
||||
language,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: StoreKey.Mask,
|
||||
|
||||
@@ -199,7 +199,7 @@ export const usePluginStore = createPersistStore(
|
||||
|
||||
getAsTools(ids: string[]) {
|
||||
const plugins = get().plugins;
|
||||
const selected = ids
|
||||
const selected = (ids || [])
|
||||
.map((id) => plugins[id])
|
||||
.filter((i) => i)
|
||||
.map((p) => FunctionToolService.add(p));
|
||||
|
||||
@@ -26,6 +26,7 @@ export type SyncStore = GetStoreState<typeof useSyncStore>;
|
||||
|
||||
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,
|
||||
|
||||
73
app/utils.ts
73
app/utils.ts
@@ -274,6 +274,19 @@ export function isDalle3(model: string) {
|
||||
return "dall-e-3" === model;
|
||||
}
|
||||
|
||||
export function removeOutdatedEntries(
|
||||
timeMap: Record<string, number>,
|
||||
): Record<string, number> {
|
||||
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 ||
|
||||
@@ -318,3 +331,63 @@ export function adapter(config: Record<string, unknown>) {
|
||||
: path;
|
||||
return fetch(fetchUrl as string, { ...rest, responseType: "text" });
|
||||
}
|
||||
|
||||
export function safeLocalStorage(): {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem: (key: string) => void;
|
||||
clear: () => void;
|
||||
} {
|
||||
let storage: Storage | null;
|
||||
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.localStorage) {
|
||||
storage = window.localStorage;
|
||||
} else {
|
||||
storage = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("localStorage is not available:", e);
|
||||
storage = null;
|
||||
}
|
||||
|
||||
return {
|
||||
getItem(key: string): string | null {
|
||||
if (storage) {
|
||||
return storage.getItem(key);
|
||||
} else {
|
||||
console.warn(
|
||||
`Attempted to get item "${key}" from localStorage, but localStorage is not available.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem(key: string, value: string): void {
|
||||
if (storage) {
|
||||
storage.setItem(key, value);
|
||||
} else {
|
||||
console.warn(
|
||||
`Attempted to set item "${key}" in localStorage, but localStorage is not available.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
removeItem(key: string): void {
|
||||
if (storage) {
|
||||
storage.removeItem(key);
|
||||
} else {
|
||||
console.warn(
|
||||
`Attempted to remove item "${key}" from localStorage, but localStorage is not available.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
clear(): void {
|
||||
if (storage) {
|
||||
storage.clear();
|
||||
} else {
|
||||
console.warn(
|
||||
"Attempted to clear localStorage, but localStorage is not available.",
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { StateStorage } from "zustand/middleware";
|
||||
import { get, set, del, clear } from "idb-keyval";
|
||||
import { safeLocalStorage } from "@/app/utils";
|
||||
|
||||
const localStorage = safeLocalStorage();
|
||||
|
||||
class IndexedDBStorage implements StateStorage {
|
||||
public async getItem(name: string): Promise<string | null> {
|
||||
|
||||
@@ -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<T> = {
|
||||
[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<string, ChatSession> = {};
|
||||
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<string, any> = {};
|
||||
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<T extends { lastUpdateTime?: number }>(
|
||||
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 {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "NextChat",
|
||||
"version": "2.15.0"
|
||||
"version": "2.15.1"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
|
||||
Reference in New Issue
Block a user