@lobehub/chat 1.12.10 → 1.12.11

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.
@@ -18,7 +18,7 @@ jobs:
18
18
  --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
19
19
  ports:
20
20
  - 5432:5432
21
-
21
+
22
22
  steps:
23
23
  - uses: actions/checkout@v4
24
24
 
@@ -40,7 +40,8 @@ jobs:
40
40
  DATABASE_DRIVER: node
41
41
  NEXT_PUBLIC_SERVICE_MODE: server
42
42
  KEY_VAULTS_SECRET: LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=
43
- NEXT_PUBLIC_S3_DOMAIN: https://example.com
43
+ S3_PUBLIC_DOMAIN: https://example.com
44
+ APP_URL: https://home.com
44
45
 
45
46
  - name: Test App Coverage
46
47
  run: bun run test-app:coverage
@@ -39,7 +39,8 @@ jobs:
39
39
  DATABASE_DRIVER: node
40
40
  NEXT_PUBLIC_SERVICE_MODE: server
41
41
  KEY_VAULTS_SECRET: LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=
42
- NEXT_PUBLIC_S3_DOMAIN: https://example.com
42
+ S3_PUBLIC_DOMAIN: https://example.com
43
+ APP_URL: https://home.com
43
44
 
44
45
  - name: Upload Server coverage to Codecov
