@lobehub/chat 1.12.10 → 1.12.12

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,48 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ### [Version 1.12.12](https://github.com/lobehub/lobe-chat/compare/v1.12.11...v1.12.12)
6
+
7
+ <sup>Released on **2024-08-24**</sup>
8
+
9
+ <br/>
10
+
11
+ <details>
12
+ <summary><kbd>Improvements and Fixes</kbd></summary>
13
+
14
+ </details>
15
+
16
+ <div align="right">
17
+
18
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
19
+
20
+ </div>
21
+
22
+ ### [Version 1.12.11](https://github.com/lobehub/lobe-chat/compare/v1.12.10...v1.12.11)
23
+
24
+ <sup>Released on **2024-08-23**</sup>
25
+
26
+ #### 🐛 Bug Fixes
27
+
28
+ - **misc**: Remove orphan chunks if there is no related file.
29
+
30
+ <br/>
31
+
32
+ <details>
33
+ <summary><kbd>Improvements and Fixes</kbd></summary>
34
+
35
+ #### What's fixed
36
+
37
+ - **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))
38
+
39
+ </details>
40
+
41
+ <div align="right">
42
+
43
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
44
+
45
+ </div>
46
+
5
47
  ### [Version 1.12.10](https://github.com/lobehub/lobe-chat/compare/v1.12.9...v1.12.10)
6
48
 
7
49
  <sup>Released on **2024-08-23**</sup>
@@ -24,6 +24,7 @@ FROM base AS builder
24
24
  ARG USE_CN_MIRROR
25
25
 
26
26
  ENV NEXT_PUBLIC_SERVICE_MODE="server" \
27
+ APP_URL="http://app.com" \
27
28
  DATABASE_DRIVER="node" \
28
29
  DATABASE_URL="postgres://postgres:password@localhost:5432/postgres" \
29
30
  KEY_VAULTS_SECRET="use-for-build"
package/README.md CHANGED
@@ -269,11 +269,11 @@ Our marketplace is not just a showcase platform but also a collaborative space.
269
269
  | Recent Submits | Description |
270
270
  | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
