@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 +50 -0
- package/changelog/v1.json +18 -0
- package/docs/self-hosting/advanced/auth/next-auth/logto.mdx +23 -1
- package/docs/self-hosting/advanced/auth/next-auth/logto.zh-CN.mdx +23 -1
- package/package.json +1 -1
- package/src/config/modelProviders/github.ts +10 -0
- 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,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
|
+
[](#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
|
+
[](#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.
|
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
|
-
|
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
|
+
}
|