Compare commits

...

63 Commits

Author SHA1 Message Date
GH Action - Upstream Sync
f80da8a263 Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-10-11 01:07:41 +00:00
Lloyd Zhou
c5074f0aa4 Merge pull request #5581 from ConnectAI-E/feature/gemini-functioncall
google gemini support function call
2024-10-10 21:02:36 +08:00
Lloyd Zhou
ba58018a15 Merge pull request #5211 from ConnectAI-E/feature/jest
feat: jest
2024-10-10 21:02:05 +08:00
GH Action - Upstream Sync
98ab561607 Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-10-10 01:07:14 +00:00
织梦人
9a025ae196 Merge tag 'v2.15.4' 2024-10-09 19:08:43 +08:00
lloydzhou
4ae34ea3ee merge main 2024-10-09 18:27:23 +08:00
GH Action - Upstream Sync
c4ae73d8a1 Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-10-09 01:07:24 +00:00
Dogtiti
acf9fa36f9 Merge branch 'main' of https://github.com/ConnectAI-E/ChatGPT-Next-Web into feature/jest 2024-10-08 10:30:47 +08:00
Dogtiti
461154bb03 fix: format package 2024-10-08 10:29:42 +08:00
GH Action - Upstream Sync
31900cbff3 Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-10-07 01:10:00 +00:00
GH Action - Upstream Sync
c6657d3d0c Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-10-04 01:07:46 +00:00
lloydzhou
450766a44b google gemini support function call 2024-10-03 20:28:15 +08:00
GH Action - Upstream Sync
cf7c6f2b9a Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-10-01 01:14:19 +00:00
GH Action - Upstream Sync
41242cae9b Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-09-30 01:10:08 +00:00
GH Action - Upstream Sync
89edebd93c Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-09-28 01:07:15 +00:00
织梦人
60bd3c56c1 Merge remote-tracking branch 'up/main'
# Conflicts:
#	app/store/chat.ts
2024-09-26 20:47:29 +08:00
GH Action - Upstream Sync
659a389fd4 Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-09-14 01:03:57 +00:00
GH Action - Upstream Sync
144fdc9b7c Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-09-13 01:04:37 +00:00
织梦人
35f52886c4 Merge remote-tracking branch 'up/main'
# Conflicts:
#	app/store/chat.ts
2024-09-12 09:19:04 +08:00
织梦人
9551f5dfc6 Merge branch 'website'
# Conflicts:
#	app/components/chat.tsx
#	app/utils.ts
#	app/utils/sync.ts
2024-09-07 13:00:33 +08:00
织梦人
370ce3eeca Merge remote-tracking branch 'up/website' into website
# Conflicts:
#	app/components/chat.tsx
#	app/utils.ts
2024-09-07 12:51:37 +08:00
织梦人
5ae4921ee0 fix: 优化云同步功能,自动去除掉非首个空会话,避免多个空会话在中间,更方便管理 2024-09-06 20:59:53 +08:00
lloydzhou
6f3d7530b9 Merge remote-tracking branch 'origin/main' into website 2024-09-06 20:18:21 +08:00
织梦人
6dc868154d fix: 优化云同步功能,使access配置按更新时间合并,解决自定义模型配置在同步后丢失的问题 2024-09-05 21:52:25 +08:00
织梦人
ccacfec918 feat: 优化聊天窗口,使支持复制会话 2024-09-05 21:19:03 +08:00
GH Action - Upstream Sync
c204031ea7 Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-09-05 01:04:08 +00:00
GH Action - Upstream Sync
2bf72d0324 Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-08-30 01:03:42 +00:00
GH Action - Upstream Sync
e8c7ac0c45 Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-08-28 01:02:59 +00:00
GH Action - Upstream Sync
0638db146e Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-08-25 01:06:24 +00:00
GH Action - Upstream Sync
2d68f179d7 Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-08-22 01:02:16 +00:00
GH Action - Upstream Sync
f1d69cb312 Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-08-21 01:00:45 +00:00
李超
31baa10363 fix: 解决会话列表按最新操作时间倒序排序,当前会话判断失败的bug 2024-08-20 22:23:17 +08:00
李超
2fdb35bcc8 fix: 解决会话列表按最新操作时间倒序排序,当前会话判断失败的bug 2024-08-20 22:21:38 +08:00
李超
5c51fd2ed8 feat: 优化会话列表按最后更新时间倒序排序,更方便查看与管理 2024-08-20 21:19:31 +08:00
李超
d0b7ddc1d6 feat: 优化会话列表按最后更新时间倒序排序,更方便查看与管理 2024-08-20 21:18:28 +08:00
GH Action - Upstream Sync
0745b6498d Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-08-20 01:00:40 +00:00
织梦人
fc97c4b06f 更新docker.yml,使image名自适应,不影响主仓库
(cherry picked from commit fdb89af355)
2024-08-18 21:20:20 +08:00
织梦人
fdb89af355 更新docker.yml,使image名自适应,不影响主仓库 2024-08-18 21:19:06 +08:00
织梦人
e515f0f957 更新docker.yml, 修改自动编译的镜像为自己的账号
(cherry picked from commit b2336f5ed9)
2024-08-18 20:23:18 +08:00
织梦人
31f282970b Merge remote-tracking branch 'up/website' into website 2024-08-18 20:22:30 +08:00
织梦人
b2336f5ed9 更新docker.yml, 修改自动编译的镜像为自己的账号 2024-08-18 19:55:55 +08:00
GH Action - Upstream Sync
0a6ddda992 Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-08-17 00:59:04 +00:00
lloydzhou
5e1064a5c8 Merge branch 'main' into website 2024-08-16 16:58:30 +08:00
李超
2ee2d50ae6 Merge remote-tracking branch 'up/website' into website 2024-08-15 22:59:03 +08:00
李超
eae593d660 feat: Add automatic data synchronization settings and implementation, enabling auto-sync after completing replies or deleting conversations 2024-08-15 22:42:23 +08:00
织梦人
621b1480c2 Merge branch 'ChatGPTNextWeb:main' into main 2024-08-15 22:41:31 +08:00
李超
4b22aaf979 feat: Add automatic data synchronization settings and implementation, enabling auto-sync after completing replies or deleting conversations 2024-08-15 22:39:30 +08:00
GH Action - Upstream Sync
93bfb55822 Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web 2024-08-14 01:01:30 +00:00
李超
648e60028d feat: The cloud synchronization feature is enhanced to support the synchronization of deleted conversations and deleted messages 2024-08-08 22:02:15 +08:00
李超
4f876f3e65 Merge tag 'v2.14.1' into website
# Conflicts:
#	app/components/chat.tsx
#	app/utils.ts
2024-08-08 12:48:36 +08:00
lloydzhou
faac0d9817 Merge remote-tracking branch 'origin/main' into website 2024-08-06 22:45:16 +08:00
Dogtiti
1287e39cc6 feat: run test before build 2024-08-06 19:24:47 +08:00
Dogtiti
1ef2aa35e9 feat: jest 2024-08-06 18:03:27 +08:00
李超
22c79595fb feat: The cloud synchronization feature is enhanced to support the synchronization of deleted conversations and deleted messages 2024-08-03 20:53:36 +08:00
李超
5065091b74 fix: Fixed the issue that WebDAV synchronization could not check the status and failed during the first backup
(cherry picked from commit 716899c030)
2024-08-03 12:41:36 +08:00
李超
22f61295bc fix: Fixed an issue where the sample of the reply content was displayed out of order
(cherry picked from commit 8498cadae8)
2024-08-03 12:41:36 +08:00
lloydzhou
c440637ad0 Merge remote-tracking branch 'origin/main' into website 2024-07-27 01:32:47 +08:00
lloydzhou
284d33bcdf Merge remote-tracking branch 'origin/main' into website 2024-07-19 18:37:32 +08:00
lloydzhou
d9573973ca Merge remote-tracking branch 'origin/main' into website 2024-07-13 21:31:15 +08:00
fred-bf
cd354cf045 Merge pull request #4685 from ChatGPTNextWeb/main
feat: update upstream
2024-05-14 17:40:46 +08:00
fred-bf
1cce87acaa Merge pull request #4181 from ChatGPTNextWeb/main
merge main
2024-03-01 11:10:11 +08:00
fred-bf
78c4084501 Merge pull request #4148 from ChatGPTNextWeb/main
feat: catch up latest commit
2024-02-27 10:43:15 +08:00
Fred Liang
1d0a40b9e8 chore: low the google safety setting to avoid unexpected blocking 2023-12-31 19:50:06 +08:00
19 changed files with 2316 additions and 169 deletions

