@lobehub/chat 1.36.46 → 1.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.ja-JP.md +8 -8
- package/README.md +8 -8
- package/README.zh-CN.md +8 -8
- package/changelog/v1.json +18 -0
- package/next.config.mjs +4 -1
- package/package.json +5 -3
- package/scripts/migrateClientDB/compile-migrations.ts +14 -0
- package/src/app/(main)/(mobile)/me/(home)/layout.tsx +2 -0
- package/src/app/(main)/chat/_layout/Desktop/index.tsx +3 -2
- package/src/app/(main)/chat/_layout/Mobile.tsx +5 -3
- package/src/app/(main)/chat/features/Migration/DBReader.ts +290 -0
- package/src/app/(main)/chat/features/Migration/UpgradeButton.tsx +4 -8
- package/src/app/(main)/chat/features/Migration/index.tsx +26 -15
- package/src/app/(main)/settings/_layout/Desktop/index.tsx +2 -0
- package/src/app/loading/Client/Content.tsx +11 -1
- package/src/app/loading/Client/Error.tsx +27 -0
- package/src/app/loading/stage.ts +8 -0
- package/src/components/FullscreenLoading/index.tsx +4 -3
- package/src/const/version.ts +1 -0
- package/src/database/client/db.test.ts +172 -0
- package/src/database/client/db.ts +246 -0
- package/src/database/client/migrations.json +289 -0
- package/src/features/InitClientDB/EnableModal.tsx +111 -0
- package/src/features/InitClientDB/ErrorResult.tsx +125 -0
- package/src/features/InitClientDB/InitIndicator.tsx +124 -0
- package/src/features/InitClientDB/PGliteSVG.tsx +22 -0
- package/src/features/InitClientDB/index.tsx +37 -0
- package/src/hooks/useCheckPluginsIsInstalled.ts +2 -2
- package/src/hooks/useFetchInstalledPlugins.ts +2 -2
- package/src/hooks/useFetchMessages.ts +2 -2
- package/src/hooks/useFetchSessions.ts +2 -2
- package/src/hooks/useFetchThreads.ts +2 -2
- package/src/hooks/useFetchTopics.ts +2 -2
- package/src/layout/GlobalProvider/StoreInitialization.tsx +2 -2
- package/src/services/baseClientService/index.ts +9 -0
- package/src/services/debug.ts +32 -34
- package/src/services/file/{client.test.ts → _deprecated.test.ts} +1 -1
- package/src/services/file/index.ts +6 -2
- package/src/services/file/pglite.test.ts +198 -0
- package/src/services/file/pglite.ts +84 -0
- package/src/services/file/type.ts +4 -3
- package/src/services/github.ts +17 -0
- package/src/services/import/index.ts +6 -2
- package/src/services/import/pglite.test.ts +997 -0
- package/src/services/import/pglite.ts +34 -0
- package/src/services/message/{client.test.ts → _deprecated.test.ts} +1 -1
- package/src/services/message/{client.ts → _deprecated.ts} +2 -0
- package/src/services/message/index.ts +6 -2
- package/src/services/message/pglite.test.ts +430 -0
- package/src/services/message/pglite.ts +118 -0
- package/src/services/message/server.ts +9 -9
- package/src/services/message/type.ts +3 -4
- package/src/services/plugin/{client.test.ts → _deprecated.test.ts} +1 -1
- package/src/services/plugin/index.ts +6 -2
- package/src/services/plugin/pglite.test.ts +175 -0
- package/src/services/plugin/pglite.ts +51 -0
- package/src/services/session/{client.test.ts → _deprecated.test.ts} +1 -1
- package/src/services/session/{client.ts → _deprecated.ts} +1 -1
- package/src/services/session/index.ts +6 -2
- package/src/services/session/pglite.test.ts +411 -0
- package/src/services/session/pglite.ts +184 -0
- package/src/services/session/type.ts +14 -1
- package/src/services/topic/client.test.ts +1 -1
- package/src/services/topic/index.ts +6 -3
- package/src/services/topic/pglite.test.ts +212 -0
- package/src/services/topic/pglite.ts +85 -0
- package/src/services/user/{client.test.ts → _deprecated.test.ts} +1 -2
- package/src/services/user/index.ts +8 -2
- package/src/services/user/pglite.test.ts +98 -0
- package/src/services/user/pglite.ts +92 -0
- package/src/store/chat/slices/builtinTool/action.test.ts +4 -5
- package/src/store/global/actions/clientDb.ts +51 -0
- package/src/store/global/initialState.ts +13 -0
- package/src/store/global/selectors.ts +24 -3
- package/src/store/global/store.ts +3 -1
- package/src/store/session/slices/sessionGroup/reducer.test.ts +6 -6
- package/src/store/user/slices/common/action.test.ts +1 -1
- package/src/store/user/slices/common/action.ts +2 -4
- package/src/types/clientDB.ts +29 -0
- package/src/types/importer.ts +17 -5
- package/src/types/meta.ts +0 -9
- package/src/types/session/sessionGroup.ts +3 -3
- package/src/services/message/index.test.ts +0 -48
- /package/src/services/file/{client.ts → _deprecated.ts} +0 -0
- /package/src/services/import/{client.ts → _deprecated.ts} +0 -0
- /package/src/services/plugin/{client.ts → _deprecated.ts} +0 -0
- /package/src/services/topic/{client.ts → _deprecated.ts} +0 -0
- /package/src/services/user/{client.ts → _deprecated.ts} +0 -0
@@ -0,0 +1,212 @@
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
3
|
+
|
4
|
+
import { clientDB, initializeDB } from '@/database/client/db';
|
5
|
+
import { sessions, topics, users } from '@/database/schemas';
|
6
|
+
import { ChatTopic } from '@/types/topic';
|
7
|
+
|
8
|
+
import { ClientService } from './pglite';
|
9
|
+
|
10
|
+
// Mock data
|
11
|
+
const userId = 'topic-user-test';
|
12
|
+
const sessionId = 'topic-session';
|
13
|
+
const mockTopicId = 'mock-topic-id';
|
14
|
+
|
15
|
+
const mockTopic = {
|
16
|
+
id: mockTopicId,
|
17
|
+
title: 'Mock Topic',
|
18
|
+
};
|
19
|
+
|
20
|
+
const topicService = new ClientService(userId);
|
21
|
+
|
22
|
+
beforeEach(async () => {
|
23
|
+
await initializeDB();
|
24
|
+
|
25
|
+
await clientDB.delete(users);
|
26
|
+
|
27
|
+
// 创建测试数据
|
28
|
+
await clientDB.transaction(async (tx) => {
|
29
|
+
await tx.insert(users).values({ id: userId });
|
30
|
+
await tx.insert(sessions).values({ id: sessionId, userId });
|
31
|
+
await tx.insert(topics).values({ ...mockTopic, sessionId, userId });
|
32
|
+
});
|
33
|
+
});
|
34
|
+
|
35
|
+
describe('TopicService', () => {
|
36
|
+
describe('createTopic', () => {
|
37
|
+
it('should create a topic and return its id', async () => {
|
38
|
+
// Setup
|
39
|
+
const createParams = {
|
40
|
+
title: 'New Topic',
|
41
|
+
sessionId: sessionId,
|
42
|
+
};
|
43
|
+
|
44
|
+
// Execute
|
45
|
+
const topicId = await topicService.createTopic(createParams);
|
46
|
+
|
47
|
+
// Assert
|
48
|
+
expect(topicId).toBeDefined();
|
49
|
+
});
|
50
|
+
|
51
|
+
it('should throw an error if topic creation fails', async () => {
|
52
|
+
// Setup
|
53
|
+
const createParams = {
|
54
|
+
title: 'New Topic',
|
55
|
+
sessionId: 123 as any, // sessionId should be string
|
56
|
+
};
|
57
|
+
|
58
|
+
// Execute & Assert
|
59
|
+
await expect(topicService.createTopic(createParams)).rejects.toThrowError();
|
60
|
+
});
|
61
|
+
});
|
62
|
+
|
63
|
+
describe('getTopics', () => {
|
64
|
+
// Example for getTopics
|
65
|
+
it('should query topics with given parameters', async () => {
|
66
|
+
// Setup
|
67
|
+
const queryParams = { sessionId };
|
68
|
+
|
69
|
+
// Execute
|
70
|
+
const data = await topicService.getTopics(queryParams);
|
71
|
+
|
72
|
+
// Assert
|
73
|
+
expect(data[0]).toMatchObject(mockTopic);
|
74
|
+
});
|
75
|
+
});
|
76
|
+
|
77
|
+
describe('updateTopic', () => {
|
78
|
+
// Example for updateFavorite
|
79
|
+
it('should toggle favorite status of a topic', async () => {
|
80
|
+
// Execute
|
81
|
+
const result = await topicService.updateTopic(mockTopicId, { favorite: true });
|
82
|
+
|
83
|
+
// Assert
|
84
|
+
expect(result[0].favorite).toBeTruthy();
|
85
|
+
});
|
86
|
+
|
87
|
+
it('should update the title of a topic', async () => {
|
88
|
+
// Setup
|
89
|
+
const newTitle = 'Updated Topic Title';
|
90
|
+
|
91
|
+
// Execute
|
92
|
+
const result = await topicService.updateTopic(mockTopicId, { title: newTitle });
|
93
|
+
|
94
|
+
// Assert
|
95
|
+
expect(result[0].title).toEqual(newTitle);
|
96
|
+
});
|
97
|
+
});
|
98
|
+
|
99
|
+
describe('removeTopic', () => {
|
100
|
+
it('should remove a topic by id', async () => {
|
101
|
+
// Execute
|
102
|
+
await topicService.removeTopic(mockTopicId);
|
103
|
+
const result = await clientDB.query.topics.findFirst({ where: eq(topics.id, mockTopicId) });
|
104
|
+
|
105
|
+
// Assert
|
106
|
+
expect(result).toBeUndefined();
|
107
|
+
});
|
108
|
+
});
|
109
|
+
|
110
|
+
describe('removeTopics', () => {
|
111
|
+
it('should remove all topics with a given session id', async () => {
|
112
|
+
// Setup
|
113
|
+
const sessionId = 'session-id';
|
114
|
+
|
115
|
+
// Execute
|
116
|
+
await topicService.removeTopics(sessionId);
|
117
|
+
const result = await clientDB.query.topics.findMany({
|
118
|
+
where: eq(topics.sessionId, sessionId),
|
119
|
+
});
|
120
|
+
|
121
|
+
expect(result.length).toEqual(0);
|
122
|
+
});
|
123
|
+
});
|
124
|
+
|
125
|
+
describe('batchRemoveTopics', () => {
|
126
|
+
it('should batch remove topics', async () => {
|
127
|
+
await clientDB.insert(topics).values([{ id: 'topic-id-1', title: 'topic-title', userId }]);
|
128
|
+
// Setup
|
129
|
+
const topicIds = [mockTopicId, 'another-topic-id'];
|
130
|
+
|
131
|
+
// Execute
|
132
|
+
await topicService.batchRemoveTopics(topicIds);
|
133
|
+
|
134
|
+
const count = await clientDB.$count(topics);
|
135
|
+
|
136
|
+
// Assert
|
137
|
+
expect(count).toBe(1);
|
138
|
+
});
|
139
|
+
});
|
140
|
+
|
141
|
+
describe('removeAllTopic', () => {
|
142
|
+
it('should clear all topics from the table', async () => {
|
143
|
+
// Execute
|
144
|
+
await topicService.removeAllTopic();
|
145
|
+
|
146
|
+
const count = await clientDB.$count(topics);
|
147
|
+
// Assert
|
148
|
+
expect(count).toBe(0);
|
149
|
+
});
|
150
|
+
});
|
151
|
+
|
152
|
+
describe('batchCreateTopics', () => {
|
153
|
+
it('should batch create topics', async () => {
|
154
|
+
// Execute
|
155
|
+
const result = await topicService.batchCreateTopics([
|
156
|
+
{ id: 'topic-id-1', title: 'topic-title' },
|
157
|
+
{ id: 'topic-id-2', title: 'topic-title' },
|
158
|
+
] as ChatTopic[]);
|
159
|
+
|
160
|
+
// Assert
|
161
|
+
expect(result.success).toBeTruthy();
|
162
|
+
expect(result.added).toBe(2);
|
163
|
+
});
|
164
|
+
});
|
165
|
+
|
166
|
+
describe('getAllTopics', () => {
|
167
|
+
it('should retrieve all topics', async () => {
|
168
|
+
await clientDB.insert(topics).values([
|
169
|
+
{ id: 'topic-id-1', title: 'topic-title', userId },
|
170
|
+
{ id: 'topic-id-2', title: 'topic-title', userId },
|
171
|
+
]);
|
172
|
+
// Execute
|
173
|
+
const result = await topicService.getAllTopics();
|
174
|
+
|
175
|
+
// Assert
|
176
|
+
expect(result.length).toEqual(3);
|
177
|
+
});
|
178
|
+
});
|
179
|
+
|
180
|
+
describe('searchTopics', () => {
|
181
|
+
it('should return all topics that match the keyword', async () => {
|
182
|
+
// Setup
|
183
|
+
const keyword = 'Topic';
|
184
|
+
|
185
|
+
// Execute
|
186
|
+
const result = await topicService.searchTopics(keyword, sessionId);
|
187
|
+
|
188
|
+
// Assert
|
189
|
+
expect(result.length).toEqual(1);
|
190
|
+
});
|
191
|
+
it('should return empty topic if not match the keyword', async () => {
|
192
|
+
// Setup
|
193
|
+
const keyword = 'search';
|
194
|
+
|
195
|
+
// Execute
|
196
|
+
const result = await topicService.searchTopics(keyword, sessionId);
|
197
|
+
|
198
|
+
// Assert
|
199
|
+
expect(result.length).toEqual(0);
|
200
|
+
});
|
201
|
+
});
|
202
|
+
|
203
|
+
describe('countTopics', () => {
|
204
|
+
it('should return topic counts', async () => {
|
205
|
+
// Execute
|
206
|
+
const result = await topicService.countTopics();
|
207
|
+
|
208
|
+
// Assert
|
209
|
+
expect(result).toBe(1);
|
210
|
+
});
|
211
|
+
});
|
212
|
+
});
|
@@ -0,0 +1,85 @@
|
|
1
|
+
import { INBOX_SESSION_ID } from '@/const/session';
|
2
|
+
import { clientDB } from '@/database/client/db';
|
3
|
+
import { TopicModel } from '@/database/server/models/topic';
|
4
|
+
import { BaseClientService } from '@/services/baseClientService';
|
5
|
+
import { ChatTopic } from '@/types/topic';
|
6
|
+
|
7
|
+
import { CreateTopicParams, ITopicService, QueryTopicParams } from './type';
|
8
|
+
|
9
|
+
export class ClientService extends BaseClientService implements ITopicService {
|
10
|
+
private get topicModel(): TopicModel {
|
11
|
+
return new TopicModel(clientDB as any, this.userId);
|
12
|
+
}
|
13
|
+
|
14
|
+
async createTopic(params: CreateTopicParams): Promise<string> {
|
15
|
+
const item = await this.topicModel.create({
|
16
|
+
...params,
|
17
|
+
sessionId: this.toDbSessionId(params.sessionId),
|
18
|
+
} as any);
|
19
|
+
|
20
|
+
if (!item) {
|
21
|
+
throw new Error('topic create Error');
|
22
|
+
}
|
23
|
+
|
24
|
+
return item.id;
|
25
|
+
}
|
26
|
+
|
27
|
+
async batchCreateTopics(importTopics: ChatTopic[]) {
|
28
|
+
const data = await this.topicModel.batchCreate(importTopics as any);
|
29
|
+
|
30
|
+
return { added: data.length, ids: [], skips: [], success: true };
|
31
|
+
}
|
32
|
+
|
33
|
+
async cloneTopic(id: string, newTitle?: string) {
|
34
|
+
const data = await this.topicModel.duplicate(id, newTitle);
|
35
|
+
return data.topic.id;
|
36
|
+
}
|
37
|
+
|
38
|
+
async getTopics(params: QueryTopicParams) {
|
39
|
+
const data = await this.topicModel.query({
|
40
|
+
...params,
|
41
|
+
sessionId: this.toDbSessionId(params.sessionId),
|
42
|
+
});
|
43
|
+
return data as unknown as Promise<ChatTopic[]>;
|
44
|
+
}
|
45
|
+
|
46
|
+
async searchTopics(keyword: string, sessionId?: string) {
|
47
|
+
const data = await this.topicModel.queryByKeyword(keyword, this.toDbSessionId(sessionId));
|
48
|
+
|
49
|
+
return data as unknown as Promise<ChatTopic[]>;
|
50
|
+
}
|
51
|
+
|
52
|
+
async getAllTopics() {
|
53
|
+
const data = await this.topicModel.queryAll();
|
54
|
+
|
55
|
+
return data as unknown as Promise<ChatTopic[]>;
|
56
|
+
}
|
57
|
+
|
58
|
+
async countTopics() {
|
59
|
+
return this.topicModel.count();
|
60
|
+
}
|
61
|
+
|
62
|
+
async updateTopic(id: string, data: Partial<ChatTopic>) {
|
63
|
+
return this.topicModel.update(id, data as any);
|
64
|
+
}
|
65
|
+
|
66
|
+
async removeTopic(id: string) {
|
67
|
+
return this.topicModel.delete(id);
|
68
|
+
}
|
69
|
+
|
70
|
+
async removeTopics(sessionId: string) {
|
71
|
+
return this.topicModel.batchDeleteBySessionId(this.toDbSessionId(sessionId));
|
72
|
+
}
|
73
|
+
|
74
|
+
async batchRemoveTopics(topics: string[]) {
|
75
|
+
return this.topicModel.batchDelete(topics);
|
76
|
+
}
|
77
|
+
|
78
|
+
async removeAllTopic() {
|
79
|
+
return this.topicModel.deleteAll();
|
80
|
+
}
|
81
|
+
|
82
|
+
private toDbSessionId(sessionId?: string | null) {
|
83
|
+
return sessionId === INBOX_SESSION_ID ? null : sessionId;
|
84
|
+
}
|
85
|
+
}
|
@@ -4,9 +4,8 @@ import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { UserModel } from '@/database/_deprecated/models/user';
|
5
5
|
import { UserPreference } from '@/types/user';
|
6
6
|
import { UserSettings } from '@/types/user/settings';
|
7
|
-
import { AsyncLocalStorage } from '@/utils/localStorage';
|
8
7
|
|
9
|
-
import { ClientService } from './
|
8
|
+
import { ClientService } from './_deprecated';
|
10
9
|
|
11
10
|
vi.mock('@/database/_deprecated/models/user', () => ({
|
12
11
|
UserModel: {
|
@@ -1,5 +1,11 @@
|
|
1
|
-
import { ClientService } from './
|
1
|
+
import { ClientService as DeprecatedService } from './_deprecated';
|
2
|
+
import { ClientService } from './pglite';
|
2
3
|
import { ServerService } from './server';
|
3
4
|
|
5
|
+
const clientService =
|
6
|
+
process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite' ? new ClientService() : new DeprecatedService();
|
7
|
+
|
4
8
|
export const userService =
|
5
|
-
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() :
|
9
|
+
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : clientService;
|
10
|
+
|
11
|
+
export const userClientService = clientService;
|
@@ -0,0 +1,98 @@
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
2
|
+
import { DeepPartial } from 'utility-types';
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
4
|
+
|
5
|
+
import { clientDB, initializeDB } from '@/database/client/db';
|
6
|
+
import { userSettings, users } from '@/database/schemas';
|
7
|
+
import { UserPreference } from '@/types/user';
|
8
|
+
import { UserSettings } from '@/types/user/settings';
|
9
|
+
|
10
|
+
import { ClientService } from './pglite';
|
11
|
+
|
12
|
+
const mockUser = {
|
13
|
+
avatar: 'avatar.png',
|
14
|
+
settings: { themeMode: 'light' } as unknown as UserSettings,
|
15
|
+
uuid: 'user-id',
|
16
|
+
};
|
17
|
+
|
18
|
+
const mockPreference = {
|
19
|
+
useCmdEnterToSend: true,
|
20
|
+
} as UserPreference;
|
21
|
+
const clientService = new ClientService(mockUser.uuid);
|
22
|
+
|
23
|
+
beforeEach(async () => {
|
24
|
+
vi.clearAllMocks();
|
25
|
+
|
26
|
+
await initializeDB();
|
27
|
+
await clientDB.delete(users);
|
28
|
+
|
29
|
+
await clientDB.insert(users).values({ id: mockUser.uuid, avatar: 'avatar.png' });
|
30
|
+
await clientDB
|
31
|
+
.insert(userSettings)
|
32
|
+
.values({ id: mockUser.uuid, general: { themeMode: 'light' } });
|
33
|
+
});
|
34
|
+
|
35
|
+
describe('ClientService', () => {
|
36
|
+
it('should get user state correctly', async () => {
|
37
|
+
const spyOn = vi
|
38
|
+
.spyOn(clientService['preferenceStorage'], 'getFromLocalStorage')
|
39
|
+
.mockResolvedValue(mockPreference);
|
40
|
+
|
41
|
+
const userState = await clientService.getUserState();
|
42
|
+
|
43
|
+
expect(userState).toMatchObject({
|
44
|
+
avatar: mockUser.avatar,
|
45
|
+
isOnboard: true,
|
46
|
+
canEnablePWAGuide: false,
|
47
|
+
hasConversation: false,
|
48
|
+
canEnableTrace: false,
|
49
|
+
preference: mockPreference,
|
50
|
+
settings: { general: { themeMode: 'light' } },
|
51
|
+
userId: mockUser.uuid,
|
52
|
+
});
|
53
|
+
expect(spyOn).toHaveBeenCalledTimes(1);
|
54
|
+
});
|
55
|
+
|
56
|
+
it('should update user settings correctly', async () => {
|
57
|
+
const settingsPatch: DeepPartial<UserSettings> = { general: { themeMode: 'dark' } };
|
58
|
+
|
59
|
+
await clientService.updateUserSettings(settingsPatch);
|
60
|
+
|
61
|
+
const result = await clientDB.query.userSettings.findFirst({
|
62
|
+
where: eq(userSettings.id, mockUser.uuid),
|
63
|
+
});
|
64
|
+
|
65
|
+
expect(result).toMatchObject(settingsPatch);
|
66
|
+
});
|
67
|
+
|
68
|
+
it('should reset user settings correctly', async () => {
|
69
|
+
await clientService.resetUserSettings();
|
70
|
+
|
71
|
+
const result = await clientDB.query.userSettings.findFirst({
|
72
|
+
where: eq(userSettings.id, mockUser.uuid),
|
73
|
+
});
|
74
|
+
|
75
|
+
expect(result).toBeUndefined();
|
76
|
+
});
|
77
|
+
|
78
|
+
it('should update user avatar correctly', async () => {
|
79
|
+
const newAvatar = 'new-avatar.png';
|
80
|
+
|
81
|
+
await clientService.updateAvatar(newAvatar);
|
82
|
+
});
|
83
|
+
|
84
|
+
it('should update user preference correctly', async () => {
|
85
|
+
const newPreference = {
|
86
|
+
useCmdEnterToSend: false,
|
87
|
+
} as UserPreference;
|
88
|
+
|
89
|
+
const spyOn = vi
|
90
|
+
.spyOn(clientService['preferenceStorage'], 'saveToLocalStorage')
|
91
|
+
.mockResolvedValue(undefined);
|
92
|
+
|
93
|
+
await clientService.updatePreference(newPreference);
|
94
|
+
|
95
|
+
expect(spyOn).toHaveBeenCalledWith(newPreference);
|
96
|
+
expect(spyOn).toHaveBeenCalledTimes(1);
|
97
|
+
});
|
98
|
+
});
|
@@ -0,0 +1,92 @@
|
|
1
|
+
import { DeepPartial } from 'utility-types';
|
2
|
+
|
3
|
+
import { clientDB } from '@/database/client/db';
|
4
|
+
import { users } from '@/database/schemas';
|
5
|
+
import { MessageModel } from '@/database/server/models/message';
|
6
|
+
import { SessionModel } from '@/database/server/models/session';
|
7
|
+
import { UserModel } from '@/database/server/models/user';
|
8
|
+
import { BaseClientService } from '@/services/baseClientService';
|
9
|
+
import { UserGuide, UserInitializationState, UserPreference } from '@/types/user';
|
10
|
+
import { UserSettings } from '@/types/user/settings';
|
11
|
+
import { AsyncLocalStorage } from '@/utils/localStorage';
|
12
|
+
|
13
|
+
import { IUserService } from './type';
|
14
|
+
|
15
|
+
export class ClientService extends BaseClientService implements IUserService {
|
16
|
+
private preferenceStorage: AsyncLocalStorage<UserPreference>;
|
17
|
+
|
18
|
+
private get userModel(): UserModel {
|
19
|
+
return new UserModel(clientDB as any, this.userId);
|
20
|
+
}
|
21
|
+
private get messageModel(): MessageModel {
|
22
|
+
return new MessageModel(clientDB as any, this.userId);
|
23
|
+
}
|
24
|
+
private get sessionModel(): SessionModel {
|
25
|
+
return new SessionModel(clientDB as any, this.userId);
|
26
|
+
}
|
27
|
+
|
28
|
+
constructor(userId?: string) {
|
29
|
+
super(userId);
|
30
|
+
this.preferenceStorage = new AsyncLocalStorage('LOBE_PREFERENCE');
|
31
|
+
}
|
32
|
+
|
33
|
+
async getUserState(): Promise<UserInitializationState> {
|
34
|
+
// if user not exist in the db, create one to make sure the user exist
|
35
|
+
await this.makeSureUserExist();
|
36
|
+
|
37
|
+
const state = await this.userModel.getUserState((encryptKeyVaultsStr) =>
|
38
|
+
encryptKeyVaultsStr ? JSON.parse(encryptKeyVaultsStr) : {},
|
39
|
+
);
|
40
|
+
|
41
|
+
const user = await UserModel.findById(clientDB as any, this.userId);
|
42
|
+
const messageCount = await this.messageModel.count();
|
43
|
+
const sessionCount = await this.sessionModel.count();
|
44
|
+
|
45
|
+
return {
|
46
|
+
...state,
|
47
|
+
avatar: user?.avatar as string,
|
48
|
+
canEnablePWAGuide: messageCount >= 4,
|
49
|
+
canEnableTrace: messageCount >= 4,
|
50
|
+
hasConversation: messageCount > 0 || sessionCount > 0,
|
51
|
+
isOnboard: true,
|
52
|
+
preference: await this.preferenceStorage.getFromLocalStorage(),
|
53
|
+
};
|
54
|
+
}
|
55
|
+
|
56
|
+
updateUserSettings = async (value: DeepPartial<UserSettings>) => {
|
57
|
+
const { keyVaults, ...res } = value;
|
58
|
+
|
59
|
+
return this.userModel.updateSetting({ ...res, keyVaults: JSON.stringify(keyVaults) });
|
60
|
+
};
|
61
|
+
|
62
|
+
resetUserSettings = async () => {
|
63
|
+
return this.userModel.deleteSetting();
|
64
|
+
};
|
65
|
+
|
66
|
+
async updateAvatar(avatar: string) {
|
67
|
+
await this.userModel.updateUser({ avatar });
|
68
|
+
}
|
69
|
+
|
70
|
+
async updatePreference(preference: Partial<UserPreference>) {
|
71
|
+
await this.preferenceStorage.saveToLocalStorage(preference);
|
72
|
+
}
|
73
|
+
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars,unused-imports/no-unused-vars
|
75
|
+
async updateGuide(guide: Partial<UserGuide>) {
|
76
|
+
throw new Error('Method not implemented.');
|
77
|
+
}
|
78
|
+
|
79
|
+
async makeSureUserExist() {
|
80
|
+
const existUsers = await clientDB.query.users.findMany();
|
81
|
+
|
82
|
+
let user: { id: string };
|
83
|
+
if (existUsers.length === 0) {
|
84
|
+
const result = await clientDB.insert(users).values({ id: this.userId }).returning();
|
85
|
+
user = result[0];
|
86
|
+
} else {
|
87
|
+
user = existUsers[0];
|
88
|
+
}
|
89
|
+
|
90
|
+
return user;
|
91
|
+
}
|
92
|
+
}
|
@@ -2,7 +2,7 @@ import { act, renderHook } from '@testing-library/react';
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest';
|
3
3
|
|
4
4
|
import { fileService } from '@/services/file';
|
5
|
-
import { ClientService } from '@/services/file/
|
5
|
+
import { ClientService } from '@/services/file/_deprecated';
|
6
6
|
import { messageService } from '@/services/message';
|
7
7
|
import { imageGenerationService } from '@/services/textToImage';
|
8
8
|
import { uploadService } from '@/services/upload';
|
@@ -47,10 +47,9 @@ describe('chatToolSlice', () => {
|
|
47
47
|
url: '',
|
48
48
|
});
|
49
49
|
vi.spyOn(result.current, 'toggleDallEImageLoading');
|
50
|
-
vi.spyOn(ClientService.prototype, 'checkFileHash').mockImplementation(
|
51
|
-
isExist: false,
|
52
|
-
|
53
|
-
}));
|
50
|
+
vi.spyOn(ClientService.prototype, 'checkFileHash').mockImplementation(
|
51
|
+
async () => ({ isExist: false }) as any,
|
52
|
+
);
|
54
53
|
|
55
54
|
await act(async () => {
|
56
55
|
await result.current.generateImageFromPrompts(prompts, messageId);
|
@@ -0,0 +1,51 @@
|
|
1
|
+
import { SWRResponse } from 'swr';
|
2
|
+
import type { StateCreator } from 'zustand/vanilla';
|
3
|
+
|
4
|
+
import { useOnlyFetchOnceSWR } from '@/libs/swr';
|
5
|
+
import type { GlobalStore } from '@/store/global';
|
6
|
+
import { DatabaseLoadingState, OnStageChange } from '@/types/clientDB';
|
7
|
+
|
8
|
+
type InitClientDBParams = { onStateChange: OnStageChange };
|
9
|
+
/**
|
10
|
+
* 设置操作
|
11
|
+
*/
|
12
|
+
export interface GlobalClientDBAction {
|
13
|
+
initializeClientDB: (params?: InitClientDBParams) => Promise<void>;
|
14
|
+
markPgliteEnabled: () => void;
|
15
|
+
useInitClientDB: (params?: InitClientDBParams) => SWRResponse;
|
16
|
+
}
|
17
|
+
|
18
|
+
export const clientDBSlice: StateCreator<
|
19
|
+
GlobalStore,
|
20
|
+
[['zustand/devtools', never]],
|
21
|
+
[],
|
22
|
+
GlobalClientDBAction
|
23
|
+
> = (set, get) => ({
|
24
|
+
initializeClientDB: async (params) => {
|
25
|
+
// if the db has started initialized or not error, just skip.
|
26
|
+
if (
|
27
|
+
get().initClientDBStage !== DatabaseLoadingState.Idle &&
|
28
|
+
get().initClientDBStage !== DatabaseLoadingState.Error
|
29
|
+
)
|
30
|
+
return;
|
31
|
+
|
32
|
+
const { initializeDB } = await import('@/database/client/db');
|
33
|
+
await initializeDB({
|
34
|
+
onError: (error) => {
|
35
|
+
set({ initClientDBError: error });
|
36
|
+
},
|
37
|
+
onProgress: (data) => {
|
38
|
+
set({ initClientDBProcess: data });
|
39
|
+
},
|
40
|
+
onStateChange: (state) => {
|
41
|
+
set({ initClientDBStage: state });
|
42
|
+
params?.onStateChange?.(state);
|
43
|
+
},
|
44
|
+
});
|
45
|
+
},
|
46
|
+
markPgliteEnabled: () => {
|
47
|
+
get().updateSystemStatus({ isEnablePglite: true });
|
48
|
+
},
|
49
|
+
useInitClientDB: (params) =>
|
50
|
+
useOnlyFetchOnceSWR('initClientDB', () => get().initializeClientDB(params)),
|
51
|
+
});
|
@@ -1,5 +1,6 @@
|
|
1
1
|
import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime';
|
2
2
|
|
3
|
+
import { DatabaseLoadingState } from '@/types/clientDB';
|
3
4
|
import { SessionDefaultGroup } from '@/types/session';
|
4
5
|
import { AsyncLocalStorage } from '@/utils/localStorage';
|
5
6
|
|
@@ -37,6 +38,10 @@ export interface SystemStatus {
|
|
37
38
|
hidePWAInstaller?: boolean;
|
38
39
|
hideThreadLimitAlert?: boolean;
|
39
40
|
inputHeight: number;
|
41
|
+
/**
|
42
|
+
* 应用初始化时不启用 PGLite,只有当用户手动开启时才启用
|
43
|
+
*/
|
44
|
+
isEnablePglite?: boolean;
|
40
45
|
mobileShowPortal?: boolean;
|
41
46
|
mobileShowTopic?: boolean;
|
42
47
|
sessionsWidth: number;
|
@@ -50,6 +55,13 @@ export interface SystemStatus {
|
|
50
55
|
|
51
56
|
export interface GlobalState {
|
52
57
|
hasNewVersion?: boolean;
|
58
|
+
initClientDBError?: Error;
|
59
|
+
initClientDBProcess?: { costTime?: number; phase: 'wasm' | 'dependencies'; progress: number };
|
60
|
+
/**
|
61
|
+
* 客户端数据库初始化状态
|
62
|
+
* 启动时为 Idle,完成为 Ready,报错为 Error
|
63
|
+
*/
|
64
|
+
initClientDBStage: DatabaseLoadingState;
|
53
65
|
isMobile?: boolean;
|
54
66
|
isStatusInit?: boolean;
|
55
67
|
latestVersion?: string;
|
@@ -76,6 +88,7 @@ export const INITIAL_STATUS = {
|
|
76
88
|
} satisfies SystemStatus;
|
77
89
|
|
78
90
|
export const initialState: GlobalState = {
|
91
|
+
initClientDBStage: DatabaseLoadingState.Idle,
|
79
92
|
isMobile: false,
|
80
93
|
isStatusInit: false,
|
81
94
|
sidebarKey: SidebarTabKey.Chat,
|
@@ -1,4 +1,6 @@
|
|
1
|
+
import { isServerMode, isUsePgliteDB } from '@/const/version';
|
1
2
|
import { GlobalStore } from '@/store/global';
|
3
|
+
import { DatabaseLoadingState } from '@/types/clientDB';
|
2
4
|
|
3
5
|
import { INITIAL_STATUS } from './initialState';
|
4
6
|
|
@@ -22,17 +24,36 @@ const filePanelWidth = (s: GlobalStore) => s.status.filePanelWidth;
|
|
22
24
|
const inputHeight = (s: GlobalStore) => s.status.inputHeight;
|
23
25
|
const threadInputHeight = (s: GlobalStore) => s.status.threadInputHeight;
|
24
26
|
|
25
|
-
const isPgliteNotEnabled = () =>
|
27
|
+
const isPgliteNotEnabled = (s: GlobalStore) =>
|
28
|
+
isUsePgliteDB && !isServerMode && s.isStatusInit && !s.status.isEnablePglite;
|
26
29
|
|
27
|
-
|
30
|
+
/**
|
31
|
+
* 当且仅当 client db 模式,且 pglite 未初始化完成时返回 true
|
32
|
+
*/
|
33
|
+
const isPgliteNotInited = (s: GlobalStore) =>
|
34
|
+
isUsePgliteDB &&
|
35
|
+
s.isStatusInit &&
|
36
|
+
s.status.isEnablePglite &&
|
37
|
+
s.initClientDBStage !== DatabaseLoadingState.Ready;
|
28
38
|
|
29
|
-
|
39
|
+
/**
|
40
|
+
* 当且仅当 client db 模式,且 pglite 初始化完成时返回 true
|
41
|
+
*/
|
42
|
+
const isPgliteInited = (s: GlobalStore): boolean =>
|
43
|
+
(s.isStatusInit &&
|
44
|
+
s.status.isEnablePglite &&
|
45
|
+
s.initClientDBStage === DatabaseLoadingState.Ready) ||
|
46
|
+
false;
|
47
|
+
|
48
|
+
// 这个变量控制 clientdb 是否完成初始化,正常来说,只有 pgliteDB 模式下,才会存在变化,其他时候都是 true
|
49
|
+
const isDBInited = (s: GlobalStore): boolean => (isUsePgliteDB ? isPgliteInited(s) : true);
|
30
50
|
|
31
51
|
export const systemStatusSelectors = {
|
32
52
|
filePanelWidth,
|
33
53
|
hidePWAInstaller,
|
34
54
|
inZenMode,
|
35
55
|
inputHeight,
|
56
|
+
isDBInited,
|
36
57
|
isPgliteInited,
|
37
58
|
isPgliteNotEnabled,
|
38
59
|
isPgliteNotInited,
|
@@ -5,15 +5,17 @@ import { StateCreator } from 'zustand/vanilla';
|
|
5
5
|
|
6
6
|
import { createDevtools } from '../middleware/createDevtools';
|
7
7
|
import { type GlobalStoreAction, globalActionSlice } from './action';
|
8
|
+
import { type GlobalClientDBAction, clientDBSlice } from './actions/clientDb';
|
8
9
|
import { type GlobalState, initialState } from './initialState';
|
9
10
|
|
10
11
|
// =============== 聚合 createStoreFn ============ //
|
11
12
|
|
12
|
-
export type GlobalStore = GlobalState & GlobalStoreAction;
|
13
|
+
export type GlobalStore = GlobalState & GlobalStoreAction & GlobalClientDBAction;
|
13
14
|
|
14
15
|
const createStore: StateCreator<GlobalStore, [['zustand/devtools', never]]> = (...parameters) => ({
|
15
16
|
...initialState,
|
16
17
|
...globalActionSlice(...parameters),
|
18
|
+
...clientDBSlice(...parameters),
|
17
19
|
});
|
18
20
|
|
19
21
|
// =============== 实装 useStore ============ //
|