@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.
Files changed (68) hide show
  1. package/.eslintrc.js +1 -0
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +2 -2
  4. package/README.zh-CN.md +2 -2
  5. package/__mocks__/zustand/traditional.ts +25 -0
  6. package/package.json +7 -3
  7. package/src/app/api/files/image/imgur.ts +72 -0
  8. package/src/app/api/files/image/route.ts +42 -0
  9. package/src/app/api/openai/chat/createChatCompletion.ts +2 -1
  10. package/src/app/chat/(desktop)/features/ChatInput/DragUpload.tsx +166 -0
  11. package/src/app/chat/(desktop)/features/ChatInput/Footer/LocalFiles.tsx +10 -0
  12. package/src/app/chat/(desktop)/features/ChatInput/Footer/index.tsx +64 -0
  13. package/src/app/chat/(desktop)/features/ChatInput/InputArea.tsx +3 -3
  14. package/src/app/chat/(desktop)/features/ChatInput/index.tsx +38 -30
  15. package/src/app/chat/components/FileList/FileItem.style.ts +81 -0
  16. package/src/app/chat/components/FileList/FileItem.tsx +69 -0
  17. package/src/app/chat/components/FileList/Lightbox.tsx +46 -0
  18. package/src/app/chat/components/FileList/index.tsx +63 -0
  19. package/src/app/chat/features/ChatInput/ActionBar/Clear.tsx +13 -4
  20. package/src/app/chat/features/ChatInput/ActionBar/FileUpload.tsx +53 -0
  21. package/src/app/chat/features/ChatInput/ActionBar/config.ts +3 -1
  22. package/src/app/chat/{(desktop)/features → features}/ChatInput/useSend.ts +5 -1
  23. package/src/app/chat/features/Conversation/ChatList/Messages/User.tsx +23 -0
  24. package/src/app/chat/features/Conversation/ChatList/Messages/index.ts +2 -0
  25. package/src/config/__tests__/client.test.ts +76 -0
  26. package/src/config/__tests__/server.test.ts +52 -0
  27. package/src/config/server.ts +8 -0
  28. package/src/const/layoutTokens.ts +1 -1
  29. package/src/const/llm.ts +3 -0
  30. package/src/const/settings.ts +2 -0
  31. package/src/database/core/__tests__/db.test.ts +77 -0
  32. package/src/database/core/__tests__/model.test.ts +55 -0
  33. package/src/database/core/db.ts +25 -0
  34. package/src/database/core/index.ts +1 -0
  35. package/src/database/core/model.ts +49 -0
  36. package/src/database/core/schema.ts +9 -0
  37. package/src/database/models/file.test.ts +71 -0
  38. package/src/database/models/file.ts +26 -0
  39. package/src/locales/default/chat.ts +5 -0
  40. package/src/services/__tests__/file.test.ts +88 -0
  41. package/src/services/chatModel.ts +11 -6
  42. package/src/services/file.ts +36 -0
  43. package/src/store/files/index.ts +2 -0
  44. package/src/store/files/initialState.ts +7 -0
  45. package/src/store/files/selectors.test.ts +75 -0
  46. package/src/store/files/selectors.ts +34 -0
  47. package/src/store/files/slices/images/action.test.ts +186 -0
  48. package/src/store/files/slices/images/action.ts +83 -0
  49. package/src/store/files/slices/images/index.ts +3 -0
  50. package/src/store/files/slices/images/initialState.ts +11 -0
  51. package/src/store/files/store.ts +27 -0
  52. package/src/store/global/selectors/settings.test.ts +1 -3
  53. package/src/store/session/slices/agentConfig/selectors.ts +7 -1
  54. package/src/store/session/slices/chat/actions/message.ts +78 -17
  55. package/src/store/session/slices/chat/reducers/files.test.ts +38 -0
  56. package/src/store/session/slices/chat/reducers/files.ts +37 -0
  57. package/src/store/session/slices/session/initialState.ts +1 -0
  58. package/src/store/session/slices/session/reducers/session.test.ts +98 -1
  59. package/src/store/session/slices/session/reducers/session.ts +26 -1
  60. package/src/types/chatMessage.ts +3 -2
  61. package/src/types/database/db.ts +4 -0
  62. package/src/types/database/files.ts +38 -0
  63. package/src/types/files.ts +8 -0
  64. package/src/types/openai/chat.ts +15 -1
  65. package/src/types/session.ts +1 -0
  66. package/tests/setup.ts +8 -0
  67. package/vitest.config.ts +2 -0
  68. 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,3 @@
1
+ export * from './action';
2
+ export * from './initialState';
3
+ // export * from './selectors';
@@ -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
- // 对 messages 做统一预处理 //
191
- // ========================== //
208
+ // ================================== //
209
+ // messages uniformly preprocess //
210
+ // ================================== //
192
211
 
193
- // 1. 按参数设定截断长度
194
- const slicedMessages = getSlicedMessagesWithConfig(messages, config);
212
+ // 1. slice messages with config
213
+ let preprocessMsgs = getSlicedMessagesWithConfig(messages, config);
195
214
 
196
- // 2. 替换 inputMessage 模板
197
- const postMessages = !config.inputTemplate
198
- ? slicedMessages
199
- : slicedMessages.map((m) => {
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. 添加 systemRole
232
+ // 3. add systemRole
213
233
  if (config.systemRole) {
214
- postMessages.unshift({ content: config.systemRole, role: 'system' } as ChatMessage);
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: 请稍等,我帮您查询一下。{"function_call": {"name": "plugin-identifier____recommendClothes____standalone", "arguments": "{\n "mood": "",\n "gender": "man"\n}"}}
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
- dispatchMessage({ id: userId, message, role: 'user', type: 'addMessage' });
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
+ };
@@ -25,6 +25,7 @@ export const initLobeSession: LobeAgentSession = {
25
25
  chats: {},
26
26
  config: initialLobeAgentConfig,
27
27
  createAt: Date.now(),
28
+ files: [],
28
29
  id: '',
29
30
  meta: DEFAULT_AGENT_META,
30
31
  type: LobeSessionType.Agent,