@lobehub/chat 0.100.5 → 0.101.1
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/.eslintrc.js +1 -0
- package/CHANGELOG.md +50 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/__mocks__/zustand/traditional.ts +25 -0
- package/package.json +7 -3
- package/src/app/api/files/image/imgur.ts +72 -0
- package/src/app/api/files/image/route.ts +42 -0
- package/src/app/api/openai/chat/createChatCompletion.ts +2 -1
- package/src/app/chat/(desktop)/features/ChatInput/DragUpload.tsx +166 -0
- package/src/app/chat/(desktop)/features/ChatInput/Footer/LocalFiles.tsx +10 -0
- package/src/app/chat/(desktop)/features/ChatInput/Footer/index.tsx +64 -0
- package/src/app/chat/(desktop)/features/ChatInput/InputArea.tsx +3 -3
- package/src/app/chat/(desktop)/features/ChatInput/index.tsx +38 -30
- package/src/app/chat/components/FileList/FileItem.style.ts +81 -0
- package/src/app/chat/components/FileList/FileItem.tsx +69 -0
- package/src/app/chat/components/FileList/Lightbox.tsx +46 -0
- package/src/app/chat/components/FileList/index.tsx +63 -0
- package/src/app/chat/features/ChatInput/ActionBar/Clear.tsx +13 -4
- package/src/app/chat/features/ChatInput/ActionBar/FileUpload.tsx +53 -0
- package/src/app/chat/features/ChatInput/ActionBar/config.ts +3 -1
- package/src/app/chat/{(desktop)/features → features}/ChatInput/useSend.ts +5 -1
- package/src/app/chat/features/Conversation/ChatList/Messages/User.tsx +23 -0
- package/src/app/chat/features/Conversation/ChatList/Messages/index.ts +2 -0
- package/src/app/market/(desktop)/features/AgentDetail.tsx +14 -9
- package/src/app/market/(desktop)/index.tsx +2 -3
- package/src/app/market/(mobile)/index.tsx +2 -3
- package/src/app/market/features/AgentCard/AgentCardItem.tsx +4 -1
- package/src/app/market/features/AgentCard/index.tsx +20 -31
- package/src/app/market/features/AgentDetailContent/AgentInfo/Header.tsx +0 -1
- package/src/app/market/features/AgentDetailContent/AgentInfo/index.tsx +2 -3
- package/src/app/market/features/AgentSearchBar/index.tsx +9 -4
- package/src/app/market/features/TagList/Inner.tsx +28 -0
- package/src/app/market/features/TagList/index.tsx +12 -23
- package/src/config/__tests__/client.test.ts +76 -0
- package/src/config/__tests__/server.test.ts +52 -0
- package/src/config/server.ts +8 -0
- package/src/const/layoutTokens.ts +1 -1
- package/src/const/llm.ts +3 -0
- package/src/const/settings.ts +2 -0
- package/src/database/core/__tests__/db.test.ts +77 -0
- package/src/database/core/__tests__/model.test.ts +55 -0
- package/src/database/core/db.ts +25 -0
- package/src/database/core/index.ts +1 -0
- package/src/database/core/model.ts +49 -0
- package/src/database/core/schema.ts +9 -0
- package/src/database/models/file.test.ts +71 -0
- package/src/database/models/file.ts +26 -0
- package/src/locales/default/chat.ts +5 -0
- package/src/services/__tests__/file.test.ts +88 -0
- package/src/services/chatModel.ts +11 -6
- package/src/services/file.ts +36 -0
- package/src/store/files/index.ts +2 -0
- package/src/store/files/initialState.ts +7 -0
- package/src/store/files/selectors.test.ts +75 -0
- package/src/store/files/selectors.ts +34 -0
- package/src/store/files/slices/images/action.test.ts +186 -0
- package/src/store/files/slices/images/action.ts +83 -0
- package/src/store/files/slices/images/index.ts +3 -0
- package/src/store/files/slices/images/initialState.ts +11 -0
- package/src/store/files/store.ts +27 -0
- package/src/store/global/selectors/settings.test.ts +1 -3
- package/src/store/market/action.ts +9 -1
- package/src/store/market/initialState.ts +2 -0
- package/src/store/market/selectors.ts +10 -8
- package/src/store/session/slices/agentConfig/selectors.ts +7 -1
- package/src/store/session/slices/chat/actions/message.ts +78 -17
- package/src/store/session/slices/chat/reducers/files.test.ts +38 -0
- package/src/store/session/slices/chat/reducers/files.ts +37 -0
- package/src/store/session/slices/session/initialState.ts +1 -0
- package/src/store/session/slices/session/reducers/session.test.ts +98 -1
- package/src/store/session/slices/session/reducers/session.ts +26 -1
- package/src/types/chatMessage.ts +3 -2
- package/src/types/database/db.ts +4 -0
- package/src/types/database/files.ts +38 -0
- package/src/types/files.ts +8 -0
- package/src/types/market.ts +1 -0
- package/src/types/openai/chat.ts +15 -1
- package/src/types/session.ts +1 -0
- package/tests/setup.ts +8 -0
- package/vitest.config.ts +2 -0
- package/src/app/chat/(desktop)/features/ChatInput/Footer.tsx +0 -63
- package/src/utils/findDuplicates.ts +0 -22
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { FileModel } from '@/database/models/file';
|
|
4
|
+
import { LocalFile } from '@/types/database/files';
|
|
5
|
+
|
|
6
|
+
import { fileService } from '../file';
|
|
7
|
+
|
|
8
|
+
// Mocks for the FileModel
|
|
9
|
+
vi.mock('@/database/models/file', () => ({
|
|
10
|
+
FileModel: {
|
|
11
|
+
create: vi.fn(),
|
|
12
|
+
delete: vi.fn(),
|
|
13
|
+
findById: vi.fn(),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Mocks for the URL and Blob objects
|
|
18
|
+
global.URL.createObjectURL = vi.fn();
|
|
19
|
+
global.Blob = vi.fn();
|
|
20
|
+
|
|
21
|
+
describe('FileService', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
// Reset all mocks before each test
|
|
24
|
+
vi.resetAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('uploadFile should save the file to the database', async () => {
|
|
28
|
+
const localFile: LocalFile = {
|
|
29
|
+
name: 'test',
|
|
30
|
+
data: new ArrayBuffer(1),
|
|
31
|
+
fileType: 'image/png',
|
|
32
|
+
saveMode: 'local',
|
|
33
|
+
size: 1,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
(FileModel.create as Mock).mockResolvedValue(localFile);
|
|
37
|
+
|
|
38
|
+
const result = await fileService.uploadFile(localFile);
|
|
39
|
+
|
|
40
|
+
expect(FileModel.create).toHaveBeenCalledWith(localFile);
|
|
41
|
+
expect(result).toEqual(localFile);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('removeFile should delete the file from the database', async () => {
|
|
45
|
+
const fileId = '1';
|
|
46
|
+
(FileModel.delete as Mock).mockResolvedValue(true);
|
|
47
|
+
|
|
48
|
+
const result = await fileService.removeFile(fileId);
|
|
49
|
+
|
|
50
|
+
expect(FileModel.delete).toHaveBeenCalledWith(fileId);
|
|
51
|
+
expect(result).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('getFile should retrieve and convert file info to FilePreview', async () => {
|
|
55
|
+
const fileId = '1';
|
|
56
|
+
const fileData: LocalFile = {
|
|
57
|
+
name: 'test',
|
|
58
|
+
data: new ArrayBuffer(1),
|
|
59
|
+
fileType: 'image/png',
|
|
60
|
+
saveMode: 'local',
|
|
61
|
+
size: 1,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
(FileModel.findById as Mock).mockResolvedValue(fileData);
|
|
65
|
+
(global.URL.createObjectURL as Mock).mockReturnValue('blob:test');
|
|
66
|
+
(global.Blob as Mock).mockImplementation(() => ['test']);
|
|
67
|
+
|
|
68
|
+
const result = await fileService.getFile(fileId);
|
|
69
|
+
|
|
70
|
+
expect(FileModel.findById).toHaveBeenCalledWith(fileId);
|
|
71
|
+
expect(result).toEqual({
|
|
72
|
+
base64Url: 'data:image/png;base64,AA==',
|
|
73
|
+
fileType: 'image/png',
|
|
74
|
+
name: 'test',
|
|
75
|
+
saveMode: 'local',
|
|
76
|
+
url: 'blob:test',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('getFile should throw an error when the file is not found', async () => {
|
|
81
|
+
const fileId = 'non-existent';
|
|
82
|
+
(FileModel.findById as Mock).mockResolvedValue(null);
|
|
83
|
+
|
|
84
|
+
const getFilePromise = fileService.getFile(fileId);
|
|
85
|
+
|
|
86
|
+
await expect(getFilePromise).rejects.toThrow('file not found');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { merge } from 'lodash-es';
|
|
2
2
|
|
|
3
|
+
import { VISION_MODEL_WHITE_LIST } from '@/const/llm';
|
|
3
4
|
import { pluginSelectors, usePluginStore } from '@/store/plugin';
|
|
4
5
|
import { initialLobeAgentConfig } from '@/store/session/initialState';
|
|
5
|
-
import type {
|
|
6
|
+
import type { OpenAIChatStreamPayload } from '@/types/openai/chat';
|
|
6
7
|
|
|
7
8
|
import { createHeaderWithOpenAI } from './_header';
|
|
8
9
|
import { OPENAI_URLS } from './_url';
|
|
@@ -26,13 +27,17 @@ export const fetchChatModel = (
|
|
|
26
27
|
},
|
|
27
28
|
params,
|
|
28
29
|
);
|
|
29
|
-
// ============
|
|
30
|
+
// ============ preprocess tools ============ //
|
|
30
31
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
const filterTools = pluginSelectors.enabledSchema(enabledPlugins)(usePluginStore.getState());
|
|
33
|
+
|
|
34
|
+
// the rule that model can use tools:
|
|
35
|
+
// 1. tools is not empty
|
|
36
|
+
// 2. model is not in vision white list, because vision model can't use tools
|
|
37
|
+
// TODO: we need to find some method to let vision model use tools
|
|
38
|
+
const shouldUseTools = filterTools.length > 0 && !VISION_MODEL_WHITE_LIST.includes(payload.model);
|
|
34
39
|
|
|
35
|
-
const functions =
|
|
40
|
+
const functions = shouldUseTools ? filterTools : undefined;
|
|
36
41
|
|
|
37
42
|
return fetch(OPENAI_URLS.chat, {
|
|
38
43
|
body: JSON.stringify({ ...payload, functions }),
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { FileModel } from '@/database/models/file';
|
|
2
|
+
import { LocalFile } from '@/types/database/files';
|
|
3
|
+
import { FilePreview } from '@/types/files';
|
|
4
|
+
|
|
5
|
+
class FileService {
|
|
6
|
+
async uploadFile(file: LocalFile) {
|
|
7
|
+
// save to local storage
|
|
8
|
+
// we may want to save to a remote server later
|
|
9
|
+
return FileModel.create(file);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async removeFile(id: string) {
|
|
13
|
+
return FileModel.delete(id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async getFile(id: string): Promise<FilePreview> {
|
|
17
|
+
const item = await FileModel.findById(id);
|
|
18
|
+
if (!item) {
|
|
19
|
+
throw new Error('file not found');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// arrayBuffer to url
|
|
23
|
+
const url = URL.createObjectURL(new Blob([item.data]));
|
|
24
|
+
const base64 = Buffer.from(item.data).toString('base64');
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
base64Url: `data:${item.fileType};base64,${base64}`,
|
|
28
|
+
fileType: item.fileType,
|
|
29
|
+
name: item.name,
|
|
30
|
+
saveMode: 'local',
|
|
31
|
+
url,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const fileService = new FileService();
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { FilesStoreState } from './initialState';
|
|
4
|
+
import { filesSelectors } from './selectors';
|
|
5
|
+
|
|
6
|
+
describe('filesSelectors', () => {
|
|
7
|
+
let state: FilesStoreState;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
// 创建并初始化 state 的模拟实例
|
|
11
|
+
state = {
|
|
12
|
+
imagesMap: {
|
|
13
|
+
'1': {
|
|
14
|
+
name: 'a',
|
|
15
|
+
fileType: 'image/png',
|
|
16
|
+
saveMode: 'local',
|
|
17
|
+
base64Url: 'base64string1',
|
|
18
|
+
url: 'blob:abc',
|
|
19
|
+
},
|
|
20
|
+
'2': {
|
|
21
|
+
name: 'b',
|
|
22
|
+
fileType: 'image/png',
|
|
23
|
+
saveMode: 'url',
|
|
24
|
+
base64Url: 'base64string2',
|
|
25
|
+
url: 'url2',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
// 假设 '3' 是不存在的 ID
|
|
29
|
+
inputFilesList: ['1', '2', '3'],
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('getImageDetailByList should return details for the provided list of image IDs', () => {
|
|
34
|
+
const list = ['1', '2'];
|
|
35
|
+
const details = filesSelectors.getImageDetailByList(list)(state);
|
|
36
|
+
expect(details.length).toBe(2);
|
|
37
|
+
expect(details[0].name).toBe('a');
|
|
38
|
+
expect(details[1].name).toBe('b');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('imageDetailList should return details for the images in the inputFilesList', () => {
|
|
42
|
+
const details = filesSelectors.imageDetailList(state);
|
|
43
|
+
// '3' should be filtered due to not exist in map
|
|
44
|
+
expect(details.length).toBe(2);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('getImageUrlOrBase64ById should return the correct URL or Base64 based on saveMode', () => {
|
|
48
|
+
const localImage = filesSelectors.getImageUrlOrBase64ById('1')(state);
|
|
49
|
+
expect(localImage).toEqual({ id: '1', url: 'base64string1' });
|
|
50
|
+
|
|
51
|
+
const serverImage = filesSelectors.getImageUrlOrBase64ById('2')(state);
|
|
52
|
+
expect(serverImage).toEqual({ id: '2', url: 'url2' });
|
|
53
|
+
|
|
54
|
+
const nonExistentImage = filesSelectors.getImageUrlOrBase64ById('3')(state);
|
|
55
|
+
expect(nonExistentImage).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('getImageUrlOrBase64ByList should return the correct list of URLs or Base64 strings', () => {
|
|
59
|
+
const list = ['1', '2', '3'];
|
|
60
|
+
const urlsOrBase64s = filesSelectors.getImageUrlOrBase64ByList(list)(state);
|
|
61
|
+
expect(urlsOrBase64s.length).toBe(2); // '3' 应该被过滤掉,因为它不存在
|
|
62
|
+
expect(urlsOrBase64s[0].url).toBe('base64string1');
|
|
63
|
+
expect(urlsOrBase64s[1].url).toBe('url2');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('imageUrlOrBase64List should return a list of image URLs or Base64 strings for all images in inputFilesList', () => {
|
|
67
|
+
const urlsOrBase64s = filesSelectors.imageUrlOrBase64List(state);
|
|
68
|
+
expect(urlsOrBase64s.length).toBe(2); // '3' 是不存在的 ID,所以应该被过滤掉
|
|
69
|
+
|
|
70
|
+
expect(urlsOrBase64s).toEqual([
|
|
71
|
+
{ id: '1', url: 'base64string1' }, // 本地保存的图像应该使用 base64 URL
|
|
72
|
+
{ id: '2', url: 'url2' }, // 服务器保存的图像应该使用普通 URL
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { FilesStoreState } from './initialState';
|
|
2
|
+
|
|
3
|
+
const getImageDetailByList = (list: string[]) => (s: FilesStoreState) =>
|
|
4
|
+
list.map((i) => s.imagesMap[i]).filter(Boolean);
|
|
5
|
+
|
|
6
|
+
const imageDetailList = (s: FilesStoreState) => getImageDetailByList(s.inputFilesList)(s);
|
|
7
|
+
|
|
8
|
+
const getImageUrlOrBase64ById =
|
|
9
|
+
(id: string) =>
|
|
10
|
+
(s: FilesStoreState): { id: string; url: string } | undefined => {
|
|
11
|
+
const preview = s.imagesMap[id];
|
|
12
|
+
|
|
13
|
+
if (!preview) return undefined;
|
|
14
|
+
|
|
15
|
+
const url = preview.saveMode === 'local' ? (preview.base64Url as string) : preview.url;
|
|
16
|
+
|
|
17
|
+
return { id, url: url };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const getImageUrlOrBase64ByList = (idList: string[]) => (s: FilesStoreState) =>
|
|
21
|
+
idList.map((i) => getImageUrlOrBase64ById(i)(s)).filter(Boolean) as {
|
|
22
|
+
id: string;
|
|
23
|
+
url: string;
|
|
24
|
+
}[];
|
|
25
|
+
|
|
26
|
+
const imageUrlOrBase64List = (s: FilesStoreState) => getImageUrlOrBase64ByList(s.inputFilesList)(s);
|
|
27
|
+
|
|
28
|
+
export const filesSelectors = {
|
|
29
|
+
getImageDetailByList,
|
|
30
|
+
getImageUrlOrBase64ById,
|
|
31
|
+
getImageUrlOrBase64ByList,
|
|
32
|
+
imageDetailList,
|
|
33
|
+
imageUrlOrBase64List,
|
|
34
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import useSWR from 'swr';
|
|
3
|
+
import { Mock, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { fileService } from '@/services/file';
|
|
6
|
+
|
|
7
|
+
import { useFileStore as useStore } from '../../store';
|
|
8
|
+
|
|
9
|
+
vi.mock('zustand/traditional');
|
|
10
|
+
|
|
11
|
+
// Mocks for fileService
|
|
12
|
+
vi.mock('@/services/file', () => ({
|
|
13
|
+
fileService: {
|
|
14
|
+
removeFile: vi.fn(),
|
|
15
|
+
uploadFile: vi.fn(),
|
|
16
|
+
getFile: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Mock for useSWR
|
|
21
|
+
vi.mock('swr', () => ({
|
|
22
|
+
default: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
// mock the arrayBuffer
|
|
26
|
+
beforeAll(() => {
|
|
27
|
+
Object.defineProperty(File.prototype, 'arrayBuffer', {
|
|
28
|
+
writable: true,
|
|
29
|
+
value: function () {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const reader = new FileReader();
|
|
32
|
+
reader.onloadend = () => {
|
|
33
|
+
resolve(reader.result);
|
|
34
|
+
};
|
|
35
|
+
reader.readAsArrayBuffer(this);
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
// Reset all mocks before each test
|
|
43
|
+
vi.resetAllMocks();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('useFileStore:images', () => {
|
|
47
|
+
it('clearImageList should clear the inputFilesList', () => {
|
|
48
|
+
const { result } = renderHook(() => useStore());
|
|
49
|
+
|
|
50
|
+
// Populate the list to clear it later
|
|
51
|
+
act(() => {
|
|
52
|
+
useStore.setState({ inputFilesList: ['test-id'] });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(result.current.inputFilesList).toEqual(['test-id']);
|
|
56
|
+
|
|
57
|
+
act(() => {
|
|
58
|
+
result.current.clearImageList();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(result.current.inputFilesList).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('removeFile should call fileService.removeFile and update the store', async () => {
|
|
65
|
+
const { result } = renderHook(() => useStore());
|
|
66
|
+
|
|
67
|
+
const fileId = 'test-id';
|
|
68
|
+
|
|
69
|
+
// Mock the fileService.removeFile to resolve
|
|
70
|
+
(fileService.removeFile as Mock).mockResolvedValue(undefined);
|
|
71
|
+
|
|
72
|
+
// Populate the list to remove an item later
|
|
73
|
+
act(() => {
|
|
74
|
+
useStore.setState(({ inputFilesList }) => ({ inputFilesList: [...inputFilesList, fileId] }));
|
|
75
|
+
// // result.current.inputFilesList.push(fileId);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await act(async () => {
|
|
79
|
+
await result.current.removeFile(fileId);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(fileService.removeFile).toHaveBeenCalledWith(fileId);
|
|
83
|
+
expect(result.current.inputFilesList).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Test for useFetchFile
|
|
87
|
+
it('useFetchFile should call useSWR and update the store', async () => {
|
|
88
|
+
const fileId = 'test-id';
|
|
89
|
+
const fileData = {
|
|
90
|
+
id: fileId,
|
|
91
|
+
name: 'test',
|
|
92
|
+
url: 'blob:test',
|
|
93
|
+
fileType: 'image/png',
|
|
94
|
+
base64Url: '',
|
|
95
|
+
saveMode: 'local',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Mock the fileService.getFile to resolve with fileData
|
|
99
|
+
(fileService.getFile as Mock).mockResolvedValue(fileData);
|
|
100
|
+
|
|
101
|
+
// Mock useSWR to call the fetcher function immediately
|
|
102
|
+
const useSWRMock = vi.mocked(useSWR);
|
|
103
|
+
useSWRMock.mockImplementation(((key: string, fetcher: any) => {
|
|
104
|
+
const data = fetcher(key);
|
|
105
|
+
return { data, error: undefined, isValidating: false, mutate: vi.fn() };
|
|
106
|
+
}) as any);
|
|
107
|
+
|
|
108
|
+
const { result } = renderHook(() => useStore().useFetchFile(fileId));
|
|
109
|
+
|
|
110
|
+
await act(async () => {
|
|
111
|
+
await result.current.data;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(fileService.getFile).toHaveBeenCalledWith(fileId);
|
|
115
|
+
|
|
116
|
+
// Since we are not rendering a component with the hook, we cannot test the state update here
|
|
117
|
+
// Instead, we would need to use a test renderer that can work with hooks, like @testing-library/react
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('uploadFile', () => {
|
|
121
|
+
it('uploadFile should handle errors', async () => {
|
|
122
|
+
const { result } = renderHook(() => useStore());
|
|
123
|
+
const testFile = new File(['content'], 'test.png', { type: 'image/png' });
|
|
124
|
+
|
|
125
|
+
// 模拟 fileService.uploadFile 抛出错误
|
|
126
|
+
const errorMessage = 'Upload failed';
|
|
127
|
+
(fileService.uploadFile as Mock).mockRejectedValue(new Error(errorMessage));
|
|
128
|
+
|
|
129
|
+
// Mock console.error for testing
|
|
130
|
+
const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
131
|
+
|
|
132
|
+
await act(async () => {
|
|
133
|
+
await result.current.uploadFile(testFile);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(fileService.uploadFile).toHaveBeenCalledWith({
|
|
137
|
+
createdAt: testFile.lastModified,
|
|
138
|
+
data: await testFile.arrayBuffer(),
|
|
139
|
+
fileType: testFile.type,
|
|
140
|
+
name: testFile.name,
|
|
141
|
+
saveMode: 'local',
|
|
142
|
+
size: testFile.size,
|
|
143
|
+
});
|
|
144
|
+
// 由于上传失败,inputFilesList 应该没有变化
|
|
145
|
+
expect(result.current.inputFilesList).toEqual([]);
|
|
146
|
+
// 确保错误被正确记录
|
|
147
|
+
expect(consoleErrorMock).toHaveBeenCalledWith('upload error:', expect.any(Error));
|
|
148
|
+
|
|
149
|
+
// Cleanup mock
|
|
150
|
+
consoleErrorMock.mockRestore();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('uploadFile should upload the file and update inputFilesList', async () => {
|
|
154
|
+
const { result } = renderHook(() => useStore());
|
|
155
|
+
const testFile = new File(['content'], 'test.png', { type: 'image/png' });
|
|
156
|
+
|
|
157
|
+
// 模拟 fileService.uploadFile 返回的数据
|
|
158
|
+
const uploadedFileData = {
|
|
159
|
+
id: 'new-file-id',
|
|
160
|
+
createdAt: testFile.lastModified,
|
|
161
|
+
data: await testFile.arrayBuffer(),
|
|
162
|
+
fileType: testFile.type,
|
|
163
|
+
name: testFile.name,
|
|
164
|
+
saveMode: 'local',
|
|
165
|
+
size: testFile.size,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Mock the fileService.uploadFile to resolve with uploadedFileData
|
|
169
|
+
(fileService.uploadFile as Mock).mockResolvedValue(uploadedFileData);
|
|
170
|
+
|
|
171
|
+
await act(async () => {
|
|
172
|
+
await result.current.uploadFile(testFile);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(fileService.uploadFile).toHaveBeenCalledWith({
|
|
176
|
+
createdAt: testFile.lastModified,
|
|
177
|
+
data: await testFile.arrayBuffer(),
|
|
178
|
+
fileType: testFile.type,
|
|
179
|
+
name: testFile.name,
|
|
180
|
+
saveMode: 'local',
|
|
181
|
+
size: testFile.size,
|
|
182
|
+
});
|
|
183
|
+
expect(result.current.inputFilesList).toContain(uploadedFileData.id);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { produce } from 'immer';
|
|
2
|
+
import useSWR, { SWRResponse } from 'swr';
|
|
3
|
+
import { StateCreator } from 'zustand/vanilla';
|
|
4
|
+
|
|
5
|
+
import { fileService } from '@/services/file';
|
|
6
|
+
import { FilePreview } from '@/types/files';
|
|
7
|
+
import { setNamespace } from '@/utils/storeDebug';
|
|
8
|
+
|
|
9
|
+
import { FileStore } from '../../store';
|
|
10
|
+
|
|
11
|
+
const t = setNamespace('files/image');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 代理行为接口
|
|
15
|
+
*/
|
|
16
|
+
export interface FileAction {
|
|
17
|
+
clearImageList: () => void;
|
|
18
|
+
removeFile: (id: string) => Promise<void>;
|
|
19
|
+
setImageMapItem: (id: string, item: FilePreview) => void;
|
|
20
|
+
uploadFile: (file: File) => Promise<void>;
|
|
21
|
+
|
|
22
|
+
useFetchFile: (id: string) => SWRResponse<FilePreview>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const createFileSlice: StateCreator<
|
|
26
|
+
FileStore,
|
|
27
|
+
[['zustand/devtools', never]],
|
|
28
|
+
[],
|
|
29
|
+
FileAction
|
|
30
|
+
> = (set, get) => ({
|
|
31
|
+
clearImageList: () => {
|
|
32
|
+
set({ inputFilesList: [] }, false, t('clearImageList'));
|
|
33
|
+
},
|
|
34
|
+
removeFile: async (id) => {
|
|
35
|
+
await fileService.removeFile(id);
|
|
36
|
+
|
|
37
|
+
set(
|
|
38
|
+
({ inputFilesList }) => ({ inputFilesList: inputFilesList.filter((i) => i !== id) }),
|
|
39
|
+
false,
|
|
40
|
+
t('removeFile'),
|
|
41
|
+
);
|
|
42
|
+
},
|
|
43
|
+
setImageMapItem: (id, item) => {
|
|
44
|
+
set(
|
|
45
|
+
produce((draft) => {
|
|
46
|
+
if (draft.imagesMap[id]) return;
|
|
47
|
+
|
|
48
|
+
draft.imagesMap[id] = item;
|
|
49
|
+
}),
|
|
50
|
+
false,
|
|
51
|
+
t('setImageMapItem'),
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
uploadFile: async (file) => {
|
|
55
|
+
try {
|
|
56
|
+
const data = await fileService.uploadFile({
|
|
57
|
+
createdAt: file.lastModified,
|
|
58
|
+
data: await file.arrayBuffer(),
|
|
59
|
+
fileType: file.type,
|
|
60
|
+
name: file.name,
|
|
61
|
+
saveMode: 'local',
|
|
62
|
+
size: file.size,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
set(
|
|
66
|
+
({ inputFilesList }) => ({ inputFilesList: [...inputFilesList, data.id] }),
|
|
67
|
+
false,
|
|
68
|
+
t('uploadFile'),
|
|
69
|
+
);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
// 提示用户上传失败
|
|
72
|
+
console.error('upload error:', error);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
useFetchFile: (id) =>
|
|
76
|
+
useSWR(id, async (id) => {
|
|
77
|
+
const item = await fileService.getFile(id);
|
|
78
|
+
|
|
79
|
+
get().setImageMapItem(id, item);
|
|
80
|
+
|
|
81
|
+
return item;
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { FilePreview } from '@/types/files';
|
|
2
|
+
|
|
3
|
+
export interface ImageFileState {
|
|
4
|
+
imagesMap: Record<string, FilePreview>;
|
|
5
|
+
inputFilesList: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const initialImageFileState: ImageFileState = {
|
|
9
|
+
imagesMap: {},
|
|
10
|
+
inputFilesList: [],
|
|
11
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { devtools } from 'zustand/middleware';
|
|
2
|
+
import { shallow } from 'zustand/shallow';
|
|
3
|
+
import { createWithEqualityFn } from 'zustand/traditional';
|
|
4
|
+
import { StateCreator } from 'zustand/vanilla';
|
|
5
|
+
|
|
6
|
+
import { isDev } from '@/utils/env';
|
|
7
|
+
|
|
8
|
+
import { FilesStoreState, initialState } from './initialState';
|
|
9
|
+
import { FileAction, createFileSlice } from './slices/images';
|
|
10
|
+
|
|
11
|
+
// =============== 聚合 createStoreFn ============ //
|
|
12
|
+
|
|
13
|
+
export type FileStore = FilesStoreState & FileAction;
|
|
14
|
+
|
|
15
|
+
const createStore: StateCreator<FileStore, [['zustand/devtools', never]]> = (...parameters) => ({
|
|
16
|
+
...initialState,
|
|
17
|
+
...createFileSlice(...parameters),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// =============== 实装 useStore ============ //
|
|
21
|
+
|
|
22
|
+
export const useFileStore = createWithEqualityFn<FileStore>()(
|
|
23
|
+
devtools(createStore, {
|
|
24
|
+
name: 'LobeChat_File' + (isDev ? '_DEV' : ''),
|
|
25
|
+
}),
|
|
26
|
+
shallow,
|
|
27
|
+
);
|
|
@@ -75,7 +75,7 @@ describe('settingsSelectors', () => {
|
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
77
|
});
|
|
78
|
-
|
|
78
|
+
|
|
79
79
|
// describe('defaultAgent', () => {
|
|
80
80
|
// it('should merge DEFAULT_AGENT and s.settings.defaultAgent correctly', () => {
|
|
81
81
|
// const s: GlobalStore = {
|
|
@@ -95,8 +95,6 @@ describe('settingsSelectors', () => {
|
|
|
95
95
|
//
|
|
96
96
|
// const result = settingsSelectors.defaultAgent(s);
|
|
97
97
|
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
98
|
// expect(result).toEqual(expected);
|
|
101
99
|
// });
|
|
102
100
|
// });
|
|
@@ -12,6 +12,7 @@ import type { Store } from './store';
|
|
|
12
12
|
export interface StoreAction {
|
|
13
13
|
activateAgent: (identifier: string) => void;
|
|
14
14
|
deactivateAgent: () => void;
|
|
15
|
+
setSearchKeywords: (keywords: string) => void;
|
|
15
16
|
updateAgentMap: (key: string, value: AgentsMarketItem) => void;
|
|
16
17
|
useFetchAgent: (identifier: string) => SWRResponse<AgentsMarketItem>;
|
|
17
18
|
useFetchAgentList: () => SWRResponse<LobeChatAgentsMarketIndex>;
|
|
@@ -29,6 +30,9 @@ export const createMarketAction: StateCreator<
|
|
|
29
30
|
deactivateAgent: () => {
|
|
30
31
|
set({ currentIdentifier: undefined }, false, 'deactivateAgent');
|
|
31
32
|
},
|
|
33
|
+
setSearchKeywords: (keywords) => {
|
|
34
|
+
set({ searchKeywords: keywords });
|
|
35
|
+
},
|
|
32
36
|
updateAgentMap: (key, value) => {
|
|
33
37
|
const { agentMap } = get();
|
|
34
38
|
|
|
@@ -56,7 +60,11 @@ export const createMarketAction: StateCreator<
|
|
|
56
60
|
useFetchAgentList: () =>
|
|
57
61
|
useSWR<LobeChatAgentsMarketIndex>(getCurrentLanguage(), getAgentList, {
|
|
58
62
|
onSuccess: (agentMarketIndex) => {
|
|
59
|
-
set(
|
|
63
|
+
set(
|
|
64
|
+
{ agentList: agentMarketIndex.agents, tagList: agentMarketIndex.tags },
|
|
65
|
+
false,
|
|
66
|
+
'useFetchAgentList',
|
|
67
|
+
);
|
|
60
68
|
},
|
|
61
69
|
}),
|
|
62
70
|
});
|
|
@@ -7,6 +7,7 @@ export interface StoreState {
|
|
|
7
7
|
agentMap: MarketAgentMap;
|
|
8
8
|
currentIdentifier: string;
|
|
9
9
|
searchKeywords: string;
|
|
10
|
+
tagList: string[];
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export const initialState: StoreState = {
|
|
@@ -14,4 +15,5 @@ export const initialState: StoreState = {
|
|
|
14
15
|
agentMap: {},
|
|
15
16
|
currentIdentifier: '',
|
|
16
17
|
searchKeywords: '',
|
|
18
|
+
tagList: [],
|
|
17
19
|
};
|