@lobehub/chat 1.36.44 → 1.36.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ### [Version 1.36.46](https://github.com/lobehub/lobe-chat/compare/v1.36.45...v1.36.46)
6
+
7
+ <sup>Released on **2024-12-21**</sup>
8
+
9
+ #### ♻ Code Refactoring
10
+
11
+ - **misc**: Refactor client mode upload to match server mode.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### Code refactoring
19
+
20
+ - **misc**: Refactor client mode upload to match server mode, closes [#5111](https://github.com/lobehub/lobe-chat/issues/5111) ([0361ced](https://github.com/lobehub/lobe-chat/commit/0361ced))
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
+
30
+ ### [Version 1.36.45](https://github.com/lobehub/lobe-chat/compare/v1.36.44...v1.36.45)
31
+
32
+ <sup>Released on **2024-12-21**</sup>
33
+
34
+ #### 💄 Styles
35
+
36
+ - **misc**: Add o1 model in GitHub models.
37
+
38
+ <br/>
39
+
40
+ <details>
41
+ <summary><kbd>Improvements and Fixes</kbd></summary>
42
+
43
+ #### Styles
44
+
45
+ - **misc**: Add o1 model in GitHub models, closes [#5110](https://github.com/lobehub/lobe-chat/issues/5110) ([91dc5d7](https://github.com/lobehub/lobe-chat/commit/91dc5d7))
46
+
47
+ </details>
48
+
49
+ <div align="right">
50
+
51
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
52
+
53
+ </div>
54
+
5
55
  ### [Version 1.36.44](https://github.com/lobehub/lobe-chat/compare/v1.36.43...v1.36.44)
6
56
 
7
57
  <sup>Released on **2024-12-21**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "improvements": [
5
+ "Refactor client mode upload to match server mode."
6
+ ]
7
+ },
8
+ "date": "2024-12-21",
9
+ "version": "1.36.46"
10
+ },
11
+ {
12
+ "children": {
13
+ "improvements": [
14
+ "Add o1 model in GitHub models."
15
+ ]
16
+ },
17
+ "date": "2024-12-21",
18
+ "version": "1.36.45"
19
+ },
2
20
  {
3
21
  "children": {
4
22
  "improvements": [
@@ -75,10 +75,32 @@ When deploying LobeChat, you need to configure the following environment variabl
75
75
 
76
76
  <Callout type={'tip'}>
77
77
  Visit [📘 Environment Variables](/docs/self-hosting/environment-variables/auth#logto) for details on related variables.
78
-
78
+
79
79
  </Callout>
80
80
  </Steps>
81
81
 
82
+ ### Troubleshooting
83
+
84
+ If you encounter issues during the Logto deployment process, refer to the following common problems:
85
+
86
+ - `Only roles with the xxx attribute may create roles`:
87
+ Check your database user's permissions and ensure that the user in your Logto database has the `admin` role to create roles.
88
+
89
+ - Error executing `logto db seed` on third-party databases like `Neon`:
90
+ Try using the `logto db seed --encrypt-base-role` command.
91
+
92
+ - Database seeding failed:
93
+ Try skipping the seeding process with the `--skip-seed` parameter.
94
+
95
+ - `Error: role xxx already exists`:
96
+ Delete the existing role in the database.
97
+
98
+ - Database migration failed after a version upgrade:
99
+ Try using the command `npx @logto/cli db alteration deploy $version` (e.g., `npx @logto/cli db alteration deploy 1.22.0`).
100
+
101
+ - I am using Docker deployment and want a one-click upgrade:
102
+ Execute the custom command in the container: `sh -c "npm run cli db seed -- --swe --encrypt-base-role" && npx @logto/cli db alteration deploy $version && npm start`
103
+
82
104
  <Callout type={'info'}>
83
105
  After successful deployment, users will be able to authenticate via Logto and use LobeChat.
84
106
  </Callout>
@@ -72,8 +72,30 @@ tags:
72
72
 
73
73
  <Callout type={'tip'}>
74
74
  前往 [📘 环境变量](/zh/docs/self-hosting/environment-variables/auth#logto) 可查阅相关变量详情。
75
-
75
+
76
76
  </Callout>
77
77
  </Steps>
78
78
 
79
+ ### 故障排除
80
+
81
+ 若你在部署 Logto 过程中遇到问题,可以参考以下常见问题:
82
+
83
+ - `Only roles with the xxx attribute may create roles`:
84
+ 请检查你的数据库用户权限,确保你的 Logto 数据库中的用户具有 `admin` 角色,以便创建角色。
85
+
86
+ - 在第三方数据库例如 `Neon` 上执行`logto db seed`出错:
87
+ 尝试使用`logto db seed --encrypt-base-role`命令。
88
+
89
+ - 数据库播种失败:
90
+ 请尝试使用`--skip-seed`参数跳过播种。
91
+
92
+ - `Error: role xxx already exists`:
93
+ 在数据库中删除已存在的角色即可。
94
+
95
+ - 版本升级后,数据库迁移失败:
96
+ 请尝试使用` npx @logto/cli db alteration deploy $version`命令(例如`npx @logto/cli db alteration deploy 1.22.0`)
97
+
98
+ - 我使用 docker 部署 希望一键升级:
99
+ 在容器中执行自定义命令:`sh -c "npm run cli db seed -- --swe --encrypt-base-role" && npx @logto/cli db alteration deploy $version && npm start`
100
+
79
101
  <Callout type={'info'}>部署成功后,用户将可以通过 Logto 身份认证并使用 LobeChat。</Callout>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.36.44",
3
+ "version": "1.36.46",
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",
@@ -4,6 +4,16 @@ import { ModelProviderCard } from '@/types/llm';
4
4
  // https://github.com/marketplace/models
5
5
  const Github: ModelProviderCard = {
6
6
  chatModels: [
7
+ {
8
+ description: '专注于高级推理和解决复杂问题,包括数学和科学任务。非常适合需要深入上下文理解和代理工作流程的应用程序。',
9
+ displayName: 'OpenAI o1',
10
+ enabled: true,
11
+ functionCall: false,
12
+ id: 'o1',
13
+ maxOutput: 100_000,
14
+ tokens: 200_000,
15
+ vision: true,
16
+ },
7
17
  {
8
18
  description: '比 o1-preview 更小、更快,成本低80%,在代码生成和小上下文操作方面表现良好。',
9
19
  displayName: 'OpenAI o1-mini',
@@ -1,5 +1,6 @@
1
1
  import { DBModel } from '@/database/_deprecated/core/types/db';
2
2
  import { DB_File, DB_FileSchema } from '@/database/_deprecated/schemas/files';
3
+ import { clientS3Storage } from '@/services/file/ClientS3';
3
4
  import { nanoid } from '@/utils/uuid';
4
5
 
5
6
  import { BaseModel } from '../core';
@@ -20,9 +21,15 @@ class _FileModel extends BaseModel<'files'> {
20
21
  if (!item) return;
21
22
 
22
23
  // arrayBuffer to url
23
- const base64 = Buffer.from(item.data!).toString('base64');
24
-
25
- return { ...item, url: `data:${item.fileType};base64,${base64}` };
24
+ let base64;
25
+ if (!item.data) {
26
+ const hash = (item.url as string).replace('client-s3://', '');
27
+ base64 = await this.getBase64ByFileHash(hash);
28
+ } else {
29
+ base64 = Buffer.from(item.data).toString('base64');
30
+ }
31
+
32
+ return { ...item, base64, url: `data:${item.fileType};base64,${base64}` };
26
33
  }
27
34
 
28
35
  async delete(id: string) {
@@ -32,6 +39,13 @@ class _FileModel extends BaseModel<'files'> {
32
39
  async clear() {
33
40
  return this.table.clear();
34
41
  }
42
+
43
+ private async getBase64ByFileHash(hash: string) {
44
+ const fileItem = await clientS3Storage.getObject(hash);
45
+ if (!fileItem) throw new Error('file not found');
46
+
47
+ return Buffer.from(await fileItem.arrayBuffer()).toString('base64');
48
+ }
35
49
  }
36
50
 
37
51
  export const FileModel = new _FileModel();
@@ -32,9 +32,7 @@ export const fileRouter = router({
32
32
  }),
33
33
 
34
34
  createFile: fileProcedure
35
- .input(
36
- UploadFileSchema.omit({ data: true, saveMode: true, url: true }).extend({ url: z.string() }),
37
- )
35
+ .input(UploadFileSchema.omit({ url: true }).extend({ url: z.string() }))
38
36
  .mutation(async ({ ctx, input }) => {
39
37
  const { isExist } = await ctx.fileModel.checkHash(input.hash!);
40
38
 
@@ -0,0 +1,175 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { fileEnv } from '@/config/file';
4
+ import { edgeClient } from '@/libs/trpc/client';
5
+ import { API_ENDPOINTS } from '@/services/_url';
6
+ import { clientS3Storage } from '@/services/file/ClientS3';
7
+
8
+ import { UPLOAD_NETWORK_ERROR, uploadService } from '../upload';
9
+
10
+ // Mock dependencies
11
+ vi.mock('@/libs/trpc/client', () => ({
12
+ edgeClient: {
13
+ upload: {
14
+ createS3PreSignedUrl: {
15
+ mutate: vi.fn(),
16
+ },
17
+ },
18
+ },
19
+ }));
20
+
21
+ vi.mock('@/services/file/ClientS3', () => ({
22
+ clientS3Storage: {
23
+ putObject: vi.fn(),
24
+ },
25
+ }));
26
+
27
+ vi.mock('@/utils/uuid', () => ({
28
+ uuid: () => 'mock-uuid',
29
+ }));
30
+
31
+ describe('UploadService', () => {
32
+ const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
33
+ const mockPreSignUrl = 'https://example.com/presign';
34
+
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ // Mock Date.now
38
+ vi.spyOn(Date, 'now').mockImplementation(() => 3600000); // 1 hour in milliseconds
39
+ });
40
+
41
+ describe('uploadWithProgress', () => {
42
+ beforeEach(() => {
43
+ // Mock XMLHttpRequest
44
+ const xhrMock = {
45
+ upload: {
46
+ addEventListener: vi.fn(),
47
+ },
48
+ open: vi.fn(),
49
+ send: vi.fn(),
50
+ setRequestHeader: vi.fn(),
51
+ addEventListener: vi.fn(),
52
+ status: 200,
53
+ };
54
+ global.XMLHttpRequest = vi.fn(() => xhrMock) as any;
55
+
56
+ // Mock createS3PreSignedUrl
57
+ (edgeClient.upload.createS3PreSignedUrl.mutate as any).mockResolvedValue(mockPreSignUrl);
58
+ });
59
+
60
+ it('should upload file successfully with progress', async () => {
61
+ const onProgress = vi.fn();
62
+ const xhr = new XMLHttpRequest();
63
+
64
+ // Simulate successful upload
65
+ vi.spyOn(xhr, 'addEventListener').mockImplementation((event, handler) => {
66
+ if (event === 'load') {
67
+ // @ts-ignore
68
+ handler({ target: { status: 200 } });
69
+ }
70
+ });
71
+
72
+ const result = await uploadService.uploadWithProgress(mockFile, { onProgress });
73
+
74
+ expect(result).toEqual({
75
+ date: '1',
76
+ dirname: `${fileEnv.NEXT_PUBLIC_S3_FILE_PATH}/1`,
77
+ filename: 'mock-uuid.png',
78
+ path: `${fileEnv.NEXT_PUBLIC_S3_FILE_PATH}/1/mock-uuid.png`,
79
+ });
80
+ });
81
+
82
+ it('should handle network error', async () => {
83
+ const xhr = new XMLHttpRequest();
84
+
85
+ // Simulate network error
86
+ vi.spyOn(xhr, 'addEventListener').mockImplementation((event, handler) => {
87
+ if (event === 'error') {
88
+ Object.assign(xhr, { status: 0 });
89
+ // @ts-ignore
90
+ handler({});
91
+ }
92
+ });
93
+
94
+ await expect(uploadService.uploadWithProgress(mockFile, {})).rejects.toBe(
95
+ UPLOAD_NETWORK_ERROR,
96
+ );
97
+ });
98
+
99
+ it('should handle upload error', async () => {
100
+ const xhr = new XMLHttpRequest();
101
+
102
+ // Simulate upload error
103
+ vi.spyOn(xhr, 'addEventListener').mockImplementation((event, handler) => {
104
+ if (event === 'load') {
105
+ Object.assign(xhr, { status: 400, statusText: 'Bad Request' });
106
+
107
+ // @ts-ignore
108
+ handler({});
109
+ }
110
+ });
111
+
112
+ await expect(uploadService.uploadWithProgress(mockFile, {})).rejects.toBe('Bad Request');
113
+ });
114
+ });
115
+
116
+ describe('uploadToClientS3', () => {
117
+ it('should upload file to client S3 successfully', async () => {
118
+ const hash = 'test-hash';
119
+ const expectedResult = {
120
+ date: '1',
121
+ dirname: '',
122
+ filename: mockFile.name,
123
+ path: `client-s3://${hash}`,
124
+ };
125
+
126
+ (clientS3Storage.putObject as any).mockResolvedValue(undefined);
127
+
128
+ const result = await uploadService.uploadToClientS3(hash, mockFile);
129
+
130
+ expect(clientS3Storage.putObject).toHaveBeenCalledWith(hash, mockFile);
131
+ expect(result).toEqual(expectedResult);
132
+ });
133
+ });
134
+
135
+ describe('getImageFileByUrlWithCORS', () => {
136
+ beforeEach(() => {
137
+ global.fetch = vi.fn();
138
+ });
139
+
140
+ it('should fetch and create file from URL', async () => {
141
+ const url = 'https://example.com/image.png';
142
+ const filename = 'test.png';
143
+ const mockArrayBuffer = new ArrayBuffer(8);
144
+
145
+ (global.fetch as any).mockResolvedValue({
146
+ arrayBuffer: () => Promise.resolve(mockArrayBuffer),
147
+ });
148
+
149
+ const result = await uploadService.getImageFileByUrlWithCORS(url, filename);
150
+
151
+ expect(global.fetch).toHaveBeenCalledWith(API_ENDPOINTS.proxy, {
152
+ body: url,
153
+ method: 'POST',
154
+ });
155
+ expect(result).toBeInstanceOf(File);
156
+ expect(result.name).toBe(filename);
157
+ expect(result.type).toBe('image/png');
158
+ });
159
+
160
+ it('should handle custom file type', async () => {
161
+ const url = 'https://example.com/image.jpg';
162
+ const filename = 'test.jpg';
163
+ const fileType = 'image/jpeg';
164
+ const mockArrayBuffer = new ArrayBuffer(8);
165
+
166
+ (global.fetch as any).mockResolvedValue({
167
+ arrayBuffer: () => Promise.resolve(mockArrayBuffer),
168
+ });
169
+
170
+ const result = await uploadService.getImageFileByUrlWithCORS(url, filename, fileType);
171
+
172
+ expect(result.type).toBe(fileType);
173
+ });
174
+ });
175
+ });
@@ -0,0 +1,115 @@
1
+ import { createStore, del, get, set } from 'idb-keyval';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { BrowserS3Storage } from './index';
5
+
6
+ // Mock idb-keyval
7
+ vi.mock('idb-keyval', () => ({
8
+ createStore: vi.fn(),
9
+ set: vi.fn(),
10
+ get: vi.fn(),
11
+ del: vi.fn(),
12
+ }));
13
+
14
+ let storage: BrowserS3Storage;
15
+ let mockStore = {};
16
+
17
+ beforeEach(() => {
18
+ // Reset all mocks before each test
19
+ vi.clearAllMocks();
20
+ mockStore = {};
21
+ (createStore as any).mockReturnValue(mockStore);
22
+ storage = new BrowserS3Storage();
23
+ });
24
+
25
+ describe('BrowserS3Storage', () => {
26
+ describe('constructor', () => {
27
+ it('should create store when in browser environment', () => {
28
+ expect(createStore).toHaveBeenCalledWith('lobechat-local-s3', 'objects');
29
+ });
30
+ });
31
+
32
+ describe('putObject', () => {
33
+ it('should successfully put a file object', async () => {
34
+ const mockFile = new File(['test content'], 'test.txt', { type: 'text/plain' });
35
+ const mockArrayBuffer = new ArrayBuffer(8);
36
+ vi.spyOn(mockFile, 'arrayBuffer').mockResolvedValue(mockArrayBuffer);
37
+ (set as any).mockResolvedValue(undefined);
38
+
39
+ await storage.putObject('1-test-key', mockFile);
40
+
41
+ expect(set).toHaveBeenCalledWith(
42
+ '1-test-key',
43
+ {
44
+ data: mockArrayBuffer,
45
+ name: 'test.txt',
46
+ type: 'text/plain',
47
+ },
48
+ mockStore,
49
+ );
50
+ });
51
+
52
+ it('should throw error when put operation fails', async () => {
53
+ const mockFile = new File(['test content'], 'test.txt', { type: 'text/plain' });
54
+ const mockError = new Error('Storage error');
55
+ (set as any).mockRejectedValue(mockError);
56
+
57
+ await expect(storage.putObject('test-key', mockFile)).rejects.toThrow(
58
+ 'Failed to put file test.txt: Storage error',
59
+ );
60
+ });
61
+ });
62
+
63
+ describe('getObject', () => {
64
+ it('should successfully get a file object', async () => {
65
+ const mockData = {
66
+ data: new ArrayBuffer(8),
67
+ name: 'test.txt',
68
+ type: 'text/plain',
69
+ };
70
+ (get as any).mockResolvedValue(mockData);
71
+
72
+ const result = await storage.getObject('test-key');
73
+
74
+ expect(result).toBeInstanceOf(File);
75
+ expect(result?.name).toBe('test.txt');
76
+ expect(result?.type).toBe('text/plain');
77
+ });
78
+
79
+ it('should return undefined when file not found', async () => {
80
+ (get as any).mockResolvedValue(undefined);
81
+
82
+ const result = await storage.getObject('test-key');
83
+
84
+ expect(result).toBeUndefined();
85
+ });
86
+
87
+ it('should throw error when get operation fails', async () => {
88
+ const mockError = new Error('Storage error');
89
+ (get as any).mockRejectedValue(mockError);
90
+
91
+ await expect(storage.getObject('test-key')).rejects.toThrow(
92
+ 'Failed to get object (key=test-key): Storage error',
93
+ );
94
+ });
95
+ });
96
+
97
+ describe('deleteObject', () => {
98
+ it('should successfully delete a file object', async () => {
99
+ (del as any).mockResolvedValue(undefined);
100
+
101
+ await storage.deleteObject('test-key2');
102
+
103
+ expect(del).toHaveBeenCalledWith('test-key2', {});
104
+ });
105
+
106
+ it('should throw error when delete operation fails', async () => {
107
+ const mockError = new Error('Storage error');
108
+ (del as any).mockRejectedValue(mockError);
109
+
110
+ await expect(storage.deleteObject('test-key')).rejects.toThrow(
111
+ 'Failed to delete object (key=test-key): Storage error',
112
+ );
113
+ });
114
+ });
115
+ });
@@ -0,0 +1,58 @@
1
+ import { createStore, del, get, set } from 'idb-keyval';
2
+
3
+ const BROWSER_S3_DB_NAME = 'lobechat-local-s3';
4
+
5
+ export class BrowserS3Storage {
6
+ private store;
7
+
8
+ constructor() {
9
+ // skip server-side rendering
10
+ if (typeof window === 'undefined') return;
11
+
12
+ this.store = createStore(BROWSER_S3_DB_NAME, 'objects');
13
+ }
14
+
15
+ /**
16
+ * 上传文件
17
+ * @param key 文件 hash
18
+ * @param file File 对象
19
+ */
20
+ async putObject(key: string, file: File): Promise<void> {
21
+ try {
22
+ const data = await file.arrayBuffer();
23
+ await set(key, { data, name: file.name, type: file.type }, this.store);
24
+ } catch (e) {
25
+ throw new Error(`Failed to put file ${file.name}: ${(e as Error).message}`);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * 获取文件
31
+ * @param key 文件 hash
32
+ * @returns File 对象
33
+ */
34
+ async getObject(key: string): Promise<File | undefined> {
35
+ try {
36
+ const res = await get<{ data: ArrayBuffer; name: string; type: string }>(key, this.store);
37
+ if (!res) return;
38
+
39
+ return new File([res.data], res!.name, { type: res?.type });
40
+ } catch (e) {
41
+ throw new Error(`Failed to get object (key=${key}): ${(e as Error).message}`);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * 删除文件
47
+ * @param key 文件 hash
48
+ */
49
+ async deleteObject(key: string): Promise<void> {
50
+ try {
51
+ await del(key, this.store);
52
+ } catch (e) {
53
+ throw new Error(`Failed to delete object (key=${key}): ${(e as Error).message}`);
54
+ }
55
+ }
56
+ }
57
+
58
+ export const clientS3Storage = new BrowserS3Storage();
@@ -3,6 +3,7 @@ import { Mock, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
3
3
  import { fileEnv } from '@/config/file';
4
4
  import { FileModel } from '@/database/_deprecated/models/file';
5
5
  import { DB_File } from '@/database/_deprecated/schemas/files';
6
+ import { clientS3Storage } from '@/services/file/ClientS3';
6
7
  import { serverConfigSelectors } from '@/store/serverConfig/selectors';
7
8
  import { createServerConfigStore } from '@/store/serverConfig/store';
8
9
 
@@ -45,19 +46,23 @@ beforeEach(() => {
45
46
 
46
47
  describe('FileService', () => {
47
48
  it('createFile should save the file to the database', async () => {
48
- const localFile: DB_File = {
49
+ const localFile = {
49
50
  name: 'test',
50
- data: new ArrayBuffer(1),
51
51
  fileType: 'image/png',
52
- saveMode: 'local',
52
+ url: 'client-s3://123',
53
53
  size: 1,
54
+ hash: '123',
54
55
  };
55
56
 
57
+ await clientS3Storage.putObject(
58
+ '123',
59
+ new File([new ArrayBuffer(1)], 'test.png', { type: 'image/png' }),
60
+ );
61
+
56
62
  (FileModel.create as Mock).mockResolvedValue(localFile);
57
63
 
58
64
  const result = await fileService.createFile(localFile);
59
65
 
60
- expect(FileModel.create).toHaveBeenCalledWith(localFile);
61
66
  expect(result).toEqual({ url: 'data:image/png;base64,AA==' });
62
67
  });
63
68
 
@@ -1,16 +1,27 @@
1
1
  import { FileModel } from '@/database/_deprecated/models/file';
2
- import { DB_File } from '@/database/_deprecated/schemas/files';
3
- import { FileItem } from '@/types/files';
2
+ import { clientS3Storage } from '@/services/file/ClientS3';
3
+ import { FileItem, UploadFileParams } from '@/types/files';
4
4
 
5
5
  import { IFileService } from './type';
6
6
 
7
7
  export class ClientService implements IFileService {
8
- async createFile(file: DB_File) {
8
+ async createFile(file: UploadFileParams) {
9
9
  // save to local storage
10
10
  // we may want to save to a remote server later
11
- const res = await FileModel.create(file);
12
- // arrayBuffer to url
13
- const base64 = Buffer.from(file.data!).toString('base64');
11
+ const res = await FileModel.create({
12
+ createdAt: Date.now(),
13
+ data: undefined,
14
+ fileHash: file.hash,
15
+ fileType: file.fileType,
16
+ metadata: file.metadata,
17
+ name: file.name,
18
+ saveMode: 'url',
19
+ size: file.size,
20
+ url: file.url,
21
+ } as any);
22
+
23
+ // get file to base64 url
24
+ const base64 = await this.getBase64ByFileHash(file.hash!);
14
25
 
15
26
  return {
16
27
  id: res.id,
@@ -18,14 +29,24 @@ export class ClientService implements IFileService {
18
29
  };
19
30
  }
20
31
 
32
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
33
+ async checkFileHash(_hash: string) {
34
+ return { isExist: false, metadata: {} };
35
+ }
36
+
21
37
  async getFile(id: string): Promise<FileItem> {
22
38
  const item = await FileModel.findById(id);
23
39
  if (!item) {
24
40
  throw new Error('file not found');
25
41
  }
26
42
 
27
- // arrayBuffer to url
28
- const url = URL.createObjectURL(new Blob([item.data!], { type: item.fileType }));
43
+ // arrayBuffer to blob or base64 to blob
44
+ const blob = !!item.data
45
+ ? new Blob([item.data!], { type: item.fileType })
46
+ : // @ts-ignore
47
+ new Blob([Buffer.from(item.base64!, 'base64')], { type: item.fileType });
48
+
49
+ const url = URL.createObjectURL(blob);
29
50
 
30
51
  return {
31
52
  createdAt: new Date(item.createdAt),
@@ -49,4 +70,11 @@ export class ClientService implements IFileService {
49
70
  async removeAllFiles() {
50
71
  return FileModel.clear();
51
72
  }
73
+
74
+ private async getBase64ByFileHash(hash: string) {
75
+ const fileItem = await clientS3Storage.getObject(hash);
76
+ if (!fileItem) throw new Error('file not found');
77
+
78
+ return Buffer.from(await fileItem.arrayBuffer()).toString('base64');
79
+ }
52
80
  }
@@ -1,7 +1,8 @@
1
1
  import { fileEnv } from '@/config/file';
2
2
  import { edgeClient } from '@/libs/trpc/client';
3
3
  import { API_ENDPOINTS } from '@/services/_url';
4
- import { FileMetadata, UploadFileParams } from '@/types/files';
4
+ import { clientS3Storage } from '@/services/file/ClientS3';
5
+ import { FileMetadata } from '@/types/files';
5
6
  import { FileUploadState, FileUploadStatus } from '@/types/files/upload';
6
7
  import { uuid } from '@/utils/uuid';
7
8
 
@@ -66,23 +67,14 @@ class UploadService {
66
67
  return result;
67
68
  };
68
69
 
69
- uploadToClientDB = async (params: UploadFileParams, file: File) => {
70
- const { FileModel } = await import('@/database/_deprecated/models/file');
71
- const fileArrayBuffer = await file.arrayBuffer();
72
-
73
- // save to local storage
74
- // we may want to save to a remote server later
75
- const res = await FileModel.create({
76
- createdAt: Date.now(),
77
- ...params,
78
- data: fileArrayBuffer,
79
- });
80
- // arrayBuffer to url
81
- const base64 = Buffer.from(fileArrayBuffer).toString('base64');
70
+ uploadToClientS3 = async (hash: string, file: File): Promise<FileMetadata> => {
71
+ await clientS3Storage.putObject(hash, file);
82
72
 
83
73
  return {
84
- id: res.id,
85
- url: `data:${params.fileType};base64,${base64}`,
74
+ date: (Date.now() / 1000 / 60 / 60).toFixed(0),
75
+ dirname: '',
76
+ filename: file.name,
77
+ path: `client-s3://${hash}`,
86
78
  };
87
79
  };
88
80
 
@@ -2,6 +2,8 @@ import { act, renderHook } from '@testing-library/react';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
3
 
4
4
  import { fileService } from '@/services/file';
5
+ import { ClientService } from '@/services/file/client';
6
+ import { messageService } from '@/services/message';
5
7
  import { imageGenerationService } from '@/services/textToImage';
6
8
  import { uploadService } from '@/services/upload';
7
9
  import { chatSelectors } from '@/store/chat/selectors';
@@ -39,17 +41,23 @@ describe('chatToolSlice', () => {
39
41
  vi.spyOn(uploadService, 'getImageFileByUrlWithCORS').mockResolvedValue(
40
42
  new File(['1'], 'file.png', { type: 'image/png' }),
41
43
  );
42
- vi.spyOn(uploadService, 'uploadToClientDB').mockResolvedValue({} as any);
43
- vi.spyOn(fileService, 'createFile').mockResolvedValue({ id: mockId, url: '' });
44
+ vi.spyOn(uploadService, 'uploadToClientS3').mockResolvedValue({} as any);
45
+ vi.spyOn(ClientService.prototype, 'createFile').mockResolvedValue({
46
+ id: mockId,
47
+ url: '',
48
+ });
44
49
  vi.spyOn(result.current, 'toggleDallEImageLoading');
50
+ vi.spyOn(ClientService.prototype, 'checkFileHash').mockImplementation(async () => ({
51
+ isExist: false,
52
+ metadata: {},
53
+ }));
45
54
 
46
55
  await act(async () => {
47
56
  await result.current.generateImageFromPrompts(prompts, messageId);
48
57
  });
49
58
  // For each prompt, loading is toggled on and then off
50
59
  expect(imageGenerationService.generateImage).toHaveBeenCalledTimes(prompts.length);
51
- expect(uploadService.uploadToClientDB).toHaveBeenCalledTimes(prompts.length);
52
-
60
+ expect(uploadService.uploadToClientS3).toHaveBeenCalledTimes(prompts.length);
53
61
  expect(result.current.toggleDallEImageLoading).toHaveBeenCalledTimes(prompts.length * 2);
54
62
  });
55
63
  });
@@ -75,6 +83,7 @@ describe('chatToolSlice', () => {
75
83
  content: initialMessageContent,
76
84
  }) as ChatMessage,
77
85
  );
86
+ vi.spyOn(messageService, 'updateMessage').mockResolvedValueOnce(undefined);
78
87
 
79
88
  await act(async () => {
80
89
  await result.current.updateImageItem(messageId, updateFunction);
@@ -6,14 +6,11 @@ import { message } from '@/components/AntdStaticMethods';
6
6
  import { LOBE_CHAT_CLOUD } from '@/const/branding';
7
7
  import { isServerMode } from '@/const/version';
8
8
  import { fileService } from '@/services/file';
9
- import { ServerService } from '@/services/file/server';
10
9
  import { uploadService } from '@/services/upload';
11
10
  import { FileMetadata, UploadFileItem } from '@/types/files';
12
11
 
13
12
  import { FileStore } from '../../store';
14
13
 
15
- const serverFileService = new ServerService();
16
-
17
14
  interface UploadWithProgressParams {
18
15
  file: File;
19
16
  knowledgeBaseId?: string;
@@ -43,10 +40,6 @@ interface UploadWithProgressResult {
43
40
  }
44
41
 
45
42
  export interface FileUploadAction {
46
- internal_uploadToClientDB: (
47
- params: Omit<UploadWithProgressParams, 'knowledgeBaseId'>,
48
- ) => Promise<UploadWithProgressResult | undefined>;
49
- internal_uploadToServer: (params: UploadWithProgressParams) => Promise<UploadWithProgressResult>;
50
43
  uploadWithProgress: (
51
44
  params: UploadWithProgressParams,
52
45
  ) => Promise<UploadWithProgressResult | undefined>;
@@ -57,51 +50,14 @@ export const createFileUploadSlice: StateCreator<
57
50
  [['zustand/devtools', never]],
58
51
  [],
59
52
  FileUploadAction
60
- > = (set, get) => ({
61
- internal_uploadToClientDB: async ({ file, onStatusUpdate, skipCheckFileType }) => {
62
- if (!skipCheckFileType && !file.type.startsWith('image')) {
63
- onStatusUpdate?.({ id: file.name, type: 'removeFile' });
64
- message.info({
65
- content: t('upload.fileOnlySupportInServerMode', {
66
- cloud: LOBE_CHAT_CLOUD,
67
- ext: file.name.split('.').pop(),
68
- ns: 'error',
69
- }),
70
- duration: 5,
71
- });
72
- return;
73
- }
74
-
75
- const fileArrayBuffer = await file.arrayBuffer();
76
-
77
- const hash = sha256(fileArrayBuffer);
78
-
79
- const data = await uploadService.uploadToClientDB(
80
- { fileType: file.type, hash, name: file.name, saveMode: 'local', size: file.size },
81
- file,
82
- );
83
-
84
- onStatusUpdate?.({
85
- id: file.name,
86
- type: 'updateFile',
87
- value: {
88
- fileUrl: data.url,
89
- id: data.id,
90
- status: 'success',
91
- uploadState: { progress: 100, restTime: 0, speed: 0 },
92
- },
93
- });
94
-
95
- return data;
96
- },
97
-
98
- internal_uploadToServer: async ({ file, onStatusUpdate, knowledgeBaseId }) => {
53
+ > = () => ({
54
+ uploadWithProgress: async ({ file, onStatusUpdate, knowledgeBaseId, skipCheckFileType }) => {
99
55
  const fileArrayBuffer = await file.arrayBuffer();
100
56
 
101
57
  // 1. check file hash
102
58
  const hash = sha256(fileArrayBuffer);
103
59
 
104
- const checkStatus = await serverFileService.checkFileHash(hash);
60
+ const checkStatus = await fileService.checkFileHash(hash);
105
61
  let metadata: FileMetadata;
106
62
 
107
63
  // 2. if file exist, just skip upload
@@ -112,17 +68,37 @@ export const createFileUploadSlice: StateCreator<
112
68
  type: 'updateFile',
113
69
  value: { status: 'processing', uploadState: { progress: 100, restTime: 0, speed: 0 } },
114
70
  });
115
- } else {
116
- // 2. if file don't exist, need upload files
117
- metadata = await uploadService.uploadWithProgress(file, {
118
- onProgress: (status, upload) => {
119
- onStatusUpdate?.({
120
- id: file.name,
121
- type: 'updateFile',
122
- value: { status: status === 'success' ? 'processing' : status, uploadState: upload },
71
+ }
72
+ // 2. if file don't exist, need upload files
73
+ else {
74
+ // if is server mode, upload to server s3, or upload to client s3
75
+ if (isServerMode) {
76
+ metadata = await uploadService.uploadWithProgress(file, {
77
+ onProgress: (status, upload) => {
78
+ onStatusUpdate?.({
79
+ id: file.name,
80
+ type: 'updateFile',
81
+ value: { status: status === 'success' ? 'processing' : status, uploadState: upload },
82
+ });
83
+ },
84
+ });
85
+ } else {
86
+ if (!skipCheckFileType && !file.type.startsWith('image')) {
87
+ onStatusUpdate?.({ id: file.name, type: 'removeFile' });
88
+ message.info({
89
+ content: t('upload.fileOnlySupportInServerMode', {
90
+ cloud: LOBE_CHAT_CLOUD,
91
+ ext: file.name.split('.').pop(),
92
+ ns: 'error',
93
+ }),
94
+ duration: 5,
123
95
  });
124
- },
125
- });
96
+ return;
97
+ }
98
+
99
+ // Upload to the indexeddb in the browser
100
+ metadata = await uploadService.uploadToClientS3(hash, file);
101
+ }
126
102
  }
127
103
 
128
104
  // 3. use more powerful file type detector to get file type
@@ -138,12 +114,10 @@ export const createFileUploadSlice: StateCreator<
138
114
  // 4. create file to db
139
115
  const data = await fileService.createFile(
140
116
  {
141
- createdAt: Date.now(),
142
117
  fileType,
143
118
  hash,
144
119
  metadata,
145
120
  name: file.name,
146
- saveMode: 'url',
147
121
  size: file.size,
148
122
  url: metadata.path,
149
123
  },
@@ -163,12 +137,4 @@ export const createFileUploadSlice: StateCreator<
163
137
 
164
138
  return data;
165
139
  },
166
-
167
- uploadWithProgress: async (payload) => {
168
- const { internal_uploadToServer, internal_uploadToClientDB } = get();
169
-
170
- if (isServerMode) return internal_uploadToServer(payload);
171
-
172
- return internal_uploadToClientDB(payload);
173
- },
174
140
  });
@@ -53,7 +53,6 @@ export const FileMetadataSchema = z.object({
53
53
  export type FileMetadata = z.infer<typeof FileMetadataSchema>;
54
54
 
55
55
  export const UploadFileSchema = z.object({
56
- data: z.instanceof(ArrayBuffer).optional(),
57
56
  /**
58
57
  * file type
59
58
  * @example 'image/png'
@@ -77,7 +76,6 @@ export const UploadFileSchema = z.object({
77
76
  * local mean save the raw file into data
78
77
  * url mean upload the file to a cdn and then save the url
79
78
  */
80
- saveMode: z.enum(['local', 'url']),
81
79
  /**
82
80
  * file size
83
81
  */
@@ -89,3 +87,11 @@ export const UploadFileSchema = z.object({
89
87
  });
90
88
 
91
89
  export type UploadFileParams = z.infer<typeof UploadFileSchema>;
90
+
91
+ export interface CheckFileHashResult {
92
+ fileType?: string;
93
+ isExist: boolean;
94
+ metadata?: unknown;
95
+ size?: number;
96
+ url?: string;
97
+ }