@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.
- package/.github/workflows/release.yml +3 -2
- package/.github/workflows/test.yml +2 -1
- package/CHANGELOG.md +25 -0
- package/package.json +1 -1
- package/src/config/app.ts +12 -5
- package/src/config/db.ts +4 -0
- package/src/database/server/models/__tests__/chunk.test.ts +60 -1
- package/src/database/server/models/__tests__/file.test.ts +211 -66
- package/src/database/server/models/__tests__/knowledgeBase.test.ts +128 -2
- package/src/database/server/models/__tests__/user.test.ts +36 -1
- package/src/database/server/models/chunk.ts +14 -1
- package/src/database/server/models/file.ts +4 -2
- package/src/server/routers/lambda/file.ts +5 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
[](#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.
|
|
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
|
-
|
|
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
|
-
|
|
19
|
+
const APP_URL = process.env.APP_URL ? process.env.APP_URL : isInVercel ? vercelUrl : undefined;
|
|
19
20
|
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
expect(file).toBeUndefined();
|
|
128
|
-
});
|
|
151
|
+
await fileModel.delete(id);
|
|
129
152
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
185
|
+
expect(file).toBeUndefined();
|
|
186
|
+
expect(globalFile).toBeDefined();
|
|
187
|
+
DISABLE_REMOVE_GLOBAL_FILE = false;
|
|
188
|
+
});
|
|
148
189
|
});
|
|
149
190
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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 {
|
|
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
|
|
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
|