271
271
  | [Variable Name Conversion Expert](https://chat-preview.lobehub.com/market?agent=variable-name-conversion)<br/><sup>By **[zengyishou](https://github.com/zengyishou)** on **2024-08-21**</sup> | In software development, naming variables is a common yet relatively time-consuming task. This assistant can automatically convert Chinese variable names into English variable names that conform to camelCase, PascalCase, snake_case, kebab-case, and constant naming conventions based on specific naming rules. This not only improves code readability but also alleviates the frustration of variable naming.<br/>`software-development` `variable-naming` `chinese-to-english` `code-standards` `automatic-conversion` |
272
+ | [Prompt Engineering Expert](https://chat-preview.lobehub.com/market?agent=ai-prompts-assistant)<br/><sup>By **[cyicz123](https://github.com/cyicz123)** on **2024-08-12**</sup> | Specializing in prompt optimization and design<br/>`prompt-engineering` `ai-interaction` `writing` `optimization` `consulting` |
272
273
  | [Commit Message Generator](https://chat-preview.lobehub.com/market?agent=commit-assistant)<br/><sup>By **[cyicz123](https://github.com/cyicz123)** on **2024-08-12**</sup> | Expert at generating precise Git commit messages<br/>`programming` `git` `commit-message` `code-review` |
273
274
  | [RO-SCIRAW Prompt Word Expert](https://chat-preview.lobehub.com/market?agent=rosciraw)<br/><sup>By **[kirklin](https://github.com/kirklin)** on **2024-08-06**</sup> | The RO-SCIRAW framework, created by Kirk Lin, is a methodology for prompt words that provides a new paradigm for building highly precise and efficient prompt words. Please enter the information for the persona you want to create.<br/>`prompt-word-framework` |
274
- | [Social Media Sage](https://chat-preview.lobehub.com/market?agent=social-media-sage)<br/><sup>By **[thedivergentai](https://github.com/thedivergentai)** on **2024-08-06**</sup> | Social Media Marketing expert crafting winning strategies for brands and empowering businesses to thrive online<br/>`social-media-marketing` `branding` `growth-strategies` |
275
275
 
276
- > 📊 Total agents: [<kbd>**314**</kbd> ](https://github.com/lobehub/lobe-chat-agents)
276
+ > 📊 Total agents: [<kbd>**315**</kbd> ](https://github.com/lobehub/lobe-chat-agents)
277
277
 
278
278
  <!-- AGENT LIST -->
279
279
 
package/README.zh-CN.md CHANGED
@@ -257,11 +257,11 @@ LobeChat 的插件生态系统是其核心功能的重要扩展,它极大地
257
257
  | 最近新增 | 助手说明 |
258
258
  | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
259
259
  | [开发变量名转换专家](https://chat-preview.lobehub.com/market?agent=variable-name-conversion)<br/><sup>By **[zengyishou](https://github.com/zengyishou)** on **2024-08-21**</sup> | 在软件开发过程中,命名变量是一项常见却相对耗时的任务。本助手能够根据特定的命名规则自动将中文变量名转换为符合小驼峰、大驼峰、下划线、横线以及常量命名规范的英文变量名。这不仅提高了代码的可读性,还解决了变量命名的苦恼。<br/>`软件开发` `变量命名` `中文转英文` `代码规范` `自动转换` |
260
+ | [提示工程专家](https://chat-preview.lobehub.com/market?agent=ai-prompts-assistant)<br/><sup>By **[cyicz123](https://github.com/cyicz123)** on **2024-08-12**</sup> | 专精 Prompt 优化与设计<br/>`提示工程` `ai交互` `写作` `优化` `咨询` |
260
261
  | [提交信息生成器](https://chat-preview.lobehub.com/market?agent=commit-assistant)<br/><sup>By **[cyicz123](https://github.com/cyicz123)** on **2024-08-12**</sup> | 擅长生成精准的 Git 提交信息<br/>`编程` `git` `提交信息` `代码审查` |
261
262
  | [RO-SCIRAW 提示词专家](https://chat-preview.lobehub.com/market?agent=rosciraw)<br/><sup>By **[kirklin](https://github.com/kirklin)** on **2024-08-06**</sup> | RO-SCIRAW 框架是由 Kirk Lin 开创的提示词方法论,为构建高度精确和高效的提示词提供了一个全新的范式。请输入你要创建的分身信息。<br/>`提示词框架` |
262
- | [社交媒体专家](https://chat-preview.lobehub.com/market?agent=social-media-sage)<br/><sup>By **[thedivergentai](https://github.com/thedivergentai)** on **2024-08-06**</sup> | 社交媒体营销专家,为品牌制定成功策略,帮助企业在线蓬勃发展<br/>`社交媒体营销` `品牌塑造` `增长策略` |
263
263
 
264
- > 📊 Total agents: [<kbd>**314**</kbd> ](https://github.com/lobehub/lobe-chat-agents)
264
+ > 📊 Total agents: [<kbd>**315**</kbd> ](https://github.com/lobehub/lobe-chat-agents)
265
265
 
266
266
  <!-- AGENT LIST -->
267
267
 
@@ -1,6 +1,9 @@
1
1
  ---
2
2
  title: LobeChat Authentication Service Configuration
3
- description: Learn how to configure external authentication services using Clerk or Next Auth for centralized user authorization management. Supported authentication services include Auth0, Azure ID, etc.
3
+ description: >-
4
+ Learn how to configure external authentication services using Clerk or Next
5
+ Auth for centralized user authorization management. Supported authentication
6
+ services include Auth0, Azure ID, etc.
4
7
  tags:
5
8
  - Authentication Service
6
9
  - Next Auth
@@ -51,7 +51,8 @@ In this documentation, S3 refers to a compatible S3 storage solution, which supp
51
51
 
52
52
  <Callout type={'warning'}>
53
53
  Additionally, since this access domain is often a separate URL, it needs to be configured to allow cross-origin access to the site. Otherwise, cross-origin issues will occur in the browser.
54
- </Callout>
54
+
55
+ </Callout>
55
56
 
56
57
  ### `S3_ENABLE_PATH_STYLE`
57
58
 
@@ -65,13 +66,15 @@ In this documentation, S3 refers to a compatible S3 storage solution, which supp
65
66
  - path-style: `s3.example.net/mybucket/config.env`
66
67
  - virtual-host: `mybucket.s3.example.net/config.env`
67
68
 
68
- </Callout>
69
+
70
+ </Callout>
69
71
 
70
72
  <Callout type={'tip'}>
71
73
 
72
74
  Common S3 cloud service providers often default to the `virtual-host` mode, while self-deployed services like Minio default to using `path-style`. Therefore, if you use Minio as the S3 service, you need to set `S3_ENABLE_PATH_STYLE=1`.
73
75
 
74
- </Callout>
76
+
77
+ </Callout>
75
78
 
76
79
  </Steps>
77
80
 
@@ -73,10 +73,8 @@ S3_ENDPOINT=https://0b33a03b5c993fd2f453379dc36558e5.r2.cloudflarestorage.com
73
73
 
74
74
  常见的 S3 Cloud 服务商往往默认采用 `virtual-host` 模式,而自部署服务 minio 则默认使用的是 `path-style`。 因此如果你使用了 minio 作为 S3 服务,你需要设置 `S3_ENABLE_PATH_STYLE=1` 。
75
75
 
76
-
77
76
  </Callout>
78
77
 
79
-
80
78
  </Steps>
81
79
 
82
80
  ## S3 配置指南
@@ -40,6 +40,18 @@ After forking the project, due to Github's limitations, you need to manually ena
40
40
  src="https://github-production-user-asset-6210df.s3.amazonaws.com/17870709/266985177-7677b4ce-c348-4145-9f60-829d448d5be6.png"
41
41
  />
42
42
 
43
+ If you encounter a sync failure, you need to manually click "Update Branch" once.
44
+
45
+ <Image
46
+ alt="GitHub Action Sync Failure"
47
+ src="https://github.com/user-attachments/assets/9baacac6-5af4-460b-862d-682b76c18459"
48
+ />
49
+
50
+ <Image
51
+ alt="Manually Sync 'Update Branch'"
52
+ src="https://github.com/user-attachments/assets/d524c20d-306a-45bc-971b-96920b87fab4"
53
+ />
54
+
43
55
  ## `B` Docker Deployment
44
56
 
45
57
  Upgrading the Docker deployment version is very simple, you just need to redeploy the latest LobeChat image. Here are the commands required to perform these steps:
@@ -35,6 +35,18 @@ tags:
35
35
  src="https://github-production-user-asset-6210df.s3.amazonaws.com/17870709/266985177-7677b4ce-c348-4145-9f60-829d448d5be6.png"
36
36
  />
37
37
 
38
+ 如果你遇到了同步失败的情况,你需要手动重新点一次 「Update Branch」。
39
+
40
+ <Image
41
+ alt="GitHub Action 同步失败"
42
+ src="https://github.com/user-attachments/assets/9baacac6-5af4-460b-862d-682b76c18459"
43
+ />
44
+
45
+ <Image
46
+ alt="手动同步 「Update Branch」"
47
+ src="https://github.com/user-attachments/assets/d524c20d-306a-45bc-971b-96920b87fab4"
48
+ />
49
+
38
50
  ## `B` Docker 部署
39
51
 
40
52
  Docker 部署版本的升级非常简单,只需要重新部署 LobeChat 的最新镜像即可。 以下是执行这些步骤所需的指令:
@@ -109,6 +109,10 @@ KEY_VAULTS_SECRET=jgwsK28dspyVQoIf8/M3IIHl1h6LYYceSYNXeLpy6uk=
109
109
 
110
110
  Make sure to add this to the Vercel environment variables as well.
111
111
 
112
+ ### Add the `APP_URL` Environment Variable
113
+
114
+ Finally, you need to add the `APP_URL` environment variable, which specifies the URL address of the LobeChat application.
115
+
112
116
  </Steps>
113
117
 
114
118
  ## 2. Configure Authentication Service
@@ -394,6 +398,8 @@ src={'https://github.com/lobehub/lobe-chat/assets/28616219/9cb5150d-6e1e-4c59-9a
394
398
  For easy copying, here is a summary of the environment variables required to configure the server-side database:
395
399
 
396
400
  ```shell
401
+ APP_URL=https://your-project.com
402
+
397
403
  # Specify the service mode as server
398
404
  NEXT_PUBLIC_SERVICE_MODE=server
399
405
 
@@ -102,6 +102,10 @@ KEY_VAULTS_SECRET=jgwsK28dspyVQoIf8/M3IIHl1h6LYYceSYNXeLpy6uk=
102
102
 
103
103
  同样需要将其添加到 Vercel 环境变量中。
104
104
 
105
+ ### 添加 `APP_URL` 环境变量
106
+
107
+ 该部分最后需要添加 APP_URL 环境变量,用于指定LobeChat 应用的 URL 地址。
108
+
105
109
  </Steps>
106
110
 
107
111
  ## 二、 配置身份验证服务
@@ -375,6 +379,8 @@ S3_PUBLIC_DOMAIN=https://s3-dev.your-domain.com
375
379
  为方便一键复制,在此汇总配置服务端数据库所需要的环境变量:
376
380
 
377
381
  ```shell
382
+ APP_URL=https://your-project.com
383
+
378
384
  # 指定服务模式为 server
379
385
  NEXT_PUBLIC_SERVICE_MODE=server
380
386
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.12.10",
3
+ "version": "1.12.12",
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",
@@ -260,7 +260,7 @@
260
260
  "eslint-plugin-mdx": "^2.3.4",
261
261
  "eslint-plugin-unused-imports": "4.0.1",
262
262
  "fake-indexeddb": "^6.0.0",
263
- "glob": "^10.4.5",
263
+ "glob": "^11.0.0",
264
264
  "gray-matter": "^4.0.3",
265
265
  "happy-dom": "^14.12.3",
266
266
  "husky": "^9.1.5",
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