@lobehub/lobehub 2.0.0-next.59 → 2.0.0-next.60
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 -3
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatMinimap/index.tsx +4 -2
- package/src/services/__tests__/upload.test.ts +92 -82
- package/src/services/upload.ts +4 -33
- package/src/services/file/ClientS3/index.test.ts +0 -115
- package/src/services/file/ClientS3/index.ts +0 -59
- package/src/store/middleware/createHyperStorage/index.test.ts +0 -341
- package/src/store/middleware/createHyperStorage/index.ts +0 -126
- package/src/store/middleware/createHyperStorage/indexedDB.test.ts +0 -64
- package/src/store/middleware/createHyperStorage/indexedDB.ts +0 -26
- package/src/store/middleware/createHyperStorage/keyMapper.ts +0 -57
- package/src/store/middleware/createHyperStorage/localStorage.ts +0 -18
- package/src/store/middleware/createHyperStorage/type.ts +0 -25
- package/src/store/middleware/createHyperStorage/urlStorage.test.ts +0 -84
- package/src/store/middleware/createHyperStorage/urlStorage.ts +0 -81
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
## [Version 2.0.0-next.60](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.59...v2.0.0-next.60)
|
|
6
|
+
|
|
7
|
+
<sup>Released on **2025-11-14**</sup>
|
|
8
|
+
|
|
9
|
+
#### 🐛 Bug Fixes
|
|
10
|
+
|
|
11
|
+
- **misc**: Reduce threshold.
|
|
12
|
+
|
|
13
|
+
<br/>
|
|
14
|
+
|
|
15
|
+
<details>
|
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
17
|
+
|
|
18
|
+
#### What's fixed
|
|
19
|
+
|
|
20
|
+
- **misc**: Reduce threshold, closes [#10222](https://github.com/lobehub/lobe-chat/issues/10222) ([abdfd06](https://github.com/lobehub/lobe-chat/commit/abdfd06))
|
|
21
|
+
|
|
22
|
+
</details>
|
|
23
|
+
|
|
24
|
+
<div align="right">
|
|
25
|
+
|
|
26
|
+
[](#readme-top)
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
|
|
5
30
|
## [Version 2.0.0-next.59](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.58...v2.0.0-next.59)
|
|
6
31
|
|
|
7
32
|
<sup>Released on **2025-11-14**</sup>
|
package/changelog/v1.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/lobehub",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.60",
|
|
4
4
|
"description": "LobeHub - an open-source,comprehensive AI Agent 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",
|
|
@@ -218,12 +218,10 @@
|
|
|
218
218
|
"i18next": "^25.6.2",
|
|
219
219
|
"i18next-browser-languagedetector": "^8.2.0",
|
|
220
220
|
"i18next-resources-to-backend": "^1.2.1",
|
|
221
|
-
"idb-keyval": "^6.2.2",
|
|
222
221
|
"immer": "^10.2.0",
|
|
223
222
|
"jose": "^5.10.0",
|
|
224
223
|
"js-sha256": "^0.11.1",
|
|
225
224
|
"jsonl-parse-stringify": "^1.0.3",
|
|
226
|
-
"keyv": "^4.5.4",
|
|
227
225
|
"langchain": "^0.3.36",
|
|
228
226
|
"langfuse": "^3.38.6",
|
|
229
227
|
"langfuse-core": "^3.38.6",
|
package/src/app/[variants]/(main)/chat/components/conversation/features/ChatMinimap/index.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { Popover, Tooltip } from 'antd';
|
|
|
5
5
|
import { createStyles, useTheme } from 'antd-style';
|
|
6
6
|
import debug from 'debug';
|
|
7
7
|
import { ChevronDown, ChevronUp } from 'lucide-react';
|
|
8
|
+
import { markdownToTxt } from 'markdown-to-txt';
|
|
8
9
|
import { memo, useCallback, useMemo, useState, useSyncExternalStore } from 'react';
|
|
9
10
|
import { useTranslation } from 'react-i18next';
|
|
10
11
|
import { Flexbox } from 'react-layout-kit';
|
|
@@ -23,7 +24,7 @@ const log = debug('lobe-react:chat-minimap');
|
|
|
23
24
|
const MIN_WIDTH = 16;
|
|
24
25
|
const MAX_WIDTH = 30;
|
|
25
26
|
const MAX_CONTENT_LENGTH = 320;
|
|
26
|
-
const MIN_MESSAGES =
|
|
27
|
+
const MIN_MESSAGES = 3;
|
|
27
28
|
|
|
28
29
|
const useStyles = createStyles(({ css, token }) => ({
|
|
29
30
|
arrow: css`
|
|
@@ -178,7 +179,8 @@ const getIndicatorWidth = (content: string | undefined) => {
|
|
|
178
179
|
const getPreviewText = (content: string | undefined) => {
|
|
179
180
|
if (!content) return '';
|
|
180
181
|
|
|
181
|
-
const
|
|
182
|
+
const plainText = markdownToTxt(content);
|
|
183
|
+
const normalized = plainText.replaceAll(/\s+/g, ' ').trim();
|
|
182
184
|
if (!normalized) return '';
|
|
183
185
|
|
|
184
186
|
return normalized.slice(0, 100) + (normalized.length > 100 ? '…' : '');
|
|
@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
3
3
|
import { fileEnv } from '@/envs/file';
|
|
4
4
|
import { lambdaClient } from '@/libs/trpc/client';
|
|
5
5
|
import { API_ENDPOINTS } from '@/services/_url';
|
|
6
|
-
import { clientS3Storage } from '@/services/file/ClientS3';
|
|
7
6
|
|
|
8
7
|
import { UPLOAD_NETWORK_ERROR, uploadService } from '../upload';
|
|
9
8
|
|
|
@@ -31,12 +30,6 @@ vi.mock('@/libs/trpc/client', () => ({
|
|
|
31
30
|
},
|
|
32
31
|
}));
|
|
33
32
|
|
|
34
|
-
vi.mock('@/services/file/ClientS3', () => ({
|
|
35
|
-
clientS3Storage: {
|
|
36
|
-
putObject: vi.fn(),
|
|
37
|
-
},
|
|
38
|
-
}));
|
|
39
|
-
|
|
40
33
|
vi.mock('@/store/electron', () => ({
|
|
41
34
|
getElectronStoreState: vi.fn(() => ({})),
|
|
42
35
|
}));
|
|
@@ -54,7 +47,12 @@ vi.mock('@/services/electron/file', () => ({
|
|
|
54
47
|
}));
|
|
55
48
|
|
|
56
49
|
vi.mock('js-sha256', () => ({
|
|
57
|
-
sha256: vi.fn((data) =>
|
|
50
|
+
sha256: vi.fn((data) => {
|
|
51
|
+
if (data instanceof ArrayBuffer) {
|
|
52
|
+
return 'mock-hash-' + data.byteLength;
|
|
53
|
+
}
|
|
54
|
+
return 'mock-hash';
|
|
55
|
+
}),
|
|
58
56
|
}));
|
|
59
57
|
|
|
60
58
|
describe('UploadService', () => {
|
|
@@ -68,63 +66,83 @@ describe('UploadService', () => {
|
|
|
68
66
|
});
|
|
69
67
|
|
|
70
68
|
describe('uploadFileToS3', () => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
// Mock XMLHttpRequest for server upload
|
|
71
|
+
const xhrMock = {
|
|
72
|
+
addEventListener: vi.fn((event, handler) => {
|
|
73
|
+
if (event === 'load') {
|
|
74
|
+
setTimeout(() => handler({ target: { status: 200 } }), 0);
|
|
75
|
+
}
|
|
76
|
+
}),
|
|
77
|
+
open: vi.fn(),
|
|
78
|
+
send: vi.fn(),
|
|
79
|
+
setRequestHeader: vi.fn(),
|
|
80
|
+
status: 200,
|
|
81
|
+
upload: {
|
|
82
|
+
addEventListener: vi.fn(),
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
global.XMLHttpRequest = vi.fn(() => xhrMock) as any;
|
|
75
86
|
|
|
87
|
+
// Mock createS3PreSignedUrl
|
|
88
|
+
vi.mocked(lambdaClient.upload.createS3PreSignedUrl.mutate).mockResolvedValue(mockPreSignUrl);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should upload to server S3 in non-desktop mode', async () => {
|
|
76
92
|
const result = await uploadService.uploadFileToS3(mockFile, {});
|
|
77
93
|
|
|
78
94
|
expect(result.success).toBe(true);
|
|
79
95
|
expect(result.data).toEqual({
|
|
80
96
|
date: '1',
|
|
81
|
-
dirname:
|
|
82
|
-
filename:
|
|
83
|
-
path:
|
|
97
|
+
dirname: `${fileEnv.NEXT_PUBLIC_S3_FILE_PATH}/1`,
|
|
98
|
+
filename: 'mock-uuid.png',
|
|
99
|
+
path: `${fileEnv.NEXT_PUBLIC_S3_FILE_PATH}/1/mock-uuid.png`,
|
|
84
100
|
});
|
|
85
|
-
expect(clientS3Storage.putObject).toHaveBeenCalledWith('test-hash', mockFile);
|
|
86
101
|
});
|
|
87
102
|
|
|
88
|
-
it('should
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
const result = await uploadService.uploadFileToS3(nonImageFile, {
|
|
93
|
-
onNotSupported,
|
|
103
|
+
it('should use custom pathname when provided', async () => {
|
|
104
|
+
const customPath = 'custom/path/file.png';
|
|
105
|
+
const result = await uploadService.uploadFileToS3(mockFile, {
|
|
106
|
+
pathname: customPath,
|
|
94
107
|
});
|
|
95
108
|
|
|
96
|
-
expect(result.success).toBe(
|
|
97
|
-
expect(
|
|
109
|
+
expect(result.success).toBe(true);
|
|
110
|
+
expect(result.data.path).toBe(customPath);
|
|
98
111
|
});
|
|
99
112
|
|
|
100
|
-
it('should
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
vi.mocked(sha256).mockReturnValue('test-hash');
|
|
104
|
-
vi.mocked(clientS3Storage.putObject).mockResolvedValue(undefined);
|
|
105
|
-
|
|
106
|
-
const result = await uploadService.uploadFileToS3(nonImageFile, {
|
|
107
|
-
skipCheckFileType: true,
|
|
113
|
+
it('should use custom directory when provided', async () => {
|
|
114
|
+
const result = await uploadService.uploadFileToS3(mockFile, {
|
|
115
|
+
directory: 'custom/dir',
|
|
108
116
|
});
|
|
109
117
|
|
|
110
118
|
expect(result.success).toBe(true);
|
|
111
|
-
expect(
|
|
119
|
+
expect(result.data.dirname).toContain('custom/dir');
|
|
112
120
|
});
|
|
121
|
+
});
|
|
113
122
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
describe('uploadBase64ToS3', () => {
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
// Mock XMLHttpRequest for server upload
|
|
126
|
+
const xhrMock = {
|
|
127
|
+
addEventListener: vi.fn((event, handler) => {
|
|
128
|
+
if (event === 'load') {
|
|
129
|
+
setTimeout(() => handler({ target: { status: 200 } }), 0);
|
|
130
|
+
}
|
|
131
|
+
}),
|
|
132
|
+
open: vi.fn(),
|
|
133
|
+
send: vi.fn(),
|
|
134
|
+
setRequestHeader: vi.fn(),
|
|
135
|
+
status: 200,
|
|
136
|
+
upload: {
|
|
137
|
+
addEventListener: vi.fn(),
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
global.XMLHttpRequest = vi.fn(() => xhrMock) as any;
|
|
121
141
|
|
|
122
|
-
|
|
123
|
-
|
|
142
|
+
// Mock createS3PreSignedUrl
|
|
143
|
+
vi.mocked(lambdaClient.upload.createS3PreSignedUrl.mutate).mockResolvedValue(mockPreSignUrl);
|
|
124
144
|
});
|
|
125
|
-
});
|
|
126
145
|
|
|
127
|
-
describe('uploadBase64ToS3', () => {
|
|
128
146
|
it('should upload base64 data successfully', async () => {
|
|
129
147
|
const { parseDataUri } = await import('@lobechat/model-runtime');
|
|
130
148
|
vi.mocked(parseDataUri).mockReturnValueOnce({
|
|
@@ -135,7 +153,6 @@ describe('UploadService', () => {
|
|
|
135
153
|
|
|
136
154
|
const { sha256 } = await import('js-sha256');
|
|
137
155
|
vi.mocked(sha256).mockReturnValue('base64-hash');
|
|
138
|
-
vi.mocked(clientS3Storage.putObject).mockResolvedValue(undefined);
|
|
139
156
|
|
|
140
157
|
const base64Data = 'data:image/png;base64,dGVzdA==';
|
|
141
158
|
const result = await uploadService.uploadBase64ToS3(base64Data);
|
|
@@ -144,7 +161,7 @@ describe('UploadService', () => {
|
|
|
144
161
|
fileType: 'image/png',
|
|
145
162
|
hash: expect.any(String),
|
|
146
163
|
metadata: expect.objectContaining({
|
|
147
|
-
path: expect.stringContaining('
|
|
164
|
+
path: expect.stringContaining(fileEnv.NEXT_PUBLIC_S3_FILE_PATH || ''),
|
|
148
165
|
}),
|
|
149
166
|
size: expect.any(Number),
|
|
150
167
|
});
|
|
@@ -175,46 +192,58 @@ describe('UploadService', () => {
|
|
|
175
192
|
|
|
176
193
|
const { sha256 } = await import('js-sha256');
|
|
177
194
|
vi.mocked(sha256).mockReturnValue('custom-hash');
|
|
178
|
-
vi.mocked(clientS3Storage.putObject).mockResolvedValue(undefined);
|
|
179
195
|
|
|
180
196
|
const base64Data = 'data:image/png;base64,dGVzdA==';
|
|
181
197
|
const result = await uploadService.uploadBase64ToS3(base64Data, {
|
|
182
198
|
filename: 'custom-image',
|
|
183
199
|
});
|
|
184
200
|
|
|
185
|
-
|
|
201
|
+
// The filename will be regenerated with UUID, but should keep the extension
|
|
202
|
+
expect(result.metadata.filename).toMatch(/^mock-uuid\.png$/);
|
|
186
203
|
});
|
|
187
204
|
});
|
|
188
205
|
|
|
189
206
|
describe('uploadDataToS3', () => {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
// Mock XMLHttpRequest for server upload
|
|
209
|
+
const xhrMock = {
|
|
210
|
+
addEventListener: vi.fn((event, handler) => {
|
|
211
|
+
if (event === 'load') {
|
|
212
|
+
setTimeout(() => handler({ target: { status: 200 } }), 0);
|
|
213
|
+
}
|
|
214
|
+
}),
|
|
215
|
+
open: vi.fn(),
|
|
216
|
+
send: vi.fn(),
|
|
217
|
+
setRequestHeader: vi.fn(),
|
|
218
|
+
status: 200,
|
|
219
|
+
upload: {
|
|
220
|
+
addEventListener: vi.fn(),
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
global.XMLHttpRequest = vi.fn(() => xhrMock) as any;
|
|
194
224
|
|
|
225
|
+
// Mock createS3PreSignedUrl
|
|
226
|
+
vi.mocked(lambdaClient.upload.createS3PreSignedUrl.mutate).mockResolvedValue(mockPreSignUrl);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should upload JSON data successfully', async () => {
|
|
195
230
|
const data = { key: 'value', number: 123 };
|
|
196
|
-
|
|
197
|
-
const result = await uploadService.uploadDataToS3(data, {
|
|
198
|
-
skipCheckFileType: true,
|
|
199
|
-
});
|
|
231
|
+
const result = await uploadService.uploadDataToS3(data);
|
|
200
232
|
|
|
201
233
|
expect(result.success).toBe(true);
|
|
202
|
-
|
|
234
|
+
// The filename will be regenerated with UUID
|
|
235
|
+
expect(result.data.filename).toMatch(/^mock-uuid\.json$/);
|
|
203
236
|
});
|
|
204
237
|
|
|
205
238
|
it('should use custom filename when provided', async () => {
|
|
206
|
-
const { sha256 } = await import('js-sha256');
|
|
207
|
-
vi.mocked(sha256).mockReturnValue('custom-json-hash');
|
|
208
|
-
vi.mocked(clientS3Storage.putObject).mockResolvedValue(undefined);
|
|
209
|
-
|
|
210
239
|
const data = { test: true };
|
|
211
240
|
const result = await uploadService.uploadDataToS3(data, {
|
|
212
241
|
filename: 'custom.json',
|
|
213
|
-
skipCheckFileType: true,
|
|
214
242
|
});
|
|
215
243
|
|
|
216
244
|
expect(result.success).toBe(true);
|
|
217
|
-
|
|
245
|
+
// The filename will be regenerated with UUID, keeping the extension
|
|
246
|
+
expect(result.data.filename).toMatch(/^mock-uuid\.json$/);
|
|
218
247
|
});
|
|
219
248
|
});
|
|
220
249
|
|
|
@@ -359,25 +388,6 @@ describe('UploadService', () => {
|
|
|
359
388
|
});
|
|
360
389
|
});
|
|
361
390
|
|
|
362
|
-
describe('uploadToClientS3', () => {
|
|
363
|
-
it('should upload file to client S3 successfully', async () => {
|
|
364
|
-
const hash = 'test-hash';
|
|
365
|
-
const expectedResult = {
|
|
366
|
-
date: '1',
|
|
367
|
-
dirname: '',
|
|
368
|
-
filename: mockFile.name,
|
|
369
|
-
path: `client-s3://${hash}`,
|
|
370
|
-
};
|
|
371
|
-
|
|
372
|
-
vi.mocked(clientS3Storage.putObject).mockResolvedValue(undefined);
|
|
373
|
-
|
|
374
|
-
const result = await uploadService['uploadToClientS3'](hash, mockFile);
|
|
375
|
-
|
|
376
|
-
expect(clientS3Storage.putObject).toHaveBeenCalledWith(hash, mockFile);
|
|
377
|
-
expect(result).toEqual(expectedResult);
|
|
378
|
-
});
|
|
379
|
-
});
|
|
380
|
-
|
|
381
391
|
describe('getImageFileByUrlWithCORS', () => {
|
|
382
392
|
beforeEach(() => {
|
|
383
393
|
global.fetch = vi.fn();
|
package/src/services/upload.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isDesktop
|
|
1
|
+
import { isDesktop } from '@lobechat/const';
|
|
2
2
|
import { parseDataUri } from '@lobechat/model-runtime';
|
|
3
3
|
import { uuid } from '@lobechat/utils';
|
|
4
4
|
import dayjs from 'dayjs';
|
|
@@ -7,7 +7,6 @@ import { sha256 } from 'js-sha256';
|
|
|
7
7
|
import { fileEnv } from '@/envs/file';
|
|
8
8
|
import { lambdaClient } from '@/libs/trpc/client';
|
|
9
9
|
import { API_ENDPOINTS } from '@/services/_url';
|
|
10
|
-
import { clientS3Storage } from '@/services/file/ClientS3';
|
|
11
10
|
import { FileMetadata, UploadBase64ToS3Result } from '@/types/files';
|
|
12
11
|
import { FileUploadState, FileUploadStatus } from '@/types/files/upload';
|
|
13
12
|
|
|
@@ -60,7 +59,7 @@ class UploadService {
|
|
|
60
59
|
*/
|
|
61
60
|
uploadFileToS3 = async (
|
|
62
61
|
file: File,
|
|
63
|
-
{ onProgress, directory,
|
|
62
|
+
{ onProgress, directory, pathname }: UploadFileToS3Options,
|
|
64
63
|
): Promise<{ data: FileMetadata; success: boolean }> => {
|
|
65
64
|
const { getElectronStoreState } = await import('@/store/electron');
|
|
66
65
|
const { electronSyncSelectors } = await import('@/store/electron/selectors');
|
|
@@ -75,27 +74,10 @@ class UploadService {
|
|
|
75
74
|
}
|
|
76
75
|
|
|
77
76
|
// Server-side upload logic
|
|
78
|
-
if (isServerMode) {
|
|
79
|
-
// if is server mode, upload to server s3,
|
|
80
77
|
|
|
81
|
-
|
|
82
|
-
return { data, success: true };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// upload to client s3
|
|
86
|
-
// Client-side upload logic
|
|
87
|
-
if (!skipCheckFileType && !file.type.startsWith('image') && !file.type.startsWith('video')) {
|
|
88
|
-
onNotSupported?.();
|
|
89
|
-
return { data: undefined as unknown as FileMetadata, success: false };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const fileArrayBuffer = await file.arrayBuffer();
|
|
93
|
-
|
|
94
|
-
// 1. check file hash
|
|
95
|
-
const hash = sha256(fileArrayBuffer);
|
|
96
|
-
// Upload to the indexeddb in the browser
|
|
97
|
-
const data = await this.uploadToClientS3(hash, file);
|
|
78
|
+
// if is server mode, upload to server s3,
|
|
98
79
|
|
|
80
|
+
const data = await this.uploadToServerS3(file, { directory, onProgress, pathname });
|
|
99
81
|
return { data, success: true };
|
|
100
82
|
};
|
|
101
83
|
|
|
@@ -229,17 +211,6 @@ class UploadService {
|
|
|
229
211
|
return metadata;
|
|
230
212
|
};
|
|
231
213
|
|
|
232
|
-
private uploadToClientS3 = async (hash: string, file: File): Promise<FileMetadata> => {
|
|
233
|
-
await clientS3Storage.putObject(hash, file);
|
|
234
|
-
|
|
235
|
-
return {
|
|
236
|
-
date: (Date.now() / 1000 / 60 / 60).toFixed(0),
|
|
237
|
-
dirname: '',
|
|
238
|
-
filename: file.name,
|
|
239
|
-
path: `client-s3://${hash}`,
|
|
240
|
-
};
|
|
241
|
-
};
|
|
242
|
-
|
|
243
214
|
/**
|
|
244
215
|
* get image File item with cors image URL
|
|
245
216
|
* @param url
|
|
@@ -1,115 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,59 +0,0 @@
|
|
|
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
|
-
* Upload file
|
|
17
|
-
* @param key File hash
|
|
18
|
-
* @param file File object
|
|
19
|
-
*/
|
|
20
|
-
putObject = async (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
|
-
* Get file
|
|
31
|
-
* @param key File hash
|
|
32
|
-
* @returns File object
|
|
33
|
-
*/
|
|
34
|
-
getObject = async (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
|
-
console.log(`Failed to get object (key=${key}):`, e);
|
|
42
|
-
return undefined;
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Delete file
|
|
48
|
-
* @param key File hash
|
|
49
|
-
*/
|
|
50
|
-
deleteObject = async (key: string): Promise<void> => {
|
|
51
|
-
try {
|
|
52
|
-
await del(key, this.store);
|
|
53
|
-
} catch (e) {
|
|
54
|
-
throw new Error(`Failed to delete object (key=${key}): ${(e as Error).message}`);
|
|
55
|
-
}
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export const clientS3Storage = new BrowserS3Storage();
|