View File

@@ -19,26 +19,26 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- -
name: Extract metadata (tags, labels) for Docker name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: yidadaa/chatgpt-next-web images: ${{ secrets.DOCKER_USERNAME }}/chatgpt-next-web
tags: | tags: |
type=raw,value=latest type=raw,value=latest
type=ref,event=tag type=ref,event=tag
- -
name: Set up QEMU name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
- -
name: Build and push Docker image name: Build and push Docker image
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
@@ -49,4 +49,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max

View File

@@ -7,21 +7,25 @@ import {
LLMUsage, LLMUsage,
SpeechOptions, SpeechOptions,
} from "../api"; } from "../api";
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; import {
useAccessStore,
useAppConfig,
useChatStore,
usePluginStore,
ChatMessageTool,
} from "@/app/store";
import { stream } from "@/app/utils/chat";
import { getClientConfig } from "@/app/config/client"; import { getClientConfig } from "@/app/config/client";
import { GEMINI_BASE_URL } from "@/app/constant"; import { GEMINI_BASE_URL } from "@/app/constant";
import Locale from "../../locales";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import { prettyObject } from "@/app/utils/format";
import { import {
getMessageTextContent, getMessageTextContent,
getMessageImages, getMessageImages,
isVisionModel, isVisionModel,
} from "@/app/utils"; } from "@/app/utils";
import { preProcessImageContent } from "@/app/utils/chat"; import { preProcessImageContent } from "@/app/utils/chat";
import { nanoid } from "nanoid";
import { RequestPayload } from "./openai";
import { fetch } from "@/app/utils/stream"; import { fetch } from "@/app/utils/stream";
export class GeminiProApi implements LLMApi { export class GeminiProApi implements LLMApi {
@@ -178,115 +182,81 @@ export class GeminiProApi implements LLMApi {
); );
if (shouldStream) { if (shouldStream) {
let responseText = ""; const [tools, funcs] = usePluginStore
let remainText = ""; .getState()
let finished = false; .getAsTools(
useChatStore.getState().currentSession().mask?.plugin || [],
);
return stream(
chatPath,
requestPayload,
getHeaders(),
// @ts-ignore
[{ functionDeclarations: tools.map((tool) => tool.function) }],
funcs,
controller,
// parseSSE
(text: string, runTools: ChatMessageTool[]) => {
// console.log("parseSSE", text, runTools);
const chunkJson = JSON.parse(text);
const finish = () => { const functionCall = chunkJson?.candidates
if (!finished) { ?.at(0)
finished = true; ?.content.parts.at(0)?.functionCall;
options.onFinish(responseText + remainText); if (functionCall) {
} const { name, args } = functionCall;
}; runTools.push({
id: nanoid(),
// animate response to make it looks smooth type: "function",
function animateResponseText() { function: {
if (finished || controller.signal.aborted) { name,
responseText += remainText; arguments: JSON.stringify(args), // utils.chat call function, using JSON.parse
finish(); },
return; });
} }
return chunkJson?.candidates?.at(0)?.content.parts.at(0)?.text;
if (remainText.length > 0) { },
const fetchCount = Math.max(1, Math.round(remainText.length / 60)); // processToolMessage, include tool_calls message and tool call results
const fetchText = remainText.slice(0, fetchCount); (
responseText += fetchText; requestPayload: RequestPayload,
remainText = remainText.slice(fetchCount); toolCallMessage: any,
options.onUpdate?.(responseText, fetchText); toolCallResult: any[],
} ) => {
// @ts-ignore
requestAnimationFrame(animateResponseText); requestPayload?.contents?.splice(
} // @ts-ignore
requestPayload?.contents?.length,
// start animaion 0,
animateResponseText(); {
role: "model",
controller.signal.onabort = finish; parts: toolCallMessage.tool_calls.map(
(tool: ChatMessageTool) => ({
fetchEventSource(chatPath, { functionCall: {
fetch: fetch as any, name: tool?.function?.name,
...chatPayload, args: JSON.parse(tool?.function?.arguments as string),
async onopen(res) { },
clearTimeout(requestTimeoutId); }),
const contentType = res.headers.get("content-type"); ),
console.log( },
"[Gemini] request response content type: ", // @ts-ignore
contentType, ...toolCallResult.map((result) => ({
role: "function",
parts: [
{
functionResponse: {
name: result.name,
response: {
name: result.name,
content: result.content, // TODO just text content...
},
},
},
],
})),
); );
if (contentType?.startsWith("text/plain")) {
responseText = await res.clone().text();
return finish();
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [responseText];
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
if (res.status === 401) {
responseTexts.push(Locale.Error.Unauthorized);
}
if (extraInfo) {
responseTexts.push(extraInfo);
}
responseText = responseTexts.join("\n\n");
return finish();
}
}, },
onmessage(msg) { options,
if (msg.data === "[DONE]" || finished) { );
return finish();
}
const text = msg.data;
try {
const json = JSON.parse(text);
const delta = apiClient.extractMessage(json);
if (delta) {
remainText += delta;
}
const blockReason = json?.promptFeedback?.blockReason;
if (blockReason) {
// being blocked
console.log(`[Google] [Safety Ratings] result:`, blockReason);
}
} catch (e) {
console.error("[Request] parse error", text, msg);
}
},
onclose() {
finish();
},
onerror(e) {
options.onError?.(e);
throw e;
},
openWhenHidden: true,
});
} else { } else {
const res = await fetch(chatPath, chatPayload); const res = await fetch(chatPath, chatPayload);
clearTimeout(requestTimeoutId); clearTimeout(requestTimeoutId);

View File

@@ -35,6 +35,7 @@ export function useCommand(commands: Commands = {}) {
interface ChatCommands { interface ChatCommands {
new?: Command; new?: Command;
newm?: Command; newm?: Command;
copy?: Command;
next?: Command; next?: Command;
prev?: Command; prev?: Command;
clear?: Command; clear?: Command;

View File

@@ -70,6 +70,7 @@ import {
getMessageImages, getMessageImages,
isVisionModel, isVisionModel,
isDalle3, isDalle3,
removeOutdatedEntries,
showPlugins, showPlugins,
safeLocalStorage, safeLocalStorage,
} from "../utils"; } from "../utils";
@@ -984,6 +985,7 @@ function _Chat() {
const chatCommands = useChatCommand({ const chatCommands = useChatCommand({
new: () => chatStore.newSession(), new: () => chatStore.newSession(),
newm: () => navigate(Path.NewChat), newm: () => navigate(Path.NewChat),
copy: () => chatStore.copySession(),
prev: () => chatStore.nextSession(-1), prev: () => chatStore.nextSession(-1),
next: () => chatStore.nextSession(1), next: () => chatStore.nextSession(1),
clear: () => clear: () =>
@@ -1115,10 +1117,20 @@ function _Chat() {
}; };
const deleteMessage = (msgId?: string) => { const deleteMessage = (msgId?: string) => {
chatStore.updateCurrentSession( chatStore.updateCurrentSession((session) => {
(session) => session.deletedMessageIds &&
(session.messages = session.messages.filter((m) => m.id !== msgId)), 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) => { const onDelete = (msgId: string) => {

View File

@@ -360,6 +360,21 @@ function SyncConfigModal(props: { onClose?: () => void }) {
</select> </select>
</ListItem> </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 <ListItem
title={Locale.Settings.Sync.Config.Proxy.Title} title={Locale.Settings.Sync.Config.Proxy.Title}
subTitle={Locale.Settings.Sync.Config.Proxy.SubTitle} subTitle={Locale.Settings.Sync.Config.Proxy.SubTitle}

View File

@@ -62,6 +62,7 @@ const cn = {
Commands: { Commands: {
new: "新建聊天", new: "新建聊天",
newm: "从面具新建聊天", newm: "从面具新建聊天",
copy: "复制当前聊天",
next: "下一个聊天", next: "下一个聊天",
prev: "上一个聊天", prev: "上一个聊天",
clear: "清除上下文", clear: "清除上下文",
@@ -232,6 +233,10 @@ const cn = {
Title: "同步类型", Title: "同步类型",
SubTitle: "选择喜爱的同步服务器", SubTitle: "选择喜爱的同步服务器",
}, },
EnableAutoSync: {
Title: "自动同步设置",
SubTitle: "在回复完成或删除消息后自动同步数据",
},
Proxy: { Proxy: {
Title: "启用代理", Title: "启用代理",
SubTitle: "在浏览器中同步时,必须启用代理以避免跨域限制", SubTitle: "在浏览器中同步时,必须启用代理以避免跨域限制",

View File

@@ -63,6 +63,7 @@ const en: LocaleType = {
Commands: { Commands: {
new: "Start a new chat", new: "Start a new chat",
newm: "Start a new chat with mask", newm: "Start a new chat with mask",
copy: "Copy the current Chat",
next: "Next Chat", next: "Next Chat",
prev: "Previous Chat", prev: "Previous Chat",
clear: "Clear Context", clear: "Clear Context",
@@ -234,6 +235,11 @@ const en: LocaleType = {
Title: "Sync Type", Title: "Sync Type",
SubTitle: "Choose your favorite sync service", SubTitle: "Choose your favorite sync service",
}, },
EnableAutoSync: {
Title: "Auto Sync Settings",
SubTitle:
"Automatically synchronize data after replying or deleting messages",
},
Proxy: { Proxy: {
Title: "Enable CORS Proxy", Title: "Enable CORS Proxy",
SubTitle: "Enable a proxy to avoid cross-origin restrictions", SubTitle: "Enable a proxy to avoid cross-origin restrictions",

View File

@@ -211,7 +211,7 @@ export const useAccessStore = createPersistStore(
}) })
.then((res: DangerConfig) => { .then((res: DangerConfig) => {
console.log("[Config] got config from server", res); console.log("[Config] got config from server", res);
set(() => ({ ...res })); set(() => ({ lastUpdateTime: Date.now(), ...res }));
}) })
.catch(() => { .catch(() => {
console.error("[Config] failed to fetch config"); console.error("[Config] failed to fetch config");

View File

@@ -1,4 +1,8 @@
import { getMessageTextContent, trimTopic } from "../utils"; import {
getMessageTextContent,
trimTopic,
removeOutdatedEntries,
} from "../utils";
import { indexedDBStorage } from "@/app/utils/indexedDB-storage"; import { indexedDBStorage } from "@/app/utils/indexedDB-storage";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -29,6 +33,7 @@ import { ModelConfig, ModelType, useAppConfig } from "./config";
import { useAccessStore } from "./access"; import { useAccessStore } from "./access";
import { collectModelsWithDefaultModel } from "../utils/model"; import { collectModelsWithDefaultModel } from "../utils/model";
import { createEmptyMask, Mask } from "./mask"; import { createEmptyMask, Mask } from "./mask";
import { useSyncStore } from "./sync";
const localStorage = safeLocalStorage(); const localStorage = safeLocalStorage();
@@ -80,6 +85,7 @@ export interface ChatSession {
lastUpdate: number; lastUpdate: number;
lastSummarizeIndex: number; lastSummarizeIndex: number;
clearContextIndex?: number; clearContextIndex?: number;
deletedMessageIds?: Record<string, number>;
mask: Mask; mask: Mask;
} }
@@ -103,6 +109,7 @@ function createEmptySession(): ChatSession {
}, },
lastUpdate: Date.now(), lastUpdate: Date.now(),
lastSummarizeIndex: 0, lastSummarizeIndex: 0,
deletedMessageIds: {},
mask: createEmptyMask(), mask: createEmptyMask(),
}; };
@@ -188,9 +195,19 @@ function fillTemplateWith(input: string, modelConfig: ModelConfig) {
return output; 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 = { const DEFAULT_CHAT_STATE = {
sessions: [createEmptySession()], sessions: [createEmptySession()],
currentSessionIndex: 0, currentSessionIndex: 0,
deletedSessionIds: {} as Record<string, number>,
lastInput: "", lastInput: "",
}; };
@@ -240,6 +257,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) { moveSession(from: number, to: number) {
set((state) => { set((state) => {
const { sessions, currentSessionIndex: oldIndex } = state; const { sessions, currentSessionIndex: oldIndex } = state;
@@ -302,7 +341,18 @@ export const useChatStore = createPersistStore(
if (!deletedSession) return; if (!deletedSession) return;
const sessions = get().sessions.slice(); 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; const currentIndex = get().currentSessionIndex;
let nextIndex = Math.min( let nextIndex = Math.min(
@@ -319,19 +369,24 @@ export const useChatStore = createPersistStore(
const restoreState = { const restoreState = {
currentSessionIndex: get().currentSessionIndex, currentSessionIndex: get().currentSessionIndex,
sessions: get().sessions.slice(), sessions: get().sessions.slice(),
deletedSessionIds: get().deletedSessionIds,
}; };
set(() => ({ set(() => ({
currentSessionIndex: nextIndex, currentSessionIndex: nextIndex,
sessions, sessions,
deletedSessionIds,
})); }));
noticeCloudSync();
showToast( showToast(
Locale.Home.DeleteToast, Locale.Home.DeleteToast,
{ {
text: Locale.Home.Revert, text: Locale.Home.Revert,
onClick() { onClick() {
set(() => restoreState); set(() => restoreState);
noticeCloudSync();
}, },
}, },
5000, 5000,
@@ -352,6 +407,24 @@ export const useChatStore = createPersistStore(
return session; 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) { onNewMessage(message: ChatMessage) {
get().updateCurrentSession((session) => { get().updateCurrentSession((session) => {
session.messages = session.messages.concat(); session.messages = session.messages.concat();
@@ -359,6 +432,8 @@ export const useChatStore = createPersistStore(
}); });
get().updateStat(message); get().updateStat(message);
get().summarizeSession(); get().summarizeSession();
get().sortSessions();
noticeCloudSync();
}, },
async onUserInput(content: string, attachImages?: string[]) { async onUserInput(content: string, attachImages?: string[]) {

View File

@@ -151,7 +151,7 @@ export const usePromptStore = createPersistStore(
if (typeof window === "undefined") { if (typeof window === "undefined") {
return; return;
} }
const PROMPT_URL = "./prompts.json"; const PROMPT_URL = "./prompts.json";
type PromptList = Array<[string, string]>; type PromptList = Array<[string, string]>;

View File

@@ -24,6 +24,7 @@ export type SyncStore = GetStoreState<typeof useSyncStore>;
const DEFAULT_SYNC_STATE = { const DEFAULT_SYNC_STATE = {
provider: ProviderType.WebDAV, provider: ProviderType.WebDAV,
enableAutoSync: true,
useProxy: true, useProxy: true,
proxyUrl: ApiPath.Cors as string, proxyUrl: ApiPath.Cors as string,
@@ -43,6 +44,8 @@ const DEFAULT_SYNC_STATE = {
lastProvider: "", lastProvider: "",
}; };
let lastSyncTime = 0;
export const useSyncStore = createPersistStore( export const useSyncStore = createPersistStore(
DEFAULT_SYNC_STATE, DEFAULT_SYNC_STATE,
(set, get) => ({ (set, get) => ({
@@ -89,6 +92,16 @@ export const useSyncStore = createPersistStore(
}, },
async sync() { async sync() {
if (lastSyncTime && lastSyncTime >= Date.now() - 800) {
return;
}
lastSyncTime = Date.now();
const enableAutoSync = get().enableAutoSync;
if (!enableAutoSync) {
return;
}
const localState = getLocalAppState(); const localState = getLocalAppState();
const provider = get().provider; const provider = get().provider;
const config = get()[provider]; const config = get()[provider];
@@ -103,9 +116,7 @@ export const useSyncStore = createPersistStore(
); );
return; return;
} else { } else {
const parsedRemoteState = JSON.parse( const parsedRemoteState = JSON.parse(remoteState) as AppState;
await client.get(config.username),
) as AppState;
mergeAppState(localState, parsedRemoteState); mergeAppState(localState, parsedRemoteState);
setLocalAppState(localState); setLocalAppState(localState);
} }
@@ -123,6 +134,14 @@ export const useSyncStore = createPersistStore(
const client = this.getClient(); const client = this.getClient();
return await client.check(); return await client.check();
}, },
async autoSync() {
const { lastSyncTime, provider } = get();
const syncStore = useSyncStore.getState();
if (lastSyncTime && syncStore.cloudSync()) {
syncStore.sync();
}
},
}), }),
{ {
name: StoreKey.Sync, name: StoreKey.Sync,

View File

@@ -274,6 +274,19 @@ export function isDalle3(model: string) {
return "dall-e-3" === model; 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) { export function showPlugins(provider: ServiceProvider, model: string) {
if ( if (
provider == ServiceProvider.OpenAI || provider == ServiceProvider.OpenAI ||
@@ -285,6 +298,9 @@ export function showPlugins(provider: ServiceProvider, model: string) {
if (provider == ServiceProvider.Anthropic && !model.includes("claude-2")) { if (provider == ServiceProvider.Anthropic && !model.includes("claude-2")) {
return true; return true;
} }
if (provider == ServiceProvider.Google && !model.includes("vision")) {
return true;
}
return false; return false;
} }

View File

@@ -250,6 +250,7 @@ export function stream(
return e.toString(); return e.toString();
}) })
.then((content) => ({ .then((content) => ({
name: tool.function.name,
role: "tool", role: "tool",
content, content,
tool_call_id: tool.id, tool_call_id: tool.id,

View File

@@ -8,6 +8,7 @@ import { useMaskStore } from "../store/mask";
import { usePromptStore } from "../store/prompt"; import { usePromptStore } from "../store/prompt";
import { StoreKey } from "../constant"; import { StoreKey } from "../constant";
import { merge } from "./merge"; import { merge } from "./merge";
import { removeOutdatedEntries } from "@/app/utils";
type NonFunctionKeys<T> = { type NonFunctionKeys<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => any ? never : K; [K in keyof T]: T[K] extends (...args: any[]) => any ? never : K;
@@ -65,7 +66,10 @@ type StateMerger = {
const MergeStates: StateMerger = { const MergeStates: StateMerger = {
[StoreKey.Chat]: (localState, remoteState) => { [StoreKey.Chat]: (localState, remoteState) => {
// merge sessions // merge sessions
const currentSession = useChatStore.getState().currentSession();
const localSessions: Record<string, ChatSession> = {}; const localSessions: Record<string, ChatSession> = {};
const localDeletedSessionIds = localState.deletedSessionIds || {};
localState.sessions.forEach((s) => (localSessions[s.id] = s)); localState.sessions.forEach((s) => (localSessions[s.id] = s));
remoteState.sessions.forEach((remoteSession) => { remoteState.sessions.forEach((remoteSession) => {
@@ -75,29 +79,98 @@ const MergeStates: StateMerger = {
const localSession = localSessions[remoteSession.id]; const localSession = localSessions[remoteSession.id];
if (!localSession) { if (!localSession) {
// if remote session is new, just merge it // if remote session is new, just merge it
localState.sessions.push(remoteSession); if (
(localDeletedSessionIds[remoteSession.id] || -1) <
remoteSession.lastUpdate
) {
localState.sessions.push(remoteSession);
}
} else { } else {
// if both have the same session id, merge the messages // if both have the same session id, merge the messages
const localMessageIds = new Set(localSession.messages.map((v) => v.id)); const localMessageIds = new Set(localSession.messages.map((v) => v.id));
const localDeletedMessageIds = localSession.deletedMessageIds || {};
remoteSession.messages.forEach((m) => { remoteSession.messages.forEach((m) => {
if (!localMessageIds.has(m.id)) { 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 // sort local messages with date field in asc order
localSession.messages.sort( localSession.messages.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), (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 // sort local sessions with date field in desc order
localState.sessions.sort( localState.sessions.sort(
(a, b) => (a, b) =>
new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime(), 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; return localState;
}, },
[StoreKey.Prompt]: (localState, remoteState) => { [StoreKey.Prompt]: (localState, remoteState) => {
@@ -153,9 +226,9 @@ export function mergeWithUpdate<T extends { lastUpdateTime?: number }>(
remoteState: T, remoteState: T,
) { ) {
const localUpdateTime = localState.lastUpdateTime ?? 0; const localUpdateTime = localState.lastUpdateTime ?? 0;
const remoteUpdateTime = localState.lastUpdateTime ?? 1; const remoteUpdateTime = remoteState.lastUpdateTime ?? 1;
if (localUpdateTime < remoteUpdateTime) { if (localUpdateTime >= remoteUpdateTime) {
merge(remoteState, localState); merge(remoteState, localState);
return { ...remoteState }; return { ...remoteState };
} else { } else {

21
jest.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { Config } from "jest";
import nextJest from "next/jest.js";
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: "./",
});
// Add any custom config to be passed to Jest
const config: Config = {
coverageProvider: "v8",
testEnvironment: "jsdom",
testMatch: ["**/*.test.js", "**/*.test.ts", "**/*.test.jsx", "**/*.test.tsx"],
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/$1",
},
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
export default createJestConfig(config);

2
jest.setup.ts Normal file
View File

@@ -0,0 +1,2 @@
// Learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";

View File

@@ -6,16 +6,18 @@
"mask": "npx tsx app/masks/build.ts", "mask": "npx tsx app/masks/build.ts",
"mask:watch": "npx watch \"yarn mask\" app/masks", "mask:watch": "npx watch \"yarn mask\" app/masks",
"dev": "concurrently -r \"yarn run mask:watch\" \"next dev\"", "dev": "concurrently -r \"yarn run mask:watch\" \"next dev\"",
"build": "yarn mask && cross-env BUILD_MODE=standalone next build", "build": "yarn test:ci && yarn mask && cross-env BUILD_MODE=standalone next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"export": "yarn mask && cross-env BUILD_MODE=export BUILD_APP=1 next build", "export": "yarn test:ci && yarn mask && cross-env BUILD_MODE=export BUILD_APP=1 next build",
"export:dev": "concurrently -r \"yarn mask:watch\" \"cross-env BUILD_MODE=export BUILD_APP=1 next dev\"", "export:dev": "concurrently -r \"yarn mask:watch\" \"cross-env BUILD_MODE=export BUILD_APP=1 next dev\"",
"app:dev": "concurrently -r \"yarn mask:watch\" \"yarn tauri dev\"", "app:dev": "concurrently -r \"yarn mask:watch\" \"yarn tauri dev\"",
"app:build": "yarn mask && yarn tauri build", "app:build": "yarn test:ci && yarn mask && yarn tauri build",
"prompts": "node ./scripts/fetch-prompts.mjs", "prompts": "node ./scripts/fetch-prompts.mjs",
"prepare": "husky install", "prepare": "husky install",
"proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev" "proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev",
"test": "jest --watch",
"test:ci": "jest --ci"
}, },
"dependencies": { "dependencies": {
"@fortaine/fetch-event-source": "^3.0.6", "@fortaine/fetch-event-source": "^3.0.6",
@@ -54,6 +56,9 @@
"devDependencies": { "devDependencies": {
"@tauri-apps/api": "^1.6.0", "@tauri-apps/api": "^1.6.0",
"@tauri-apps/cli": "1.5.11", "@tauri-apps/cli": "1.5.11",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@types/jest": "^29.5.12",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^20.11.30", "@types/node": "^20.11.30",
@@ -69,8 +74,11 @@
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unused-imports": "^3.2.0", "eslint-plugin-unused-imports": "^3.2.0",
"husky": "^8.0.0", "husky": "^8.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"lint-staged": "^13.2.2", "lint-staged": "^13.2.2",
"prettier": "^3.0.2", "prettier": "^3.0.2",
"ts-node": "^10.9.2",
"tsx": "^4.16.0", "tsx": "^4.16.0",
"typescript": "5.2.2", "typescript": "5.2.2",
"watch": "^1.0.2", "watch": "^1.0.2",

9
test/sum-module.test.ts Normal file
View File

@@ -0,0 +1,9 @@
function sum(a: number, b: number) {
return a + b;
}
describe("sum module", () => {
test("adds 1 + 2 to equal 3", () => {
expect(sum(1, 2)).toBe(3);
});
});

1970
yarn.lock

File diff suppressed because it is too large Load Diff