@lobehub/chat 0.100.5 → 0.101.0
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 +25 -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/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/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/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
|
@@ -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
|
// });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t } from 'i18next';
|
|
2
2
|
|
|
3
|
-
import { DEFAULT_OPENAI_MODEL_LIST } from '@/const/llm';
|
|
3
|
+
import { DEFAULT_OPENAI_MODEL_LIST, VISION_MODEL_WHITE_LIST } from '@/const/llm';
|
|
4
4
|
import { DEFAULT_AVATAR, DEFAULT_BACKGROUND_COLOR } from '@/const/meta';
|
|
5
5
|
import { SessionStore } from '@/store/session';
|
|
6
6
|
import { LanguageModel } from '@/types/llm';
|
|
@@ -49,6 +49,11 @@ const currentAgentModel = (s: SessionStore): LanguageModel | string => {
|
|
|
49
49
|
return config?.model || LanguageModel.GPT3_5;
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
const modelHasVisionAbility = (s: SessionStore): boolean => {
|
|
53
|
+
const model = currentAgentModel(s);
|
|
54
|
+
return VISION_MODEL_WHITE_LIST.includes(model);
|
|
55
|
+
};
|
|
56
|
+
|
|
52
57
|
const currentAgentPlugins = (s: SessionStore) => {
|
|
53
58
|
const config = currentAgentConfig(s);
|
|
54
59
|
|
|
@@ -87,5 +92,6 @@ export const agentSelectors = {
|
|
|
87
92
|
getDescription,
|
|
88
93
|
getTitle,
|
|
89
94
|
hasSystemRole,
|
|
95
|
+
modelHasVisionAbility,
|
|
90
96
|
showTokenTag,
|
|
91
97
|
};
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { template } from 'lodash-es';
|
|
2
2
|
import { StateCreator } from 'zustand/vanilla';
|
|
3
3
|
|
|
4
|
+
import { VISION_MODEL_WHITE_LIST } from '@/const/llm';
|
|
4
5
|
import { LOADING_FLAT } from '@/const/message';
|
|
6
|
+
import { VISION_MODEL_DEFAULT_MAX_TOKENS } from '@/const/settings';
|
|
5
7
|
import { fetchChatModel } from '@/services/chatModel';
|
|
8
|
+
import { filesSelectors, useFileStore } from '@/store/files';
|
|
6
9
|
import { SessionStore } from '@/store/session';
|
|
7
10
|
import { ChatMessage } from '@/types/chatMessage';
|
|
11
|
+
import { OpenAIChatMessage, UserMessageContentPart } from '@/types/openai/chat';
|
|
8
12
|
import { fetchSSE } from '@/utils/fetch';
|
|
9
13
|
import { isFunctionMessageAtStart, testFunctionMessageAtEnd } from '@/utils/message';
|
|
10
14
|
import { setNamespace } from '@/utils/storeDebug';
|
|
@@ -12,6 +16,7 @@ import { nanoid } from '@/utils/uuid';
|
|
|
12
16
|
|
|
13
17
|
import { agentSelectors } from '../../agentConfig/selectors';
|
|
14
18
|
import { sessionSelectors } from '../../session/selectors';
|
|
19
|
+
import { FileDispatch, filesReducer } from '../reducers/files';
|
|
15
20
|
import { MessageDispatch, messagesReducer } from '../reducers/message';
|
|
16
21
|
import { chatSelectors } from '../selectors';
|
|
17
22
|
import { getSlicedMessagesWithConfig } from '../utils';
|
|
@@ -37,6 +42,10 @@ export interface ChatMessageAction {
|
|
|
37
42
|
* @param id - 消息 ID
|
|
38
43
|
*/
|
|
39
44
|
deleteMessage: (id: string) => void;
|
|
45
|
+
/**
|
|
46
|
+
* agent files dispatch method
|
|
47
|
+
*/
|
|
48
|
+
dispatchAgentFile: (payload: FileDispatch) => void;
|
|
40
49
|
/**
|
|
41
50
|
* 分发消息
|
|
42
51
|
* @param payload - 消息分发参数
|
|
@@ -66,7 +75,7 @@ export interface ChatMessageAction {
|
|
|
66
75
|
* 发送消息
|
|
67
76
|
* @param text - 消息文本
|
|
68
77
|
*/
|
|
69
|
-
sendMessage: (text: string) => Promise<void>;
|
|
78
|
+
sendMessage: (text: string, images?: { id: string; url: string }[]) => Promise<void>;
|
|
70
79
|
stopGenerateMessage: () => void;
|
|
71
80
|
toggleChatLoading: (
|
|
72
81
|
loading: boolean,
|
|
@@ -164,6 +173,15 @@ export const chatMessage: StateCreator<
|
|
|
164
173
|
get().dispatchMessage({ id, type: 'deleteMessage' });
|
|
165
174
|
},
|
|
166
175
|
|
|
176
|
+
dispatchAgentFile: (payload) => {
|
|
177
|
+
const { activeId } = get();
|
|
178
|
+
const session = sessionSelectors.currentSession(get());
|
|
179
|
+
if (!activeId || !session) return;
|
|
180
|
+
|
|
181
|
+
const files = filesReducer(session.files || [], payload);
|
|
182
|
+
|
|
183
|
+
get().dispatchSession({ files, id: activeId, type: 'updateSessionFiles' });
|
|
184
|
+
},
|
|
167
185
|
dispatchMessage: (payload) => {
|
|
168
186
|
const { activeId } = get();
|
|
169
187
|
const session = sessionSelectors.currentSession(get());
|
|
@@ -173,6 +191,7 @@ export const chatMessage: StateCreator<
|
|
|
173
191
|
|
|
174
192
|
get().dispatchSession({ chats, id: activeId, type: 'updateSessionChat' });
|
|
175
193
|
},
|
|
194
|
+
|
|
176
195
|
fetchAIChatMessage: async (messages, assistantId) => {
|
|
177
196
|
const { dispatchMessage, toggleChatLoading } = get();
|
|
178
197
|
|
|
@@ -186,17 +205,17 @@ export const chatMessage: StateCreator<
|
|
|
186
205
|
|
|
187
206
|
const compiler = template(config.inputTemplate, { interpolate: /{{([\S\s]+?)}}/g });
|
|
188
207
|
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
208
|
+
// ================================== //
|
|
209
|
+
// messages uniformly preprocess //
|
|
210
|
+
// ================================== //
|
|
192
211
|
|
|
193
|
-
// 1.
|
|
194
|
-
|
|
212
|
+
// 1. slice messages with config
|
|
213
|
+
let preprocessMsgs = getSlicedMessagesWithConfig(messages, config);
|
|
195
214
|
|
|
196
|
-
// 2.
|
|
197
|
-
|
|
198
|
-
?
|
|
199
|
-
:
|
|
215
|
+
// 2. replace inputMessage template
|
|
216
|
+
preprocessMsgs = !config.inputTemplate
|
|
217
|
+
? preprocessMsgs
|
|
218
|
+
: preprocessMsgs.map((m) => {
|
|
200
219
|
if (m.role === 'user') {
|
|
201
220
|
try {
|
|
202
221
|
return { ...m, content: compiler({ text: m.content }) };
|
|
@@ -206,12 +225,41 @@ export const chatMessage: StateCreator<
|
|
|
206
225
|
return m;
|
|
207
226
|
}
|
|
208
227
|
}
|
|
228
|
+
|
|
209
229
|
return m;
|
|
210
230
|
});
|
|
211
231
|
|
|
212
|
-
// 3.
|
|
232
|
+
// 3. add systemRole
|
|
213
233
|
if (config.systemRole) {
|
|
214
|
-
|
|
234
|
+
preprocessMsgs.unshift({ content: config.systemRole, role: 'system' } as ChatMessage);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let postMessages: OpenAIChatMessage[] = preprocessMsgs;
|
|
238
|
+
|
|
239
|
+
// 4. handle content type for vision model
|
|
240
|
+
// for the models with visual ability, add image url to content
|
|
241
|
+
// refs: https://platform.openai.com/docs/guides/vision/quick-start
|
|
242
|
+
if (VISION_MODEL_WHITE_LIST.includes(config.model)) {
|
|
243
|
+
postMessages = preprocessMsgs.map((m) => {
|
|
244
|
+
if (!m.files) return m;
|
|
245
|
+
|
|
246
|
+
const imageList = filesSelectors.getImageUrlOrBase64ByList(m.files)(
|
|
247
|
+
useFileStore.getState(),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
if (imageList.length === 0) return m;
|
|
251
|
+
|
|
252
|
+
const content: UserMessageContentPart[] = [
|
|
253
|
+
{ text: m.content, type: 'text' },
|
|
254
|
+
...imageList.map(
|
|
255
|
+
(i) => ({ image_url: { detail: 'auto', url: i.url }, type: 'image_url' }) as const,
|
|
256
|
+
),
|
|
257
|
+
];
|
|
258
|
+
return { ...m, content };
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// due to vision model's default max_tokens is very small, we need to set the max_tokens a larger one.
|
|
262
|
+
if (!config.params.max_tokens) config.params.max_tokens = VISION_MODEL_DEFAULT_MAX_TOKENS;
|
|
215
263
|
}
|
|
216
264
|
|
|
217
265
|
const fetcher = () =>
|
|
@@ -248,7 +296,7 @@ export const chatMessage: StateCreator<
|
|
|
248
296
|
|
|
249
297
|
toggleChatLoading(false, undefined, t('generateMessage(end)') as string);
|
|
250
298
|
|
|
251
|
-
// also exist message like this:
|
|
299
|
+
// also exist message like this: 请稍等,我帮您查询一下。{"function_call": {"name": "plugin-identifier____recommendClothes____standalone", "arguments": "{\n "mood": "",\n "gender": "man"\n}"}}
|
|
252
300
|
if (!isFunctionCall) {
|
|
253
301
|
const { content, valid } = testFunctionMessageAtEnd(output);
|
|
254
302
|
|
|
@@ -305,13 +353,27 @@ export const chatMessage: StateCreator<
|
|
|
305
353
|
await coreProcessMessage(contextMessages, latestMsg.id);
|
|
306
354
|
},
|
|
307
355
|
|
|
308
|
-
sendMessage: async (message) => {
|
|
309
|
-
const { dispatchMessage, coreProcessMessage, activeTopicId } = get();
|
|
356
|
+
sendMessage: async (message, files) => {
|
|
357
|
+
const { dispatchMessage, dispatchAgentFile, coreProcessMessage, activeTopicId } = get();
|
|
310
358
|
const session = sessionSelectors.currentSession(get());
|
|
311
359
|
if (!session || !message) return;
|
|
312
360
|
|
|
313
361
|
const userId = nanoid();
|
|
314
|
-
|
|
362
|
+
|
|
363
|
+
dispatchMessage({
|
|
364
|
+
id: userId,
|
|
365
|
+
message: message,
|
|
366
|
+
role: 'user',
|
|
367
|
+
type: 'addMessage',
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// if message has attached with files, then add files to message and the agent
|
|
371
|
+
if (files && files.length > 0) {
|
|
372
|
+
const fileIdList = files.map((f) => f.id);
|
|
373
|
+
dispatchMessage({ id: userId, key: 'files', type: 'updateMessage', value: fileIdList });
|
|
374
|
+
|
|
375
|
+
dispatchAgentFile({ files: fileIdList, type: 'addFiles' });
|
|
376
|
+
}
|
|
315
377
|
|
|
316
378
|
// if there is activeTopicId,then add topicId to message
|
|
317
379
|
if (activeTopicId) {
|
|
@@ -341,7 +403,6 @@ export const chatMessage: StateCreator<
|
|
|
341
403
|
|
|
342
404
|
toggleChatLoading(false);
|
|
343
405
|
},
|
|
344
|
-
|
|
345
406
|
toggleChatLoading: (loading, id, action) => {
|
|
346
407
|
if (loading) {
|
|
347
408
|
const abortController = new AbortController();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { FileDispatch, FilesState, filesReducer } from './files';
|
|
2
|
+
|
|
3
|
+
describe('filesReducer', () => {
|
|
4
|
+
it('should add a file to the state', () => {
|
|
5
|
+
const initialState: FilesState = ['file1', 'file2'];
|
|
6
|
+
const action: FileDispatch = { type: 'addFile', file: 'file3' };
|
|
7
|
+
const newState = filesReducer(initialState, action);
|
|
8
|
+
expect(newState).toEqual(['file1', 'file2', 'file3']);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should delete a file from the state by ID', () => {
|
|
12
|
+
const initialState: FilesState = ['file1', 'file2', 'file3'];
|
|
13
|
+
const action: FileDispatch = { type: 'deleteFile', id: 'file2' };
|
|
14
|
+
const newState = filesReducer(initialState, action);
|
|
15
|
+
expect(newState).toEqual(['file1', 'file3']);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should return the state unchanged if file ID does not exist for deletion', () => {
|
|
19
|
+
const initialState: FilesState = ['file1', 'file2', 'file3'];
|
|
20
|
+
const action: FileDispatch = { type: 'deleteFile', id: 'file4' };
|
|
21
|
+
const newState = filesReducer(initialState, action);
|
|
22
|
+
expect(newState).toEqual(['file1', 'file2', 'file3']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should add multiple files to the state', () => {
|
|
26
|
+
const initialState: FilesState = ['file1', 'file2'];
|
|
27
|
+
const action: FileDispatch = { type: 'addFiles', files: ['file3', 'file4'] };
|
|
28
|
+
const newState = filesReducer(initialState, action);
|
|
29
|
+
expect(newState).toEqual(['file1', 'file2', 'file3', 'file4']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should return the initial state if the action type is unknown', () => {
|
|
33
|
+
const initialState: FilesState = ['file1', 'file2'];
|
|
34
|
+
const action = { type: 'unknown', id: 'file1' };
|
|
35
|
+
const newState = filesReducer(initialState, action as FileDispatch);
|
|
36
|
+
expect(newState).toEqual(['file1', 'file2']);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { produce } from 'immer';
|
|
2
|
+
|
|
3
|
+
export type FilesState = string[];
|
|
4
|
+
|
|
5
|
+
export type FileDispatch =
|
|
6
|
+
| { file: string; type: 'addFile' }
|
|
7
|
+
| { id: string; type: 'deleteFile' }
|
|
8
|
+
| { files: string[]; type: 'addFiles' };
|
|
9
|
+
|
|
10
|
+
export const filesReducer = (state: FilesState, payload: FileDispatch): FilesState => {
|
|
11
|
+
switch (payload.type) {
|
|
12
|
+
case 'addFile': {
|
|
13
|
+
return produce(state, (draftState) => {
|
|
14
|
+
draftState.push(payload.file);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
case 'deleteFile': {
|
|
19
|
+
return produce(state, (draftState) => {
|
|
20
|
+
const index = draftState.indexOf(payload.id);
|
|
21
|
+
if (index !== -1) {
|
|
22
|
+
draftState.splice(index, 1);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
case 'addFiles': {
|
|
28
|
+
return produce(state, (draftState) => {
|
|
29
|
+
draftState.push(...payload.files);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
default: {
|
|
34
|
+
return state;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|