@lobehub/chat 1.36.45 → 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 +25 -0
- package/changelog/v1.json +9 -0
- package/package.json +1 -1
- package/src/database/_deprecated/models/file.ts +17 -3
- package/src/server/routers/lambda/file.ts +1 -3
- package/src/services/__tests__/upload.test.ts +175 -0
- package/src/services/file/ClientS3/index.test.ts +115 -0
- package/src/services/file/ClientS3/index.ts +58 -0
- package/src/services/file/client.test.ts +9 -4
- package/src/services/file/client.ts +36 -8
- package/src/services/upload.ts +8 -16
- package/src/store/chat/slices/builtinTool/action.test.ts +13 -4
- package/src/store/file/slices/upload/action.ts +33 -67
- package/src/types/files/upload.ts +8 -2
package/CHANGELOG.md
CHANGED
@@ -2,6 +2,31 @@
|
|
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
|
+
[](#readme-top)
|
27
|
+
|
28
|
+
</div>
|
29
|
+
|
5
30
|
### [Version 1.36.45](https://github.com/lobehub/lobe-chat/compare/v1.36.44...v1.36.45)
|
6
31
|
|
7
32
|
<sup>Released on **2024-12-21**</sup>
|
package/changelog/v1.json
CHANGED
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@lobehub/chat",
|
3
|
-
"version": "1.36.
|
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",
|
@@ -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
|
-
|
24
|
-
|
25
|
-
|
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
|
49
|
+
const localFile = {
|
49
50
|
name: 'test',
|
50
|
-
data: new ArrayBuffer(1),
|
51
51
|
fileType: 'image/png',
|
52
|
-
|
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 {
|
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:
|
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(
|
12
|
-
|
13
|
-
|
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
|
28
|
-
const
|
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
|
}
|
package/src/services/upload.ts
CHANGED
@@ -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 {
|
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
|
-
|
70
|
-
|
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
|
-
|
85
|
-
|
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, '
|
43
|
-
vi.spyOn(
|
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.
|
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
|
-
> = (
|
61
|
-
|
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
|
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
|
-
}
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
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
|
+
}
|