@lobehub/chat 1.77.15 → 1.77.16
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/server/routers/async/file.ts +3 -4
- package/src/server/routers/lambda/file.ts +8 -11
- package/src/server/routers/lambda/importer.ts +3 -4
- package/src/server/routers/lambda/message.ts +9 -3
- package/src/server/routers/lambda/ragEval.ts +5 -6
- package/src/server/services/file/impls/index.ts +12 -0
- package/src/server/services/file/impls/s3.test.ts +110 -0
- package/src/server/services/file/impls/s3.ts +60 -0
- package/src/server/services/file/impls/type.ts +44 -0
- package/src/server/services/file/index.ts +65 -0
- package/src/services/electron/__tests__/devtools.test.ts +34 -0
- package/src/server/utils/files.test.ts +0 -37
- package/src/server/utils/files.ts +0 -20
package/CHANGELOG.md
CHANGED
@@ -2,6 +2,31 @@
|
|
2
2
|
|
3
3
|
# Changelog
|
4
4
|
|
5
|
+
### [Version 1.77.16](https://github.com/lobehub/lobe-chat/compare/v1.77.15...v1.77.16)
|
6
|
+
|
7
|
+
<sup>Released on **2025-04-06**</sup>
|
8
|
+
|
9
|
+
#### ♻ Code Refactoring
|
10
|
+
|
11
|
+
- **misc**: Refactor the file service.
|
12
|
+
|
13
|
+
<br/>
|
14
|
+
|
15
|
+
<details>
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
17
|
+
|
18
|
+
#### Code refactoring
|
19
|
+
|
20
|
+
- **misc**: Refactor the file service, closes [#7323](https://github.com/lobehub/lobe-chat/issues/7323) ([3721b88](https://github.com/lobehub/lobe-chat/commit/3721b88))
|
21
|
+
|
22
|
+
</details>
|
23
|
+
|
24
|
+
<div align="right">
|
25
|
+
|
26
|
+
[](#readme-top)
|
27
|
+
|
28
|
+
</div>
|
29
|
+
|
5
30
|
### [Version 1.77.15](https://github.com/lobehub/lobe-chat/compare/v1.77.14...v1.77.15)
|
6
31
|
|
7
32
|
<sup>Released on **2025-04-06**</sup>
|
package/changelog/v1.json
CHANGED
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@lobehub/chat",
|
3
|
-
"version": "1.77.
|
3
|
+
"version": "1.77.16",
|
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",
|
@@ -14,8 +14,8 @@ import { NewChunkItem, NewEmbeddingsItem } from '@/database/schemas';
|
|
14
14
|
import { asyncAuthedProcedure, asyncRouter as router } from '@/libs/trpc/async';
|
15
15
|
import { getServerDefaultFilesConfig } from '@/server/globalConfig';
|
16
16
|
import { initAgentRuntimeWithUserPayload } from '@/server/modules/AgentRuntime';
|
17
|
-
import { S3 } from '@/server/modules/S3';
|
18
17
|
import { ChunkService } from '@/server/services/chunk';
|
18
|
+
import { FileService } from '@/server/services/file';
|
19
19
|
import {
|
20
20
|
AsyncTaskError,
|
21
21
|
AsyncTaskErrorType,
|
@@ -35,6 +35,7 @@ const fileProcedure = asyncAuthedProcedure.use(async (opts) => {
|
|
35
35
|
chunkService: new ChunkService(ctx.userId),
|
36
36
|
embeddingModel: new EmbeddingModel(ctx.serverDB, ctx.userId),
|
37
37
|
fileModel: new FileModel(ctx.serverDB, ctx.userId),
|
38
|
+
fileService: new FileService(),
|
38
39
|
},
|
39
40
|
});
|
40
41
|
});
|
@@ -162,11 +163,9 @@ export const fileRouter = router({
|
|
162
163
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'File not found' });
|
163
164
|
}
|
164
165
|
|
165
|
-
const s3 = new S3();
|
166
|
-
|
167
166
|
let content: Uint8Array | undefined;
|
168
167
|
try {
|
169
|
-
content = await
|
168
|
+
content = await ctx.fileService.getFileByteArray(file.url);
|
170
169
|
} catch (e) {
|
171
170
|
console.error(e);
|
172
171
|
// if file not found, delete it from db
|
@@ -7,8 +7,7 @@ import { ChunkModel } from '@/database/models/chunk';
|
|
7
7
|
import { FileModel } from '@/database/models/file';
|
8
8
|
import { authedProcedure, router } from '@/libs/trpc';
|
9
9
|
import { serverDatabase } from '@/libs/trpc/lambda';
|
10
|
-
import {
|
11
|
-
import { getFullFileUrl } from '@/server/utils/files';
|
10
|
+
import { FileService } from '@/server/services/file';
|
12
11
|
import { AsyncTaskStatus, AsyncTaskType } from '@/types/asyncTask';
|
13
12
|
import { FileListItem, QueryFileListSchema, UploadFileSchema } from '@/types/files';
|
14
13
|
|
@@ -20,6 +19,7 @@ const fileProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
|
20
19
|
asyncTaskModel: new AsyncTaskModel(ctx.serverDB, ctx.userId),
|
21
20
|
chunkModel: new ChunkModel(ctx.serverDB, ctx.userId),
|
22
21
|
fileModel: new FileModel(ctx.serverDB, ctx.userId),
|
22
|
+
fileService: new FileService(),
|
23
23
|
},
|
24
24
|
});
|
25
25
|
});
|
@@ -50,7 +50,7 @@ export const fileRouter = router({
|
|
50
50
|
!isExist,
|
51
51
|
);
|
52
52
|
|
53
|
-
return { id, url: await getFullFileUrl(input.url) };
|
53
|
+
return { id, url: await ctx.fileService.getFullFileUrl(input.url) };
|
54
54
|
}),
|
55
55
|
findById: fileProcedure
|
56
56
|
.input(
|
@@ -62,7 +62,7 @@ export const fileRouter = router({
|
|
62
62
|
const item = await ctx.fileModel.findById(input.id);
|
63
63
|
if (!item) throw new TRPCError({ code: 'BAD_REQUEST', message: 'File not found' });
|
64
64
|
|
65
|
-
return { ...item, url: await getFullFileUrl(item?.url) };
|
65
|
+
return { ...item, url: await ctx.fileService.getFullFileUrl(item?.url) };
|
66
66
|
}),
|
67
67
|
|
68
68
|
getFileItemById: fileProcedure
|
@@ -95,7 +95,7 @@ export const fileRouter = router({
|
|
95
95
|
embeddingError: embeddingTask?.error,
|
96
96
|
embeddingStatus: embeddingTask?.status as AsyncTaskStatus,
|
97
97
|
finishEmbedding: embeddingTask?.status === AsyncTaskStatus.Success,
|
98
|
-
url: await getFullFileUrl(item.url!),
|
98
|
+
url: await ctx.fileService.getFullFileUrl(item.url!),
|
99
99
|
};
|
100
100
|
}),
|
101
101
|
|
@@ -132,7 +132,7 @@ export const fileRouter = router({
|
|
132
132
|
embeddingError: embeddingTask?.error ?? null,
|
133
133
|
embeddingStatus: embeddingTask?.status as AsyncTaskStatus,
|
134
134
|
finishEmbedding: embeddingTask?.status === AsyncTaskStatus.Success,
|
135
|
-
url: await getFullFileUrl(item.url!),
|
135
|
+
url: await ctx.fileService.getFullFileUrl(item.url!),
|
136
136
|
} as FileListItem;
|
137
137
|
resultFiles.push(fileItem);
|
138
138
|
}
|
@@ -150,8 +150,7 @@ export const fileRouter = router({
|
|
150
150
|
if (!file) return;
|
151
151
|
|
152
152
|
// delele the file from remove from S3 if it is not used by other files
|
153
|
-
|
154
|
-
await s3Client.deleteFile(file.url!);
|
153
|
+
await ctx.fileService.deleteFile(file.url!);
|
155
154
|
}),
|
156
155
|
|
157
156
|
removeFileAsyncTask: fileProcedure
|
@@ -184,9 +183,7 @@ export const fileRouter = router({
|
|
184
183
|
if (!needToRemoveFileList || needToRemoveFileList.length === 0) return;
|
185
184
|
|
186
185
|
// remove from S3
|
187
|
-
|
188
|
-
|
189
|
-
await s3Client.deleteFiles(needToRemoveFileList.map((file) => file.url!));
|
186
|
+
await ctx.fileService.deleteFiles(needToRemoveFileList.map((file) => file.url!));
|
190
187
|
}),
|
191
188
|
});
|
192
189
|
|
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|
4
4
|
import { DataImporterRepos } from '@/database/repositories/dataImporter';
|
5
5
|
import { authedProcedure, router } from '@/libs/trpc';
|
6
6
|
import { serverDatabase } from '@/libs/trpc/lambda';
|
7
|
-
import {
|
7
|
+
import { FileService } from '@/server/services/file';
|
8
8
|
import { ImportPgDataStructure } from '@/types/export';
|
9
9
|
import { ImportResultData, ImporterEntryData } from '@/types/importer';
|
10
10
|
|
@@ -13,7 +13,7 @@ const importProcedure = authedProcedure.use(serverDatabase).use(async (opts) =>
|
|
13
13
|
const dataImporterService = new DataImporterRepos(ctx.serverDB, ctx.userId);
|
14
14
|
|
15
15
|
return opts.next({
|
16
|
-
ctx: { dataImporterService },
|
16
|
+
ctx: { dataImporterService, fileService: new FileService() },
|
17
17
|
});
|
18
18
|
});
|
19
19
|
|
@@ -24,8 +24,7 @@ export const importerRouter = router({
|
|
24
24
|
let data: ImporterEntryData | undefined;
|
25
25
|
|
26
26
|
try {
|
27
|
-
const
|
28
|
-
const dataStr = await s3.getFileContent(input.pathname);
|
27
|
+
const dataStr = await ctx.fileService.getFileContent(input.pathname);
|
29
28
|
data = JSON.parse(dataStr);
|
30
29
|
} catch {
|
31
30
|
data = undefined;
|
@@ -5,7 +5,7 @@ import { updateMessagePluginSchema } from '@/database/schemas';
|
|
5
5
|
import { getServerDB } from '@/database/server';
|
6
6
|
import { authedProcedure, publicProcedure, router } from '@/libs/trpc';
|
7
7
|
import { serverDatabase } from '@/libs/trpc/lambda';
|
8
|
-
import {
|
8
|
+
import { FileService } from '@/server/services/file';
|
9
9
|
import { ChatMessage } from '@/types/message';
|
10
10
|
import { BatchTaskResult } from '@/types/service';
|
11
11
|
|
@@ -15,7 +15,10 @@ const messageProcedure = authedProcedure.use(serverDatabase).use(async (opts) =>
|
|
15
15
|
const { ctx } = opts;
|
16
16
|
|
17
17
|
return opts.next({
|
18
|
-
ctx: {
|
18
|
+
ctx: {
|
19
|
+
fileService: new FileService(),
|
20
|
+
messageModel: new MessageModel(ctx.serverDB, ctx.userId),
|
21
|
+
},
|
19
22
|
});
|
20
23
|
});
|
21
24
|
|
@@ -99,8 +102,11 @@ export const messageRouter = router({
|
|
99
102
|
const serverDB = await getServerDB();
|
100
103
|
|
101
104
|
const messageModel = new MessageModel(serverDB, ctx.userId);
|
105
|
+
const fileService = new FileService();
|
102
106
|
|
103
|
-
return messageModel.query(input, {
|
107
|
+
return messageModel.query(input, {
|
108
|
+
postProcessUrl: (path) => fileService.getFullFileUrl(path),
|
109
|
+
});
|
104
110
|
}),
|
105
111
|
|
106
112
|
rankModels: messageProcedure.query(async ({ ctx }) => {
|
@@ -16,9 +16,8 @@ import {
|
|
16
16
|
import { authedProcedure, router } from '@/libs/trpc';
|
17
17
|
import { serverDatabase } from '@/libs/trpc/lambda';
|
18
18
|
import { keyVaults } from '@/libs/trpc/middleware/keyVaults';
|
19
|
-
import { S3 } from '@/server/modules/S3';
|
20
19
|
import { createAsyncServerClient } from '@/server/routers/async';
|
21
|
-
import {
|
20
|
+
import { FileService } from '@/server/services/file';
|
22
21
|
import {
|
23
22
|
EvalDatasetRecord,
|
24
23
|
EvalEvaluationStatus,
|
@@ -42,7 +41,7 @@ const ragEvalProcedure = authedProcedure
|
|
42
41
|
datasetRecordModel: new EvalDatasetRecordModel(ctx.userId),
|
43
42
|
evaluationModel: new EvalEvaluationModel(ctx.userId),
|
44
43
|
evaluationRecordModel: new EvaluationRecordModel(ctx.userId),
|
45
|
-
|
44
|
+
fileService: new FileService(),
|
46
45
|
},
|
47
46
|
});
|
48
47
|
});
|
@@ -144,7 +143,7 @@ export const ragEvalRouter = router({
|
|
144
143
|
}),
|
145
144
|
)
|
146
145
|
.mutation(async ({ input, ctx }) => {
|
147
|
-
const dataStr = await ctx.
|
146
|
+
const dataStr = await ctx.fileService.getFileContent(input.pathname);
|
148
147
|
const items = JSONL.parse<InsertEvalDatasetRecord>(dataStr);
|
149
148
|
|
150
149
|
insertEvalDatasetRecordSchema.array().parse(items);
|
@@ -262,12 +261,12 @@ export const ragEvalRouter = router({
|
|
262
261
|
const filename = `${date}-eval_${evaluation.id}-${evaluation.name}.jsonl`;
|
263
262
|
const path = `rag_eval_records/${filename}`;
|
264
263
|
|
265
|
-
await ctx.
|
264
|
+
await ctx.fileService.uploadContent(path, JSONL.stringify(evalRecords));
|
266
265
|
|
267
266
|
// 保存数据
|
268
267
|
await ctx.evaluationModel.update(input.id, {
|
269
268
|
status: EvalEvaluationStatus.Success,
|
270
|
-
evalRecordsUrl: await getFullFileUrl(path),
|
269
|
+
evalRecordsUrl: await ctx.fileService.getFullFileUrl(path),
|
271
270
|
});
|
272
271
|
}
|
273
272
|
|
@@ -0,0 +1,12 @@
|
|
1
|
+
import { S3StaticFileImpl } from './s3';
|
2
|
+
import { FileServiceImpl } from './type';
|
3
|
+
|
4
|
+
/**
|
5
|
+
* 创建文件服务模块
|
6
|
+
*/
|
7
|
+
export const createFileServiceModule = (): FileServiceImpl => {
|
8
|
+
// 默认使用 S3 实现
|
9
|
+
return new S3StaticFileImpl();
|
10
|
+
};
|
11
|
+
|
12
|
+
export type { FileServiceImpl } from './type';
|
@@ -0,0 +1,110 @@
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
2
|
+
|
3
|
+
import { S3StaticFileImpl } from './s3';
|
4
|
+
|
5
|
+
const config = {
|
6
|
+
S3_ENABLE_PATH_STYLE: false,
|
7
|
+
S3_PUBLIC_DOMAIN: 'https://example.com',
|
8
|
+
S3_BUCKET: 'my-bucket',
|
9
|
+
S3_SET_ACL: true,
|
10
|
+
};
|
11
|
+
|
12
|
+
// 模拟 fileEnv
|
13
|
+
vi.mock('@/config/file', () => ({
|
14
|
+
get fileEnv() {
|
15
|
+
return config;
|
16
|
+
},
|
17
|
+
}));
|
18
|
+
|
19
|
+
// 模拟 S3 类
|
20
|
+
vi.mock('@/server/modules/S3', () => ({
|
21
|
+
S3: vi.fn().mockImplementation(() => ({
|
22
|
+
createPreSignedUrlForPreview: vi
|
23
|
+
.fn()
|
24
|
+
.mockResolvedValue('https://presigned.example.com/test.jpg'),
|
25
|
+
getFileContent: vi.fn().mockResolvedValue('file content'),
|
26
|
+
getFileByteArray: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])),
|
27
|
+
deleteFile: vi.fn().mockResolvedValue({}),
|
28
|
+
deleteFiles: vi.fn().mockResolvedValue({}),
|
29
|
+
createPreSignedUrl: vi.fn().mockResolvedValue('https://upload.example.com/test.jpg'),
|
30
|
+
uploadContent: vi.fn().mockResolvedValue({}),
|
31
|
+
})),
|
32
|
+
}));
|
33
|
+
|
34
|
+
describe('S3StaticFileImpl', () => {
|
35
|
+
let fileService: S3StaticFileImpl;
|
36
|
+
|
37
|
+
beforeEach(() => {
|
38
|
+
fileService = new S3StaticFileImpl();
|
39
|
+
});
|
40
|
+
|
41
|
+
describe('getFullFileUrl', () => {
|
42
|
+
it('should return empty string for null or undefined input', async () => {
|
43
|
+
expect(await fileService.getFullFileUrl(null)).toBe('');
|
44
|
+
expect(await fileService.getFullFileUrl(undefined)).toBe('');
|
45
|
+
});
|
46
|
+
|
47
|
+
it('当S3_SET_ACL为false时应返回预签名URL', async () => {
|
48
|
+
config.S3_SET_ACL = false;
|
49
|
+
const url = 'path/to/file.jpg';
|
50
|
+
expect(await fileService.getFullFileUrl(url)).toBe('https://presigned.example.com/test.jpg');
|
51
|
+
config.S3_SET_ACL = true;
|
52
|
+
});
|
53
|
+
|
54
|
+
it('should return correct URL when S3_ENABLE_PATH_STYLE is false', async () => {
|
55
|
+
const url = 'path/to/file.jpg';
|
56
|
+
expect(await fileService.getFullFileUrl(url)).toBe('https://example.com/path/to/file.jpg');
|
57
|
+
});
|
58
|
+
|
59
|
+
it('should return correct URL when S3_ENABLE_PATH_STYLE is true', async () => {
|
60
|
+
config.S3_ENABLE_PATH_STYLE = true;
|
61
|
+
const url = 'path/to/file.jpg';
|
62
|
+
expect(await fileService.getFullFileUrl(url)).toBe(
|
63
|
+
'https://example.com/my-bucket/path/to/file.jpg',
|
64
|
+
);
|
65
|
+
config.S3_ENABLE_PATH_STYLE = false;
|
66
|
+
});
|
67
|
+
});
|
68
|
+
|
69
|
+
describe('getFileContent', () => {
|
70
|
+
it('应该返回文件内容', async () => {
|
71
|
+
expect(await fileService.getFileContent('test.txt')).toBe('file content');
|
72
|
+
});
|
73
|
+
});
|
74
|
+
|
75
|
+
describe('getFileByteArray', () => {
|
76
|
+
it('应该返回文件字节数组', async () => {
|
77
|
+
const result = await fileService.getFileByteArray('test.jpg');
|
78
|
+
expect(result).toBeInstanceOf(Uint8Array);
|
79
|
+
expect(result.length).toBe(3);
|
80
|
+
});
|
81
|
+
});
|
82
|
+
|
83
|
+
describe('deleteFile', () => {
|
84
|
+
it('应该调用S3的deleteFile方法', async () => {
|
85
|
+
await fileService.deleteFile('test.jpg');
|
86
|
+
expect(fileService['s3'].deleteFile).toHaveBeenCalledWith('test.jpg');
|
87
|
+
});
|
88
|
+
});
|
89
|
+
|
90
|
+
describe('deleteFiles', () => {
|
91
|
+
it('应该调用S3的deleteFiles方法', async () => {
|
92
|
+
await fileService.deleteFiles(['test1.jpg', 'test2.jpg']);
|
93
|
+
expect(fileService['s3'].deleteFiles).toHaveBeenCalledWith(['test1.jpg', 'test2.jpg']);
|
94
|
+
});
|
95
|
+
});
|
96
|
+
|
97
|
+
describe('createPreSignedUrl', () => {
|
98
|
+
it('应该调用S3的createPreSignedUrl方法', async () => {
|
99
|
+
const result = await fileService.createPreSignedUrl('test.jpg');
|
100
|
+
expect(result).toBe('https://upload.example.com/test.jpg');
|
101
|
+
});
|
102
|
+
});
|
103
|
+
|
104
|
+
describe('uploadContent', () => {
|
105
|
+
it('应该调用S3的uploadContent方法', async () => {
|
106
|
+
await fileService.uploadContent('test.jpg', 'content');
|
107
|
+
expect(fileService['s3'].uploadContent).toHaveBeenCalledWith('test.jpg', 'content');
|
108
|
+
});
|
109
|
+
});
|
110
|
+
});
|
@@ -0,0 +1,60 @@
|
|
1
|
+
import urlJoin from 'url-join';
|
2
|
+
|
3
|
+
import { fileEnv } from '@/config/file';
|
4
|
+
import { S3 } from '@/server/modules/S3';
|
5
|
+
|
6
|
+
import { FileServiceImpl } from './type';
|
7
|
+
|
8
|
+
/**
|
9
|
+
* 基于S3的文件服务实现
|
10
|
+
*/
|
11
|
+
export class S3StaticFileImpl implements FileServiceImpl {
|
12
|
+
private readonly s3: S3;
|
13
|
+
|
14
|
+
constructor() {
|
15
|
+
this.s3 = new S3();
|
16
|
+
}
|
17
|
+
|
18
|
+
async deleteFile(key: string) {
|
19
|
+
return this.s3.deleteFile(key);
|
20
|
+
}
|
21
|
+
|
22
|
+
async deleteFiles(keys: string[]) {
|
23
|
+
return this.s3.deleteFiles(keys);
|
24
|
+
}
|
25
|
+
|
26
|
+
async getFileContent(key: string): Promise<string> {
|
27
|
+
return this.s3.getFileContent(key);
|
28
|
+
}
|
29
|
+
|
30
|
+
async getFileByteArray(key: string): Promise<Uint8Array> {
|
31
|
+
return this.s3.getFileByteArray(key);
|
32
|
+
}
|
33
|
+
|
34
|
+
async createPreSignedUrl(key: string): Promise<string> {
|
35
|
+
return this.s3.createPreSignedUrl(key);
|
36
|
+
}
|
37
|
+
|
38
|
+
async createPreSignedUrlForPreview(key: string, expiresIn?: number): Promise<string> {
|
39
|
+
return this.s3.createPreSignedUrlForPreview(key, expiresIn);
|
40
|
+
}
|
41
|
+
|
42
|
+
async uploadContent(path: string, content: string) {
|
43
|
+
return this.s3.uploadContent(path, content);
|
44
|
+
}
|
45
|
+
|
46
|
+
async getFullFileUrl(url?: string | null, expiresIn?: number): Promise<string> {
|
47
|
+
if (!url) return '';
|
48
|
+
|
49
|
+
// If bucket is not set public read, the preview address needs to be regenerated each time
|
50
|
+
if (!fileEnv.S3_SET_ACL) {
|
51
|
+
return await this.createPreSignedUrlForPreview(url, expiresIn);
|
52
|
+
}
|
53
|
+
|
54
|
+
if (fileEnv.S3_ENABLE_PATH_STYLE) {
|
55
|
+
return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, fileEnv.S3_BUCKET!, url);
|
56
|
+
}
|
57
|
+
|
58
|
+
return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, url);
|
59
|
+
}
|
60
|
+
}
|
@@ -0,0 +1,44 @@
|
|
1
|
+
/**
|
2
|
+
* S3文件服务实现
|
3
|
+
*/
|
4
|
+
export interface FileServiceImpl {
|
5
|
+
/**
|
6
|
+
* 创建预签名上传URL
|
7
|
+
*/
|
8
|
+
createPreSignedUrl(key: string): Promise<string>;
|
9
|
+
|
10
|
+
/**
|
11
|
+
* 创建预签名预览URL
|
12
|
+
*/
|
13
|
+
createPreSignedUrlForPreview(key: string, expiresIn?: number): Promise<string>;
|
14
|
+
|
15
|
+
/**
|
16
|
+
* 删除文件
|
17
|
+
*/
|
18
|
+
deleteFile(key: string): Promise<any>;
|
19
|
+
|
20
|
+
/**
|
21
|
+
* 批量删除文件
|
22
|
+
*/
|
23
|
+
deleteFiles(keys: string[]): Promise<any>;
|
24
|
+
|
25
|
+
/**
|
26
|
+
* 获取文件字节数组
|
27
|
+
*/
|
28
|
+
getFileByteArray(key: string): Promise<Uint8Array>;
|
29
|
+
|
30
|
+
/**
|
31
|
+
* 获取文件内容
|
32
|
+
*/
|
33
|
+
getFileContent(key: string): Promise<string>;
|
34
|
+
|
35
|
+
/**
|
36
|
+
* 获取完整文件URL
|
37
|
+
*/
|
38
|
+
getFullFileUrl(url?: string | null, expiresIn?: number): Promise<string>;
|
39
|
+
|
40
|
+
/**
|
41
|
+
* 上传内容
|
42
|
+
*/
|
43
|
+
uploadContent(path: string, content: string): Promise<any>;
|
44
|
+
}
|
@@ -0,0 +1,65 @@
|
|
1
|
+
import { FileServiceImpl, createFileServiceModule } from './impls';
|
2
|
+
|
3
|
+
/**
|
4
|
+
* 文件服务类
|
5
|
+
* 使用模块化实现方式,提供文件操作服务
|
6
|
+
*/
|
7
|
+
export class FileService {
|
8
|
+
private impl: FileServiceImpl = createFileServiceModule();
|
9
|
+
|
10
|
+
/**
|
11
|
+
* 删除文件
|
12
|
+
*/
|
13
|
+
public async deleteFile(key: string) {
|
14
|
+
return this.impl.deleteFile(key);
|
15
|
+
}
|
16
|
+
|
17
|
+
/**
|
18
|
+
* 批量删除文件
|
19
|
+
*/
|
20
|
+
public async deleteFiles(keys: string[]) {
|
21
|
+
return this.impl.deleteFiles(keys);
|
22
|
+
}
|
23
|
+
|
24
|
+
/**
|
25
|
+
* 获取文件内容
|
26
|
+
*/
|
27
|
+
public async getFileContent(key: string): Promise<string> {
|
28
|
+
return this.impl.getFileContent(key);
|
29
|
+
}
|
30
|
+
|
31
|
+
/**
|
32
|
+
* 获取文件字节数组
|
33
|
+
*/
|
34
|
+
public async getFileByteArray(key: string): Promise<Uint8Array> {
|
35
|
+
return this.impl.getFileByteArray(key);
|
36
|
+
}
|
37
|
+
|
38
|
+
/**
|
39
|
+
* 创建预签名上传URL
|
40
|
+
*/
|
41
|
+
public async createPreSignedUrl(key: string): Promise<string> {
|
42
|
+
return this.impl.createPreSignedUrl(key);
|
43
|
+
}
|
44
|
+
|
45
|
+
/**
|
46
|
+
* 创建预签名预览URL
|
47
|
+
*/
|
48
|
+
public async createPreSignedUrlForPreview(key: string, expiresIn?: number): Promise<string> {
|
49
|
+
return this.impl.createPreSignedUrlForPreview(key, expiresIn);
|
50
|
+
}
|
51
|
+
|
52
|
+
/**
|
53
|
+
* 上传内容
|
54
|
+
*/
|
55
|
+
public async uploadContent(path: string, content: string) {
|
56
|
+
return this.impl.uploadContent(path, content);
|
57
|
+
}
|
58
|
+
|
59
|
+
/**
|
60
|
+
* 获取完整文件URL
|
61
|
+
*/
|
62
|
+
public async getFullFileUrl(url?: string | null, expiresIn?: number): Promise<string> {
|
63
|
+
return this.impl.getFullFileUrl(url, expiresIn);
|
64
|
+
}
|
65
|
+
}
|
@@ -0,0 +1,34 @@
|
|
1
|
+
import { dispatch } from '@lobechat/electron-client-ipc';
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
3
|
+
|
4
|
+
import { electronDevtoolsService } from '../devtools';
|
5
|
+
|
6
|
+
vi.mock('@lobechat/electron-client-ipc', () => ({
|
7
|
+
dispatch: vi.fn(),
|
8
|
+
}));
|
9
|
+
|
10
|
+
describe('DevtoolsService', () => {
|
11
|
+
beforeEach(() => {
|
12
|
+
vi.clearAllMocks();
|
13
|
+
});
|
14
|
+
|
15
|
+
describe('openDevtools', () => {
|
16
|
+
it('should call dispatch with openDevtools', async () => {
|
17
|
+
await electronDevtoolsService.openDevtools();
|
18
|
+
expect(dispatch).toHaveBeenCalledWith('openDevtools');
|
19
|
+
});
|
20
|
+
|
21
|
+
it('should return void when dispatch succeeds', async () => {
|
22
|
+
vi.mocked(dispatch).mockResolvedValueOnce();
|
23
|
+
const result = await electronDevtoolsService.openDevtools();
|
24
|
+
expect(result).toBeUndefined();
|
25
|
+
});
|
26
|
+
|
27
|
+
it('should throw error when dispatch fails', async () => {
|
28
|
+
const error = new Error('Failed to open devtools');
|
29
|
+
vi.mocked(dispatch).mockRejectedValueOnce(error);
|
30
|
+
|
31
|
+
await expect(electronDevtoolsService.openDevtools()).rejects.toThrow(error);
|
32
|
+
});
|
33
|
+
});
|
34
|
+
});
|
@@ -1,37 +0,0 @@
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
2
|
-
|
3
|
-
import { fileEnv } from '@/config/file';
|
4
|
-
|
5
|
-
import { getFullFileUrl } from './files';
|
6
|
-
|
7
|
-
const config = {
|
8
|
-
S3_ENABLE_PATH_STYLE: false,
|
9
|
-
S3_PUBLIC_DOMAIN: 'https://example.com',
|
10
|
-
S3_BUCKET: 'my-bucket',
|
11
|
-
S3_SET_ACL: true,
|
12
|
-
};
|
13
|
-
|
14
|
-
vi.mock('@/config/file', () => ({
|
15
|
-
get fileEnv() {
|
16
|
-
return config;
|
17
|
-
},
|
18
|
-
}));
|
19
|
-
|
20
|
-
describe('getFullFileUrl', () => {
|
21
|
-
it('should return empty string for null or undefined input', async () => {
|
22
|
-
expect(await getFullFileUrl(null)).toBe('');
|
23
|
-
expect(await getFullFileUrl(undefined)).toBe('');
|
24
|
-
});
|
25
|
-
|
26
|
-
it('should return correct URL when S3_ENABLE_PATH_STYLE is false', async () => {
|
27
|
-
const url = 'path/to/file.jpg';
|
28
|
-
expect(await getFullFileUrl(url)).toBe('https://example.com/path/to/file.jpg');
|
29
|
-
});
|
30
|
-
|
31
|
-
it('should return correct URL when S3_ENABLE_PATH_STYLE is true', async () => {
|
32
|
-
config.S3_ENABLE_PATH_STYLE = true;
|
33
|
-
const url = 'path/to/file.jpg';
|
34
|
-
expect(await getFullFileUrl(url)).toBe('https://example.com/my-bucket/path/to/file.jpg');
|
35
|
-
config.S3_ENABLE_PATH_STYLE = false;
|
36
|
-
});
|
37
|
-
});
|
@@ -1,20 +0,0 @@
|
|
1
|
-
import urlJoin from 'url-join';
|
2
|
-
|
3
|
-
import { fileEnv } from '@/config/file';
|
4
|
-
import { S3 } from '@/server/modules/S3';
|
5
|
-
|
6
|
-
export const getFullFileUrl = async (url?: string | null, expiresIn?: number) => {
|
7
|
-
if (!url) return '';
|
8
|
-
|
9
|
-
// If bucket is not set public read, the preview address needs to be regenerated each time
|
10
|
-
if (!fileEnv.S3_SET_ACL) {
|
11
|
-
const s3 = new S3();
|
12
|
-
return await s3.createPreSignedUrlForPreview(url, expiresIn);
|
13
|
-
}
|
14
|
-
|
15
|
-
if (fileEnv.S3_ENABLE_PATH_STYLE) {
|
16
|
-
return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, fileEnv.S3_BUCKET!, url);
|
17
|
-
}
|
18
|
-
|
19
|
-
return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, url);
|
20
|
-
};
|