45
46
  uses: codecov/codecov-action@v4
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ### [Version 1.12.11](https://github.com/lobehub/lobe-chat/compare/v1.12.10...v1.12.11)
6
+
7
+ <sup>Released on **2024-08-23**</sup>
8
+
9
+ #### 🐛 Bug Fixes
10
+
11
+ - **misc**: Remove orphan chunks if there is no related file.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's fixed
19
+
20
+ - **misc**: Remove orphan chunks if there is no related file, closes [#3578](https://github.com/lobehub/lobe-chat/issues/3578) ([36bcaf3](https://github.com/lobehub/lobe-chat/commit/36bcaf3))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
5
30
  ### [Version 1.12.10](https://github.com/lobehub/lobe-chat/compare/v1.12.9...v1.12.10)
6
31
 
7
32
  <sup>Released on **2024-08-23**</sup>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.12.10",
3
+ "version": "1.12.11",
4
4
  "description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
package/src/config/app.ts CHANGED
@@ -2,6 +2,8 @@
2
2
  import { createEnv } from '@t3-oss/env-nextjs';
3
3
  import { z } from 'zod';
4
4
 
5
+ import { isServerMode } from '@/const/version';
6
+
5
7
  declare global {
6
8
  // eslint-disable-next-line @typescript-eslint/no-namespace
7
9
  namespace NodeJS {
@@ -10,14 +12,19 @@ declare global {
10
12
  }
11
13
  }
12
14
  }
15
+ const isInVercel = process.env.VERCEL === '1';
13
16
 
14
- export const getAppConfig = () => {
15
- const ACCESS_CODES = process.env.ACCESS_CODE?.split(',').filter(Boolean) || [];
16
- const isInVercel = process.env.VERCEL === '1';
17
+ const vercelUrl = `https://${process.env.VERCEL_URL}`;
17
18
 
18
- const vercelUrl = `https://${process.env.VERCEL_URL}`;
19
+ const APP_URL = process.env.APP_URL ? process.env.APP_URL : isInVercel ? vercelUrl : undefined;
19
20
 
20
- const APP_URL = process.env.APP_URL ? process.env.APP_URL : isInVercel ? vercelUrl : undefined;
21
+ // only throw error in server mode and server side
22
+ if (typeof window === 'undefined' && isServerMode && !APP_URL) {
23
+ throw new Error('`APP_URL` is required in server mode');
24
+ }
25
+
26
+ export const getAppConfig = () => {
27
+ const ACCESS_CODES = process.env.ACCESS_CODE?.split(',').filter(Boolean) || [];
21
28
 
22
29
  return createEnv({
23
30
  client: {
package/src/config/db.ts CHANGED
@@ -11,6 +11,8 @@ export const getServerDBConfig = () => {
11
11
  DATABASE_TEST_URL: process.env.DATABASE_TEST_URL,
12
12
  DATABASE_URL: process.env.DATABASE_URL,
13
13
 
14
+ DISABLE_REMOVE_GLOBAL_FILE: process.env.DISABLE_REMOVE_GLOBAL_FILE === '1',
15
+
14
16
  KEY_VAULTS_SECRET: process.env.KEY_VAULTS_SECRET,
15
17
 
16
18
  NEXT_PUBLIC_ENABLED_SERVER_SERVICE: process.env.NEXT_PUBLIC_SERVICE_MODE === 'server',
@@ -20,6 +22,8 @@ export const getServerDBConfig = () => {
20
22
  DATABASE_TEST_URL: z.string().optional(),
21
23
  DATABASE_URL: z.string().optional(),
22
24
 
25
+ DISABLE_REMOVE_GLOBAL_FILE: z.boolean().optional(),
26
+
23
27
  KEY_VAULTS_SECRET: z.string().optional(),
24
28
  },
25
29
  });
@@ -98,7 +98,66 @@ describe('ChunkModel', () => {
98
98
  });
99
99
  });
100
100
 
101
- // Add more test cases for other methods...
101
+ describe('deleteOrphanChunks', () => {
102
+ it('should delete orphaned chunks', async () => {
103
+ // Create orphaned chunks
104
+ await serverDB
105
+ .insert(chunks)
106
+ .values([
107
+ { text: 'Orphan Chunk 1', userId },
108
+ { text: 'Orphan Chunk 2', userId },
109
+ ])
110
+ .returning();
111
+
112
+ // Create a non-orphaned chunk
113
+ const [nonOrphanChunk] = await serverDB
114
+ .insert(chunks)
115
+ .values([{ text: 'Non-Orphan Chunk', userId }])
116
+ .returning();
117
+
118
+ await serverDB.insert(fileChunks).values([{ fileId: '1', chunkId: nonOrphanChunk.id }]);
119
+
120
+ // Execute the method
121
+ await chunkModel.deleteOrphanChunks();
122
+
123
+ // Check if orphaned chunks are deleted
124
+ const remainingChunks = await serverDB.query.chunks.findMany();
125
+ expect(remainingChunks).toHaveLength(1);
126
+ expect(remainingChunks[0].id).toBe(nonOrphanChunk.id);
127
+ });
128
+
129
+ it('should not delete any chunks when there are no orphans', async () => {
130
+ // Create non-orphaned chunks
131
+ const [chunk1, chunk2] = await serverDB
132
+ .insert(chunks)
133
+ .values([
134
+ { text: 'Chunk 1', userId },
135
+ { text: 'Chunk 2', userId },
136
+ ])
137
+ .returning();
138
+
139
+ await serverDB.insert(fileChunks).values([
140
+ { fileId: '1', chunkId: chunk1.id },
141
+ { fileId: '2', chunkId: chunk2.id },
142
+ ]);
143
+
144
+ // Execute the method
145
+ await chunkModel.deleteOrphanChunks();
146
+
147
+ // Check if all chunks are still present
148
+ const remainingChunks = await serverDB.query.chunks.findMany();
149
+ expect(remainingChunks).toHaveLength(2);
150
+ });
151
+
152
+ it('should not throw an error when the database is empty', async () => {
153
+ // Ensure the database is empty
154
+ await serverDB.delete(chunks);
155
+ await serverDB.delete(fileChunks);
156
+
157
+ // Execute the method and expect it not to throw
158
+ await expect(chunkModel.deleteOrphanChunks()).resolves.not.toThrow();
159
+ });
160
+ });
102
161
 
103
162
  describe('semanticSearch', () => {
104
163
  it('should perform semantic search and return results', async () => {
@@ -1,7 +1,8 @@
1
1
  // @vitest-environment node
2
- import { eq } from 'drizzle-orm';
2
+ import { eq, inArray } from 'drizzle-orm';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
 
5
+ import { getServerDBConfig, serverDBEnv } from '@/config/db';
5
6
  import { getTestDBInstance } from '@/database/server/core/dbForTest';
6
7
  import { FilesTabs, SortType } from '@/types/files';
7
8
 
@@ -22,6 +23,20 @@ vi.mock('@/database/server/core/db', async () => ({
22
23
  },
23
24
  }));
24
25
 
26
+ let DISABLE_REMOVE_GLOBAL_FILE = false;
27
+
28
+ vi.mock('@/config/db', async () => ({
29
+ get serverDBEnv() {
30
+ return {
31
+ get DISABLE_REMOVE_GLOBAL_FILE() {
32
+ return DISABLE_REMOVE_GLOBAL_FILE;
33
+ },
34
+ DATABASE_TEST_URL: process.env.DATABASE_TEST_URL,
35
+ DATABASE_DRIVER: 'node',
36
+ };
37
+ },
38
+ }));
39
+
25
40
  const userId = 'file-model-test-user-id';
26
41
  const fileModel = new FileModel(userId);
27
42
 
@@ -73,18 +88,21 @@ describe('FileModel', () => {
73
88
  });
74
89
  });
75
90
 
76
- it('should create a global file', async () => {
77
- const globalFile = {
78
- hashId: 'test-hash',
79
- fileType: 'text/plain',
80
- size: 100,
81
- url: 'https://example.com/global-file.txt',
82
- metadata: { key: 'value' },
83
- };
91
+ describe('createGlobalFile', () => {
92
+ it('should create a global file', async () => {
93
+ const globalFile = {
94
+ hashId: 'test-hash',
95
+ fileType: 'text/plain',
96
+ size: 100,
97
+ url: 'https://example.com/global-file.txt',
98
+ metadata: { key: 'value' },
99
+ };
84
100
 
85
- const result = await fileModel.createGlobalFile(globalFile);
86
- expect(result[0]).toMatchObject(globalFile);
101
+ const result = await fileModel.createGlobalFile(globalFile);
102
+ expect(result[0]).toMatchObject(globalFile);
103
+ });
87
104
  });
105
+
88
106
  describe('checkHash', () => {
89
107
  it('should return isExist: false for non-existent hash', async () => {
90
108
  const result = await fileModel.checkHash('non-existent-hash');
@@ -113,58 +131,183 @@ describe('FileModel', () => {
113
131
  });
114
132
  });
115
133
 
116
- it('should delete a file by id', async () => {
117
- const { id } = await fileModel.create({
118
- name: 'test-file.txt',
119
- url: 'https://example.com/test-file.txt',
120
- size: 100,
121
- fileType: 'text/plain',
122
- });
134
+ describe('delete', () => {
135
+ it('should delete a file by id', async () => {
136
+ await fileModel.createGlobalFile({
137
+ hashId: '1',
138
+ url: 'https://example.com/file1.txt',
139
+ size: 100,
140
+ fileType: 'text/plain',
141
+ });
123
142
 
124
- await fileModel.delete(id);
143
+ const { id } = await fileModel.create({
144
+ name: 'test-file.txt',
145
+ url: 'https://example.com/test-file.txt',
146
+ size: 100,
147
+ fileType: 'text/plain',
148
+ fileHash: '1',
149
+ });
125
150
 
126
- const file = await serverDB.query.files.findFirst({ where: eq(files.id, id) });
127
- expect(file).toBeUndefined();
128
- });
151
+ await fileModel.delete(id);
129
152
 
130
- it('should delete multiple files', async () => {
131
- const file1 = await fileModel.create({
132
- name: 'file1.txt',
133
- url: 'https://example.com/file1.txt',
134
- size: 100,
135
- fileType: 'text/plain',
136
- });
137
- const file2 = await fileModel.create({
138
- name: 'file2.txt',
139
- url: 'https://example.com/file2.txt',
140
- size: 200,
141
- fileType: 'text/plain',
153
+ const file = await serverDB.query.files.findFirst({ where: eq(files.id, id) });
154
+ const globalFile = await serverDB.query.globalFiles.findFirst({
155
+ where: eq(globalFiles.hashId, '1'),
156
+ });
157
+
158
+ expect(file).toBeUndefined();
159
+ expect(globalFile).toBeUndefined();
142
160
  });
161
+ it('should delete a file by id but global file not removed ', async () => {
162
+ DISABLE_REMOVE_GLOBAL_FILE = true;
163
+ await fileModel.createGlobalFile({
164
+ hashId: '1',
165
+ url: 'https://example.com/file1.txt',
166
+ size: 100,
167
+ fileType: 'text/plain',
168
+ });
143
169
 
144
- await fileModel.deleteMany([file1.id, file2.id]);
170
+ const { id } = await fileModel.create({
171
+ name: 'test-file.txt',
172
+ url: 'https://example.com/test-file.txt',
173
+ size: 100,
174
+ fileType: 'text/plain',
175
+ fileHash: '1',
176
+ });
177
+
178
+ await fileModel.delete(id);
179
+
180
+ const file = await serverDB.query.files.findFirst({ where: eq(files.id, id) });
181
+ const globalFile = await serverDB.query.globalFiles.findFirst({
182
+ where: eq(globalFiles.hashId, '1'),
183
+ });
145
184
 
146
- const remainingFiles = await serverDB.query.files.findMany({ where: eq(files.userId, userId) });
147
- expect(remainingFiles).toHaveLength(0);
185
+ expect(file).toBeUndefined();
186
+ expect(globalFile).toBeDefined();
187
+ DISABLE_REMOVE_GLOBAL_FILE = false;
188
+ });
148
189
  });
149
190
 
150
- it('should clear all files for the user', async () => {
151
- await fileModel.create({
152
- name: 'test-file-1.txt',
153
- url: 'https://example.com/test-file-1.txt',
154
- size: 100,
155
- fileType: 'text/plain',
191
+ describe('deleteMany', () => {
192
+ it('should delete multiple files', async () => {
193
+ await fileModel.createGlobalFile({
194
+ hashId: '1',
195
+ url: 'https://example.com/file1.txt',
196
+ size: 100,
197
+ fileType: 'text/plain',
198
+ });
199
+ await fileModel.createGlobalFile({
200
+ hashId: '2',
201
+ url: 'https://example.com/file2.txt',
202
+ size: 200,
203
+ fileType: 'text/plain',
204
+ });
205
+
206
+ const file1 = await fileModel.create({
207
+ name: 'file1.txt',
208
+ url: 'https://example.com/file1.txt',
209
+ size: 100,
210
+ fileHash: '1',
211
+ fileType: 'text/plain',
212
+ });
213
+ const file2 = await fileModel.create({
214
+ name: 'file2.txt',
215
+ url: 'https://example.com/file2.txt',
216
+ size: 200,
217
+ fileType: 'text/plain',
218
+ fileHash: '2',
219
+ });
220
+ const globalFilesResult = await serverDB.query.globalFiles.findMany({
221
+ where: inArray(globalFiles.hashId, ['1', '2']),
222
+ });
223
+ expect(globalFilesResult).toHaveLength(2);
224
+
225
+ await fileModel.deleteMany([file1.id, file2.id]);
226
+
227
+ const remainingFiles = await serverDB.query.files.findMany({
228
+ where: eq(files.userId, userId),
229
+ });
230
+ const globalFilesResult2 = await serverDB.query.globalFiles.findMany({
231
+ where: inArray(
232
+ globalFiles.hashId,
233
+ remainingFiles.map((i) => i.fileHash as string),
234
+ ),
235
+ });
236
+
237
+ expect(remainingFiles).toHaveLength(0);
238
+ expect(globalFilesResult2).toHaveLength(0);
156
239
  });
157
- await fileModel.create({
158
- name: 'test-file-2.txt',
159
- url: 'https://example.com/test-file-2.txt',
160
- size: 200,
161
- fileType: 'text/plain',
240
+ it('should delete multiple files but not remove global files if DISABLE_REMOVE_GLOBAL_FILE=true', async () => {
241
+ DISABLE_REMOVE_GLOBAL_FILE = true;
242
+ await fileModel.createGlobalFile({
243
+ hashId: '1',
244
+ url: 'https://example.com/file1.txt',
245
+ size: 100,
246
+ fileType: 'text/plain',
247
+ });
248
+ await fileModel.createGlobalFile({
249
+ hashId: '2',
250
+ url: 'https://example.com/file2.txt',
251
+ size: 200,
252
+ fileType: 'text/plain',
253
+ });
254
+
255
+ const file1 = await fileModel.create({
256
+ name: 'file1.txt',
257
+ url: 'https://example.com/file1.txt',
258
+ size: 100,
259
+ fileType: 'text/plain',
260
+ fileHash: '1',
261
+ });
262
+ const file2 = await fileModel.create({
263
+ name: 'file2.txt',
264
+ url: 'https://example.com/file2.txt',
265
+ size: 200,
266
+ fileType: 'text/plain',
267
+ fileHash: '2',
268
+ });
269
+
270
+ const globalFilesResult = await serverDB.query.globalFiles.findMany({
271
+ where: inArray(globalFiles.hashId, ['1', '2']),
272
+ });
273
+
274
+ expect(globalFilesResult).toHaveLength(2);
275
+
276
+ await fileModel.deleteMany([file1.id, file2.id]);
277
+
278
+ const remainingFiles = await serverDB.query.files.findMany({
279
+ where: eq(files.userId, userId),
280
+ });
281
+ const globalFilesResult2 = await serverDB.query.globalFiles.findMany({
282
+ where: inArray(globalFiles.hashId, ['1', '2']),
283
+ });
284
+
285
+ expect(remainingFiles).toHaveLength(0);
286
+ expect(globalFilesResult2).toHaveLength(2);
287
+ DISABLE_REMOVE_GLOBAL_FILE = false;
162
288
  });
289
+ });
163
290
 
164
- await fileModel.clear();
291
+ describe('clear', () => {
292
+ it('should clear all files for the user', async () => {
293
+ await fileModel.create({
294
+ name: 'test-file-1.txt',
295
+ url: 'https://example.com/test-file-1.txt',
296
+ size: 100,
297
+ fileType: 'text/plain',
298
+ });
299
+ await fileModel.create({
300
+ name: 'test-file-2.txt',
301
+ url: 'https://example.com/test-file-2.txt',
302
+ size: 200,
303
+ fileType: 'text/plain',
304
+ });
305
+
306
+ await fileModel.clear();
165
307
 
166
- const userFiles = await serverDB.query.files.findMany({ where: eq(files.userId, userId) });
167
- expect(userFiles).toHaveLength(0);
308
+ const userFiles = await serverDB.query.files.findMany({ where: eq(files.userId, userId) });
309
+ expect(userFiles).toHaveLength(0);
310
+ });
168
311
  });
169
312
 
170
313
  describe('Query', () => {
@@ -334,22 +477,24 @@ describe('FileModel', () => {
334
477
  });
335
478
  });
336
479
 
337
- it('should find a file by id', async () => {
338
- const { id } = await fileModel.create({
339
- name: 'test-file.txt',
340
- url: 'https://example.com/test-file.txt',
341
- size: 100,
342
- fileType: 'text/plain',
343
- });
480
+ describe('findById', () => {
481
+ it('should find a file by id', async () => {
482
+ const { id } = await fileModel.create({
483
+ name: 'test-file.txt',
484
+ url: 'https://example.com/test-file.txt',
485
+ size: 100,
486
+ fileType: 'text/plain',
487
+ });
344
488
 
345
- const file = await fileModel.findById(id);
346
- expect(file).toMatchObject({
347
- id,
348
- name: 'test-file.txt',
349
- url: 'https://example.com/test-file.txt',
350
- size: 100,
351
- fileType: 'text/plain',
352
- userId,
489
+ const file = await fileModel.findById(id);
490
+ expect(file).toMatchObject({
491
+ id,
492
+ name: 'test-file.txt',
493
+ url: 'https://example.com/test-file.txt',
494
+ size: 100,
495
+ fileType: 'text/plain',
496
+ userId,
497
+ });
353
498
  });
354
499
  });
355
500
 
@@ -1,11 +1,18 @@
1
1
  // @vitest-environment node
2
2
  import { eq } from 'drizzle-orm';
3
- import { desc } from 'drizzle-orm/expressions';
3
+ import { and, desc } from 'drizzle-orm/expressions';
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
6
  import { getTestDBInstance } from '@/database/server/core/dbForTest';
7
7
 
8
- import { NewKnowledgeBase, knowledgeBases, users } from '../../schemas/lobechat';
8
+ import {
9
+ NewKnowledgeBase,
10
+ files,
11
+ globalFiles,
12
+ knowledgeBaseFiles,
13
+ knowledgeBases,
14
+ users,
15
+ } from '../../schemas/lobechat';
9
16
  import { KnowledgeBaseModel } from '../knowledgeBase';
10
17
 
11
18
  let serverDB = await getTestDBInstance();
@@ -21,6 +28,7 @@ const knowledgeBaseModel = new KnowledgeBaseModel(userId);
21
28
 
22
29
  beforeEach(async () => {
23
30
  await serverDB.delete(users);
31
+ await serverDB.delete(globalFiles);
24
32
  await serverDB.insert(users).values([{ id: userId }, { id: 'user2' }]);
25
33
  });
26
34
 
@@ -129,4 +137,122 @@ describe('KnowledgeBaseModel', () => {
129
137
  });
130
138
  });
131
139
  });
140
+
141
+ const fileList = [
142
+ {
143
+ id: 'file1',
144
+ name: 'document.pdf',
145
+ url: 'https://example.com/document.pdf',
146
+ fileHash: 'hash1',
147
+ size: 1000,
148
+ fileType: 'application/pdf',
149
+ userId,
150
+ },
151
+ {
152
+ id: 'file2',
153
+ name: 'image.jpg',
154
+ url: 'https://example.com/image.jpg',
155
+ fileHash: 'hash2',
156
+ size: 500,
157
+ fileType: 'image/jpeg',
158
+ userId,
159
+ },
160
+ ];
161
+
162
+ describe('addFilesToKnowledgeBase', () => {
163
+ it('should add files to a knowledge base', async () => {
164
+ await serverDB.insert(globalFiles).values([
165
+ {
166
+ hashId: 'hash1',
167
+ url: 'https://example.com/document.pdf',
168
+ size: 1000,
169
+ fileType: 'application/pdf',
170
+ },
171
+ {
172
+ hashId: 'hash2',
173
+ url: 'https://example.com/image.jpg',
174
+ size: 500,
175
+ fileType: 'image/jpeg',
176
+ },
177
+ ]);
178
+
179
+ await serverDB.insert(files).values(fileList);
180
+
181
+ const { id: knowledgeBaseId } = await knowledgeBaseModel.create({ name: 'Test Group' });
182
+ const fileIds = ['file1', 'file2'];
183
+
184
+ const result = await knowledgeBaseModel.addFilesToKnowledgeBase(knowledgeBaseId, fileIds);
185
+
186
+ expect(result).toHaveLength(2);
187
+ expect(result).toEqual(
188
+ expect.arrayContaining(
189
+ fileIds.map((fileId) => expect.objectContaining({ fileId, knowledgeBaseId })),
190
+ ),
191
+ );
192
+
193
+ const addedFiles = await serverDB.query.knowledgeBaseFiles.findMany({
194
+ where: eq(knowledgeBaseFiles.knowledgeBaseId, knowledgeBaseId),
195
+ });
196
+ expect(addedFiles).toHaveLength(2);
197
+ });
198
+ });
199
+
200
+ describe('removeFilesFromKnowledgeBase', () => {
201
+ it('should remove files from a knowledge base', async () => {
202
+ await serverDB.insert(globalFiles).values([
203
+ {
204
+ hashId: 'hash1',
205
+ url: 'https://example.com/document.pdf',
206
+ size: 1000,
207
+ fileType: 'application/pdf',
208
+ },
209
+ {
210
+ hashId: 'hash2',
211
+ url: 'https://example.com/image.jpg',
212
+ size: 500,
213
+ fileType: 'image/jpeg',
214
+ },
215
+ ]);
216
+
217
+ await serverDB.insert(files).values(fileList);
218
+
219
+ const { id: knowledgeBaseId } = await knowledgeBaseModel.create({ name: 'Test Group' });
220
+ const fileIds = ['file1', 'file2'];
221
+ await knowledgeBaseModel.addFilesToKnowledgeBase(knowledgeBaseId, fileIds);
222
+
223
+ const filesToRemove = ['file1'];
224
+ await knowledgeBaseModel.removeFilesFromKnowledgeBase(knowledgeBaseId, filesToRemove);
225
+
226
+ const remainingFiles = await serverDB.query.knowledgeBaseFiles.findMany({
227
+ where: and(eq(knowledgeBaseFiles.knowledgeBaseId, knowledgeBaseId)),
228
+ });
229
+ expect(remainingFiles).toHaveLength(1);
230
+ expect(remainingFiles[0].fileId).toBe('file2');
231
+ });
232
+ });
233
+
234
+ describe('static findById', () => {
235
+ it('should find a knowledge base by id without user restriction', async () => {
236
+ const { id } = await knowledgeBaseModel.create({ name: 'Test Group' });
237
+
238
+ const group = await KnowledgeBaseModel.findById(id);
239
+ expect(group).toMatchObject({
240
+ id,
241
+ name: 'Test Group',
242
+ userId,
243
+ });
244
+ });
245
+
246
+ it('should find a knowledge base created by another user', async () => {
247
+ const anotherKnowledgeBaseModel = new KnowledgeBaseModel('user2');
248
+ const { id } = await anotherKnowledgeBaseModel.create({ name: 'Another User Group' });
249
+
250
+ const group = await KnowledgeBaseModel.findById(id);
251
+ expect(group).toMatchObject({
252
+ id,
253
+ name: 'Another User Group',
254
+ userId: 'user2',
255
+ });
256
+ });
257
+ });
132
258
  });
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { INBOX_SESSION_ID } from '@/const/session';
5
5
  import { getTestDBInstance } from '@/database/server/core/dbForTest';
6
6
  import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
7
- import { UserPreference } from '@/types/user';
7
+ import { UserGuide, UserPreference } from '@/types/user';
8
8
  import { UserSettings } from '@/types/user/settings';
9
9
 
10
10
  import { userSettings, users } from '../../schemas/lobechat';
@@ -167,6 +167,24 @@ describe('UserModel', () => {
167
167
  const { plaintext } = await gateKeeper.decrypt(updatedSettings!.keyVaults!);
168
168
  expect(JSON.parse(plaintext)).toEqual(settings.keyVaults);
169
169
  });
170
+
171
+ it('should update user settings with encrypted keyVaults', async () => {
172
+ const settings = {
173
+ general: { language: 'en-US' },
174
+ } as UserSettings;
175
+ await serverDB.insert(users).values({ id: userId });
176
+ await serverDB.insert(userSettings).values({ ...settings, keyVaults: '', id: userId });
177
+
178
+ const newSettings = {
179
+ general: { fontSize: 16, language: 'zh-CN', themeMode: 'dark' },
180
+ } as UserSettings;
181
+ await userModel.updateSetting(userId, newSettings);
182
+
183
+ const updatedSettings = await serverDB.query.userSettings.findFirst({
184
+ where: eq(users.id, userId),
185
+ });
186
+ expect(updatedSettings?.general).toEqual(newSettings.general);
187
+ });
170
188
  });
171
189
 
172
190
  describe('updatePreference', () => {
@@ -183,4 +201,21 @@ describe('UserModel', () => {
183
201
  expect(updatedUser?.preference).toEqual({ ...preference, ...newPreference });
184
202
  });
185
203
  });
204
+
205
+ describe('updateGuide', () => {
206
+ it('should update user guide', async () => {
207
+ const preference = { guide: { topic: false } } as UserGuide;
208
+ await serverDB.insert(users).values({ id: userId, preference });
209
+
210
+ const newGuide: Partial<UserGuide> = {
211
+ topic: true,
212
+ moveSettingsToAvatar: true,
213
+ uploadFileInKnowledgeBase: true,
214
+ };
215
+ await userModel.updateGuide(userId, newGuide);
216
+
217
+ const updatedUser = await serverDB.query.users.findFirst({ where: eq(users.id, userId) });
218
+ expect(updatedUser?.preference).toEqual({ ...preference, guide: newGuide });
219
+ });
220
+ });
186
221
  });
@@ -1,5 +1,5 @@
1
1
  import { asc, cosineDistance, count, eq, inArray, sql } from 'drizzle-orm';
2
- import { and, desc } from 'drizzle-orm/expressions';
2
+ import { and, desc, isNull } from 'drizzle-orm/expressions';
3
3
 
4
4
  import { serverDB } from '@/database/server';
5
5
  import { ChunkMetadata, FileChunk, SemanticSearchChunk } from '@/types/chunk';
@@ -43,6 +43,19 @@ export class ChunkModel {
43
43
  return serverDB.delete(chunks).where(and(eq(chunks.id, id), eq(chunks.userId, this.userId)));
44
44
  };
45
45
 
46
+ deleteOrphanChunks = async () => {
47
+ const orphanedChunks = await serverDB
48
+ .select({ chunkId: chunks.id })
49
+ .from(chunks)
50
+ .leftJoin(fileChunks, eq(chunks.id, fileChunks.chunkId))
51
+ .where(isNull(fileChunks.fileId));
52
+
53
+ const ids = orphanedChunks.map((chunk) => chunk.chunkId);
54
+ if (ids.length === 0) return;
55
+
56
+ await serverDB.delete(chunks).where(inArray(chunks.id, ids));
57
+ };
58
+
46
59
  findById = async (id: string) => {
47
60
  return serverDB.query.chunks.findFirst({
48
61
  where: and(eq(chunks.id, id)),
@@ -1,6 +1,7 @@
1
1
  import { asc, count, eq, ilike, inArray, notExists } from 'drizzle-orm';
2
2
  import { and, desc } from 'drizzle-orm/expressions';
3
3
 
4
+ import { serverDBEnv } from '@/config/db';
4
5
  import { serverDB } from '@/database/server/core/db';
5
6
  import { FilesTabs, QueryFileListParams, SortType } from '@/types/files';
6
7
 
@@ -77,7 +78,8 @@ export class FileModel {
77
78
  const fileCount = result[0].count;
78
79
 
79
80
  // delete the file from global file if it is not used by other files
80
- if (fileCount === 0) {
81
+ // if `DISABLE_REMOVE_GLOBAL_FILE` is true, we will not remove the global file
82
+ if (fileCount === 0 && !serverDBEnv.DISABLE_REMOVE_GLOBAL_FILE) {
81
83
  await trx.delete(globalFiles).where(eq(globalFiles.hashId, fileHash));
82
84
 
83
85
  return file;
@@ -118,7 +120,7 @@ export class FileModel {
118
120
 
119
121
  const needToDeleteList = fileHashCounts.filter((item) => item.count === 0);
120
122
 
121
- if (needToDeleteList.length === 0) return;
123
+ if (needToDeleteList.length === 0 || serverDBEnv.DISABLE_REMOVE_GLOBAL_FILE) return;
122
124
 
123
125
  // delete the file from global file if it is not used by other files
124
126
  await trx.delete(globalFiles).where(
@@ -150,6 +150,8 @@ export const fileRouter = router({
150
150
  removeFile: fileProcedure.input(z.object({ id: z.string() })).mutation(async ({ input, ctx }) => {
151
151
  const file = await ctx.fileModel.delete(input.id);
152
152
 
153
+ // delete the orphan chunks
154
+ await ctx.chunkModel.deleteOrphanChunks();
153
155
  if (!file) return;
154
156
 
155
157
  // delele the file from remove from S3 if it is not used by other files
@@ -162,6 +164,9 @@ export const fileRouter = router({
162
164
  .mutation(async ({ input, ctx }) => {
163
165
  const needToRemoveFileList = await ctx.fileModel.deleteMany(input.ids);
164
166
 
167
+ // delete the orphan chunks
168
+ await ctx.chunkModel.deleteOrphanChunks();
169
+
165
170
  if (!needToRemoveFileList || needToRemoveFileList.length === 0) return;
166
171
 
167
172
  // remove from S3