@lobehub/chat 0.147.21 → 0.147.22

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 CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ### [Version 0.147.22](https://github.com/lobehub/lobe-chat/compare/v0.147.21...v0.147.22)
6
+
7
+ <sup>Released on **2024-04-19**</sup>
8
+
9
+ <br/>
10
+
11
+ <details>
12
+ <summary><kbd>Improvements and Fixes</kbd></summary>
13
+
14
+ </details>
15
+
16
+ <div align="right">
17
+
18
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
19
+
20
+ </div>
21
+
5
22
  ### [Version 0.147.21](https://github.com/lobehub/lobe-chat/compare/v0.147.20...v0.147.21)
6
23
 
7
24
  <sup>Released on **2024-04-19**</sup>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "0.147.21",
3
+ "version": "0.147.22",
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",
@@ -86,7 +86,7 @@
86
86
  "@aws-sdk/client-bedrock-runtime": "^3.549.0",
87
87
  "@azure/openai": "^1.0.0-beta.12",
88
88
  "@cfworker/json-schema": "^1.12.8",
89
- "@google/generative-ai": "^0.5.0",
89
+ "@google/generative-ai": "^0.7.0",
90
90
  "@icons-pack/react-simple-icons": "^9.4.0",
91
91
  "@lobehub/chat-plugin-sdk": "latest",
92
92
  "@lobehub/chat-plugins-gateway": "latest",
Binary file
Binary file
@@ -114,8 +114,8 @@ const TopicContent = memo<TopicContentProps>(({ id, title, fav, showMore }) => {
114
114
  modal.confirm({
115
115
  centered: true,
116
116
  okButtonProps: { danger: true },
117
- onOk: () => {
118
- removeTopic(id);
117
+ onOk: async () => {
118
+ await removeTopic(id);
119
119
  },
120
120
  title: t('topic.confirmRemoveTopic', { ns: 'chat' }),
121
121
  });
@@ -22,11 +22,9 @@ const metadata: Metadata = {
22
22
  },
23
23
  description,
24
24
  icons: {
25
- apple:
26
- 'https://registry.npmmirror.com/@lobehub/assets-favicons/latest/files/assets/apple-touch-icon.png',
27
- icon: 'https://registry.npmmirror.com/@lobehub/assets-favicons/latest/files/assets/favicon-32x32.png',
28
- shortcut:
29
- 'https://registry.npmmirror.com/@lobehub/assets-favicons/latest/files/assets/favicon.ico',
25
+ apple:'icons/apple-touch-icon.png',
26
+ icon:'favicon.ico',
27
+ shortcut:'favicon-32x32.ico',
30
28
  },
31
29
  manifest: noManifest ? undefined : '/manifest.json',
32
30
  metadataBase: new URL(SITE_URL),
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
7
7
 
8
8
  import HotKeys from '@/components/HotKeys';
9
9
  import { PREFIX_KEY, SAVE_TOPIC_KEY } from '@/const/hotkeys';
10
+ import { useActionSWR } from '@/libs/swr';
10
11
  import { useChatStore } from '@/store/chat';
11
12
 
12
13
  const SaveTopic = memo<{ mobile?: boolean }>(({ mobile }) => {
@@ -16,20 +17,23 @@ const SaveTopic = memo<{ mobile?: boolean }>(({ mobile }) => {
16
17
  s.openNewTopicOrSaveTopic,
17
18
  ]);
18
19
 
20
+ const { mutate, isValidating } = useActionSWR('openNewTopicOrSaveTopic', openNewTopicOrSaveTopic);
21
+
19
22
  const icon = hasTopic ? LucideMessageSquarePlus : LucideGalleryVerticalEnd;
20
23
  const Render = mobile ? ActionIcon : Button;
21
24
  const iconRender: any = mobile ? icon : <Icon icon={icon} />;
22
25
  const desc = t(hasTopic ? 'topic.openNewTopic' : 'topic.saveCurrentMessages');
23
26
 
24
27
  const hotkeys = [PREFIX_KEY, SAVE_TOPIC_KEY].join('+');
25
- useHotkeys(hotkeys, openNewTopicOrSaveTopic, {
28
+
29
+ useHotkeys(hotkeys, () => mutate(), {
26
30
  enableOnFormTags: true,
27
31
  preventDefault: true,
28
32
  });
29
33
 
30
34
  return (
31
35
  <Tooltip title={<HotKeys desc={desc} keys={hotkeys} />}>
32
- <Render aria-label={desc} icon={iconRender} onClick={openNewTopicOrSaveTopic} />
36
+ <Render aria-label={desc} icon={iconRender} loading={isValidating} onClick={() => mutate()} />
33
37
  </Tooltip>
34
38
  );
35
39
  });
@@ -19,6 +19,9 @@ import ActionsBar from './ActionsBar';
19
19
  import HistoryDivider from './HistoryDivider';
20
20
 
21
21
  const useStyles = createStyles(({ css, prefixCls }) => ({
22
+ loading: css`
23
+ opacity: 0.6;
24
+ `,
22
25
  message: css`
23
26
  // prevent the textarea too long
24
27
  .${prefixCls}-input {
@@ -35,7 +38,7 @@ export interface ChatListItemProps {
35
38
  const Item = memo<ChatListItemProps>(({ index, id }) => {
36
39
  const fontSize = useGlobalStore((s) => settingsSelectors.currentSettings(s).fontSize);
37
40
  const { t } = useTranslation('common');
38
- const { styles } = useStyles();
41
+ const { styles, cx } = useStyles();
39
42
  const [editing, setEditing] = useState(false);
40
43
  const [type = 'chat'] = useSessionStore((s) => {
41
44
  const config = agentSelectors.currentAgentConfig(s);
@@ -54,10 +57,12 @@ const Item = memo<ChatListItemProps>(({ index, id }) => {
54
57
  const historyLength = useChatStore((s) => chatSelectors.currentChats(s).length);
55
58
 
56
59
  const [loading, updateMessageContent] = useChatStore((s) => [
57
- s.chatLoadingId === id,
60
+ s.chatLoadingId === id || s.messageLoadingIds.includes(id),
58
61
  s.modifyMessageContent,
59
62
  ]);
60
63
 
64
+ const [isMessageLoading] = useChatStore((s) => [s.messageLoadingIds.includes(id)]);
65
+
61
66
  const onAvatarsClick = useAvatarsClick();
62
67
 
63
68
  const RenderMessage = useCallback(
@@ -110,7 +115,7 @@ const Item = memo<ChatListItemProps>(({ index, id }) => {
110
115
  <ChatItem
111
116
  actions={<ActionsBar index={index} setEditing={setEditing} />}
112
117
  avatar={item.meta}
113
- className={styles.message}
118
+ className={cx(styles.message, isMessageLoading && styles.loading)}
114
119
  editing={editing}
115
120
  error={error}
116
121
  errorMessage={<ErrorMessageExtra data={item} />}
@@ -32,3 +32,12 @@ export const useActionSWR: SWRHook = (key, fetch, config) =>
32
32
  revalidateOnReconnect: false,
33
33
  ...config,
34
34
  });
35
+
36
+ export interface SWRRefreshParams<T, A = (...args: any[]) => any> {
37
+ action: A;
38
+ optimisticData?: (data: T | undefined) => T;
39
+ }
40
+
41
+ export type SWRefreshMethod<T> = <A extends (...args: any[]) => Promise<any>>(
42
+ params?: SWRRefreshParams<T, A>,
43
+ ) => ReturnType<A>;
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
2
2
  // Disable the auto sort key eslint rule to make the code more logic and readable
3
3
  import { copyToClipboard } from '@lobehub/ui';
4
+ import { produce } from 'immer';
4
5
  import { template } from 'lodash-es';
5
6
  import { SWRResponse, mutate } from 'swr';
6
7
  import { StateCreator } from 'zustand/vanilla';
@@ -19,6 +20,7 @@ import { agentSelectors } from '@/store/session/selectors';
19
20
  import { ChatMessage } from '@/types/message';
20
21
  import { TraceEventPayloads } from '@/types/trace';
21
22
  import { setNamespace } from '@/utils/storeDebug';
23
+ import { nanoid } from '@/utils/uuid';
22
24
 
23
25
  import { chatSelectors } from '../../selectors';
24
26
  import { MessageDispatch, messagesReducer } from './reducer';
@@ -97,6 +99,7 @@ export interface ChatMessageAction {
97
99
  id?: string,
98
100
  action?: string,
99
101
  ) => AbortController | undefined;
102
+ toggleMessageLoading: (loading: boolean, id: string) => void;
100
103
  refreshMessages: () => Promise<void>;
101
104
  // TODO: 后续 smoothMessage 实现考虑落到 sse 这一层
102
105
  createSmoothMessage: (id: string) => {
@@ -111,6 +114,7 @@ export interface ChatMessageAction {
111
114
  * @param content
112
115
  */
113
116
  internalUpdateMessageContent: (id: string, content: string) => Promise<void>;
117
+ internalCreateMessage: (params: CreateMessageParams) => Promise<string>;
114
118
  internalResendMessage: (id: string, traceId?: string) => Promise<void>;
115
119
  internalTraceMessage: (id: string, payload: TraceEventPayloads) => Promise<void>;
116
120
  }
@@ -130,6 +134,7 @@ export const chatMessage: StateCreator<
130
134
  ChatMessageAction
131
135
  > = (set, get) => ({
132
136
  deleteMessage: async (id) => {
137
+ get().dispatchMessage({ type: 'deleteMessage', id });
133
138
  await messageService.removeMessage(id);
134
139
  await get().refreshMessages();
135
140
  },
@@ -167,43 +172,6 @@ export const chatMessage: StateCreator<
167
172
  await messageService.removeAllMessages();
168
173
  await refreshMessages();
169
174
  },
170
- internalResendMessage: async (messageId, traceId) => {
171
- // 1. 构造所有相关的历史记录
172
- const chats = chatSelectors.currentChats(get());
173
-
174
- const currentIndex = chats.findIndex((c) => c.id === messageId);
175
- if (currentIndex < 0) return;
176
-
177
- const currentMessage = chats[currentIndex];
178
-
179
- let contextMessages: ChatMessage[] = [];
180
-
181
- switch (currentMessage.role) {
182
- case 'function':
183
- case 'user': {
184
- contextMessages = chats.slice(0, currentIndex + 1);
185
- break;
186
- }
187
- case 'assistant': {
188
- // 消息是 AI 发出的因此需要找到它的 user 消息
189
- const userId = currentMessage.parentId;
190
- const userIndex = chats.findIndex((c) => c.id === userId);
191
- // 如果消息没有 parentId,那么同 user/function 模式
192
- contextMessages = chats.slice(0, userIndex < 0 ? currentIndex + 1 : userIndex + 1);
193
- break;
194
- }
195
- }
196
-
197
- if (contextMessages.length <= 0) return;
198
-
199
- const { coreProcessMessage } = get();
200
-
201
- const latestMsg = contextMessages.filter((s) => s.role === 'user').at(-1);
202
-
203
- if (!latestMsg) return;
204
-
205
- await coreProcessMessage(contextMessages, latestMsg.id, traceId);
206
- },
207
175
  sendMessage: async ({ message, files, onlyAddUserMessage }) => {
208
176
  const { coreProcessMessage, activeTopicId, activeId } = get();
209
177
  if (!activeId) return;
@@ -223,8 +191,7 @@ export const chatMessage: StateCreator<
223
191
  topicId: activeTopicId,
224
192
  };
225
193
 
226
- const id = await messageService.createMessage(newMessage);
227
- await get().refreshMessages();
194
+ const id = await get().internalCreateMessage(newMessage);
228
195
 
229
196
  // if only add user message, then stop
230
197
  if (onlyAddUserMessage) return;
@@ -315,8 +282,7 @@ export const chatMessage: StateCreator<
315
282
  topicId: activeTopicId, // if there is activeTopicId,then add it to topicId
316
283
  };
317
284
 
318
- const mid = await messageService.createMessage(assistantMessage);
319
- await refreshMessages();
285
+ const mid = await get().internalCreateMessage(assistantMessage);
320
286
 
321
287
  // 2. fetch the AI response
322
288
  const { isFunctionCall, content, functionCallAtEnd, functionCallContent, traceId } =
@@ -344,7 +310,7 @@ export const chatMessage: StateCreator<
344
310
  traceId,
345
311
  };
346
312
 
347
- functionId = await messageService.createMessage(functionMessage);
313
+ functionId = await get().internalCreateMessage(functionMessage);
348
314
  }
349
315
 
350
316
  await refreshMessages();
@@ -533,6 +499,62 @@ export const chatMessage: StateCreator<
533
499
  window.removeEventListener('beforeunload', preventLeavingFn);
534
500
  }
535
501
  },
502
+ toggleMessageLoading: (loading, id) => {
503
+ set(
504
+ {
505
+ messageLoadingIds: produce(get().messageLoadingIds, (draft) => {
506
+ if (loading) {
507
+ draft.push(id);
508
+ } else {
509
+ const index = draft.indexOf(id);
510
+
511
+ if (index >= 0) draft.splice(index, 1);
512
+ }
513
+ }),
514
+ },
515
+ false,
516
+ 'toggleMessageLoading',
517
+ );
518
+ },
519
+
520
+ internalResendMessage: async (messageId, traceId) => {
521
+ // 1. 构造所有相关的历史记录
522
+ const chats = chatSelectors.currentChats(get());
523
+
524
+ const currentIndex = chats.findIndex((c) => c.id === messageId);
525
+ if (currentIndex < 0) return;
526
+
527
+ const currentMessage = chats[currentIndex];
528
+
529
+ let contextMessages: ChatMessage[] = [];
530
+
531
+ switch (currentMessage.role) {
532
+ case 'function':
533
+ case 'user': {
534
+ contextMessages = chats.slice(0, currentIndex + 1);
535
+ break;
536
+ }
537
+ case 'assistant': {
538
+ // 消息是 AI 发出的因此需要找到它的 user 消息
539
+ const userId = currentMessage.parentId;
540
+ const userIndex = chats.findIndex((c) => c.id === userId);
541
+ // 如果消息没有 parentId,那么同 user/function 模式
542
+ contextMessages = chats.slice(0, userIndex < 0 ? currentIndex + 1 : userIndex + 1);
543
+ break;
544
+ }
545
+ }
546
+
547
+ if (contextMessages.length <= 0) return;
548
+
549
+ const { coreProcessMessage } = get();
550
+
551
+ const latestMsg = contextMessages.filter((s) => s.role === 'user').at(-1);
552
+
553
+ if (!latestMsg) return;
554
+
555
+ await coreProcessMessage(contextMessages, latestMsg.id, traceId);
556
+ },
557
+
536
558
  internalUpdateMessageContent: async (id, content) => {
537
559
  const { dispatchMessage, refreshMessages } = get();
538
560
 
@@ -545,6 +567,22 @@ export const chatMessage: StateCreator<
545
567
  await refreshMessages();
546
568
  },
547
569
 
570
+ internalCreateMessage: async (message) => {
571
+ const { dispatchMessage, refreshMessages, toggleMessageLoading } = get();
572
+
573
+ // use optimistic update to avoid the slow waiting
574
+ const tempId = 'tmp_' + nanoid();
575
+ dispatchMessage({ type: 'createMessage', id: tempId, value: message });
576
+
577
+ toggleMessageLoading(true, tempId);
578
+ const id = await messageService.createMessage(message);
579
+
580
+ await refreshMessages();
581
+ toggleMessageLoading(false, tempId);
582
+
583
+ return id;
584
+ },
585
+
548
586
  createSmoothMessage: (id) => {
549
587
  const { dispatchMessage } = get();
550
588
 
@@ -9,7 +9,7 @@ export interface ChatMessageState {
9
9
  activeId: string;
10
10
  chatLoadingId?: string;
11
11
  inputMessage: string;
12
- messageLoadingIds: [];
12
+ messageLoadingIds: string[];
13
13
  messages: ChatMessage[];
14
14
  /**
15
15
  * whether messages have fetched
@@ -1,6 +1,7 @@
1
1
  import isEqual from 'fast-deep-equal';
2
2
  import { produce } from 'immer';
3
3
 
4
+ import { CreateMessageParams } from '@/services/message';
4
5
  import { ChatMessage } from '@/types/message';
5
6
  import { merge } from '@/utils/merge';
6
7
 
@@ -10,6 +11,15 @@ interface UpdateMessage {
10
11
  type: 'updateMessage';
11
12
  value: ChatMessage[keyof ChatMessage];
12
13
  }
14
+ interface CreateMessage {
15
+ id: string;
16
+ type: 'createMessage';
17
+ value: CreateMessageParams;
18
+ }
19
+ interface DeleteMessage {
20
+ id: string;
21
+ type: 'deleteMessage';
22
+ }
13
23
 
14
24
  interface UpdatePluginState {
15
25
  id: string;
@@ -24,7 +34,12 @@ interface UpdateMessageExtra {
24
34
  value: any;
25
35
  }
26
36
 
27
- export type MessageDispatch = UpdateMessage | UpdatePluginState | UpdateMessageExtra;
37
+ export type MessageDispatch =
38
+ | CreateMessage
39
+ | UpdateMessage
40
+ | UpdatePluginState
41
+ | UpdateMessageExtra
42
+ | DeleteMessage;
28
43
 
29
44
  export const messagesReducer = (state: ChatMessage[], payload: MessageDispatch): ChatMessage[] => {
30
45
  switch (payload.type) {
@@ -76,6 +91,22 @@ export const messagesReducer = (state: ChatMessage[], payload: MessageDispatch):
76
91
  });
77
92
  }
78
93
 
94
+ case 'createMessage': {
95
+ return produce(state, (draftState) => {
96
+ const { value, id } = payload;
97
+
98
+ draftState.push({ ...value, createdAt: Date.now(), id, meta: {}, updatedAt: Date.now() });
99
+ });
100
+ }
101
+ case 'deleteMessage': {
102
+ return produce(state, (draft) => {
103
+ const { id } = payload;
104
+
105
+ const index = draft.findIndex((m) => m.id === id);
106
+
107
+ if (index >= 0) draft.splice(index, 1);
108
+ });
109
+ }
79
110
  default: {
80
111
  throw new Error('暂未实现的 type,请检查 reducer');
81
112
  }
@@ -149,7 +149,9 @@ describe('topic action', () => {
149
149
  });
150
150
 
151
151
  // Check if mutate has been called with the active session ID
152
- expect(mutate).toHaveBeenCalledWith(['SWR_USE_FETCH_TOPIC', activeId]);
152
+ expect(mutate).toHaveBeenCalledWith(['SWR_USE_FETCH_TOPIC', activeId], undefined, {
153
+ populateCache: false,
154
+ });
153
155
  });
154
156
 
155
157
  it('should handle errors during refreshing topics', async () => {
@@ -314,7 +316,7 @@ describe('topic action', () => {
314
316
  const activeId = 'test-session-id';
315
317
 
316
318
  await act(async () => {
317
- useChatStore.setState({ activeId });
319
+ useChatStore.setState({ activeId, activeTopicId: topicId });
318
320
  });
319
321
 
320
322
  const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
@@ -329,6 +331,27 @@ describe('topic action', () => {
329
331
  expect(refreshTopicSpy).toHaveBeenCalled();
330
332
  expect(switchTopicSpy).toHaveBeenCalled();
331
333
  });
334
+ it('should remove a specific topic and its messages, then not refresh the topic list', async () => {
335
+ const topicId = 'topic-1';
336
+ const { result } = renderHook(() => useChatStore());
337
+ const activeId = 'test-session-id';
338
+
339
+ await act(async () => {
340
+ useChatStore.setState({ activeId });
341
+ });
342
+
343
+ const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
344
+ const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
345
+
346
+ await act(async () => {
347
+ await result.current.removeTopic(topicId);
348
+ });
349
+
350
+ expect(messageService.removeMessages).toHaveBeenCalledWith(activeId, topicId);
351
+ expect(topicService.removeTopic).toHaveBeenCalledWith(topicId);
352
+ expect(refreshTopicSpy).toHaveBeenCalled();
353
+ expect(switchTopicSpy).not.toHaveBeenCalled();
354
+ });
332
355
  });
333
356
  describe('removeUnstarredTopic', () => {
334
357
  it('should remove unstarred topics and refresh the topic list', async () => {
@@ -9,11 +9,11 @@ import { StateCreator } from 'zustand/vanilla';
9
9
  import { chainSummaryTitle } from '@/chains/summaryTitle';
10
10
  import { LOADING_FLAT } from '@/const/message';
11
11
  import { TraceNameMap } from '@/const/trace';
12
- import { useClientDataSWR } from '@/libs/swr';
12
+ import { SWRefreshMethod, useClientDataSWR } from '@/libs/swr';
13
13
  import { chatService } from '@/services/chat';
14
14
  import { messageService } from '@/services/message';
15
15
  import { topicService } from '@/services/topic';
16
- import { ChatStore } from '@/store/chat';
16
+ import type { ChatStore } from '@/store/chat';
17
17
  import { ChatMessage } from '@/types/message';
18
18
  import { ChatTopic } from '@/types/topic';
19
19
  import { setNamespace } from '@/utils/storeDebug';
@@ -29,7 +29,7 @@ const SWR_USE_SEARCH_TOPIC = 'SWR_USE_SEARCH_TOPIC';
29
29
  export interface ChatTopicAction {
30
30
  favoriteTopic: (id: string, favState: boolean) => Promise<void>;
31
31
  openNewTopicOrSaveTopic: () => Promise<void>;
32
- refreshTopic: () => Promise<void>;
32
+ refreshTopic: SWRefreshMethod<ChatTopic[]>;
33
33
  removeAllTopics: () => Promise<void>;
34
34
  removeSessionTopics: () => Promise<void>;
35
35
  removeTopic: (id: string) => Promise<void>;
@@ -78,6 +78,17 @@ export const chatTopic: StateCreator<
78
78
  messages: messages.map((m) => m.id),
79
79
  });
80
80
  await refreshTopic();
81
+ // TODO: 优化为乐观更新
82
+ // const params: CreateTopicParams = {
83
+ // sessionId: activeId,
84
+ // title: t('topic.defaultTitle', { ns: 'chat' }),
85
+ // messages: messages.map((m) => m.id),
86
+ // };
87
+
88
+ // const topicId = await refreshTopic({
89
+ // action: async () => topicService.createTopic(params),
90
+ // optimisticData: (data) => topicReducer(data, { type: 'addTopic', value: params }),
91
+ // });
81
92
 
82
93
  // 2. auto summary topic Title
83
94
  // we don't need to wait for summary, just let it run async
@@ -189,9 +200,10 @@ export const chatTopic: StateCreator<
189
200
  await refreshTopic();
190
201
  },
191
202
  removeTopic: async (id) => {
192
- const { activeId, switchTopic, refreshTopic } = get();
203
+ const { activeId, activeTopicId, switchTopic, refreshTopic } = get();
193
204
 
194
205
  // remove messages in the topic
206
+ // TODO: Need to remove because server service don't need to call it
195
207
  await messageService.removeMessages(activeId, id);
196
208
 
197
209
  // remove topic
@@ -199,7 +211,7 @@ export const chatTopic: StateCreator<
199
211
  await refreshTopic();
200
212
 
201
213
  // switch bach to default topic
202
- switchTopic();
214
+ if (activeTopicId === id) switchTopic();
203
215
  },
204
216
  removeUnstarredTopic: async () => {
205
217
  const { refreshTopic, switchTopic } = get();
@@ -226,7 +238,12 @@ export const chatTopic: StateCreator<
226
238
  updateTopicLoading: (id) => {
227
239
  set({ topicLoadingId: id }, false, n('updateTopicLoading'));
228
240
  },
229
- refreshTopic: async () => {
230
- await mutate([SWR_USE_FETCH_TOPIC, get().activeId]);
241
+ // TODO: I don't know why this ts error, so have to ignore it
242
+ // @ts-ignore
243
+ refreshTopic: async (params) => {
244
+ return mutate([SWR_USE_FETCH_TOPIC, get().activeId], params?.action, {
245
+ optimisticData: params?.optimisticData,
246
+ populateCache: false,
247
+ });
231
248
  },
232
249
  });
@@ -0,0 +1,141 @@
1
+ import { produce } from 'immer';
2
+ import { expect } from 'vitest';
3
+
4
+ import { ChatTopic } from '@/types/topic';
5
+
6
+ import { ChatTopicDispatch, topicReducer } from './reducer';
7
+
8
+ describe('topicReducer', () => {
9
+ let state: ChatTopic[];
10
+
11
+ beforeEach(() => {
12
+ state = [];
13
+ });
14
+
15
+ describe('addTopic', () => {
16
+ it('should add a new ChatTopic object to state', () => {
17
+ const payload: ChatTopicDispatch = {
18
+ type: 'addTopic',
19
+ value: {
20
+ title: 'Test Topic',
21
+ sessionId: '',
22
+ },
23
+ };
24
+
25
+ const newState = topicReducer(state, payload);
26
+
27
+ expect(newState[0].id).toBeDefined();
28
+ });
29
+ });
30
+
31
+ describe('updateTopic', () => {
32
+ it('should update the ChatTopic object in state', () => {
33
+ const topic: ChatTopic = {
34
+ id: '1',
35
+ title: 'Test Topic',
36
+ createdAt: Date.now(),
37
+ updatedAt: Date.now(),
38
+ };
39
+
40
+ state.push(topic);
41
+
42
+ const payload: ChatTopicDispatch = {
43
+ type: 'updateTopic',
44
+ id: '1',
45
+ key: 'title',
46
+ value: 'Updated Topic',
47
+ };
48
+
49
+ const newState = topicReducer(state, payload);
50
+
51
+ expect(newState[0].title).toBe('Updated Topic');
52
+ });
53
+
54
+ it('should update the ChatTopic object with correct properties', () => {
55
+ const topic: ChatTopic = {
56
+ id: '1',
57
+ title: 'Test Topic',
58
+ createdAt: Date.now() - 1,
59
+ updatedAt: Date.now() - 1, // 设定比当前时间前面一点
60
+ };
61
+
62
+ state.push(topic);
63
+
64
+ const payload: ChatTopicDispatch = {
65
+ type: 'updateTopic',
66
+ id: '1',
67
+ key: 'title',
68
+ value: 'Updated Topic',
69
+ };
70
+
71
+ const newState = topicReducer(state, payload);
72
+
73
+ expect(newState[0].updatedAt).toBeGreaterThan(topic.updatedAt);
74
+ });
75
+ });
76
+
77
+ describe('deleteTopic', () => {
78
+ it('should delete the specified ChatTopic object from state', () => {
79
+ const topic: ChatTopic = {
80
+ id: '1',
81
+ title: 'Test Topic',
82
+ createdAt: Date.now(),
83
+ updatedAt: Date.now(),
84
+ };
85
+
86
+ state.push(topic);
87
+
88
+ const payload: ChatTopicDispatch = {
89
+ type: 'deleteTopic',
90
+ id: '1',
91
+ };
92
+
93
+ const newState = topicReducer(state, payload);
94
+
95
+ expect(newState).toEqual([]);
96
+ });
97
+ });
98
+
99
+ describe('default', () => {
100
+ it('should return the original state object', () => {
101
+ const payload = {
102
+ type: 'unknown',
103
+ } as unknown as ChatTopicDispatch;
104
+
105
+ const newState = topicReducer(state, payload);
106
+
107
+ expect(newState).toBe(state);
108
+ });
109
+ });
110
+
111
+ describe('produce', () => {
112
+ it('should generate immutable state object', () => {
113
+ const payload: ChatTopicDispatch = {
114
+ type: 'addTopic',
115
+ value: {
116
+ title: 'Test Topic',
117
+ sessionId: '1',
118
+ },
119
+ };
120
+
121
+ const newState = topicReducer(state, payload);
122
+
123
+ expect(newState).not.toBe(state);
124
+ });
125
+
126
+ it('should not modify the original state object', () => {
127
+ const payload: ChatTopicDispatch = {
128
+ type: 'addTopic',
129
+ value: {
130
+ title: 'Test Topic',
131
+
132
+ sessionId: '123',
133
+ },
134
+ };
135
+
136
+ const newState = topicReducer(state, payload);
137
+
138
+ expect(state).toEqual([]);
139
+ });
140
+ });
141
+ });
@@ -0,0 +1,67 @@
1
+ import { produce } from 'immer';
2
+
3
+ import { CreateTopicParams } from '@/services/topic/type';
4
+ import { ChatTopic } from '@/types/topic';
5
+
6
+ interface AddChatTopicAction {
7
+ type: 'addTopic';
8
+ value: CreateTopicParams;
9
+ }
10
+
11
+ interface UpdateChatTopicAction {
12
+ id: string;
13
+ key: keyof ChatTopic;
14
+ type: 'updateTopic';
15
+ value: any;
16
+ }
17
+
18
+ interface DeleteChatTopicAction {
19
+ id: string;
20
+ type: 'deleteTopic';
21
+ }
22
+
23
+ export type ChatTopicDispatch = AddChatTopicAction | UpdateChatTopicAction | DeleteChatTopicAction;
24
+
25
+ export const topicReducer = (state: ChatTopic[] = [], payload: ChatTopicDispatch): ChatTopic[] => {
26
+ switch (payload.type) {
27
+ case 'addTopic': {
28
+ return produce(state, (draftState) => {
29
+ draftState.unshift({
30
+ ...payload.value,
31
+ createdAt: Date.now(),
32
+ id: Date.now().toString(),
33
+ sessionId: payload.value.sessionId ? payload.value.sessionId : undefined,
34
+ updatedAt: Date.now(),
35
+ });
36
+ });
37
+ }
38
+
39
+ case 'updateTopic': {
40
+ return produce(state, (draftState) => {
41
+ const { key, value, id } = payload;
42
+ const topicIndex = draftState.findIndex((topic) => topic.id === id);
43
+
44
+ if (topicIndex !== -1) {
45
+ const updatedTopic = { ...draftState[topicIndex] };
46
+ // @ts-ignore
47
+ updatedTopic[key] = value;
48
+ draftState[topicIndex] = updatedTopic;
49
+ updatedTopic.updatedAt = Date.now();
50
+ }
51
+ });
52
+ }
53
+
54
+ case 'deleteTopic': {
55
+ return produce(state, (draftState) => {
56
+ const topicIndex = draftState.findIndex((topic) => topic.id === payload.id);
57
+ if (topicIndex !== -1) {
58
+ draftState.splice(topicIndex, 1);
59
+ }
60
+ });
61
+ }
62
+
63
+ default: {
64
+ return state;
65
+ }
66
+ }
67
+ };
@@ -6,7 +6,7 @@ import { StateCreator } from 'zustand/vanilla';
6
6
 
7
7
  import { message } from '@/components/AntdStaticMethods';
8
8
  import { INBOX_SESSION_ID } from '@/const/session';
9
- import { useClientDataSWR } from '@/libs/swr';
9
+ import { SWRRefreshParams, useClientDataSWR } from '@/libs/swr';
10
10
  import { sessionService } from '@/services/session';
11
11
  import { useGlobalStore } from '@/store/global';
12
12
  import { settingsSelectors } from '@/store/global/selectors';
@@ -51,10 +51,7 @@ export interface SessionAction {
51
51
  /**
52
52
  * re-fetch the data
53
53
  */
54
- refreshSessions: (params?: {
55
- action: () => Promise<void>;
56
- optimisticData?: (data: ChatSessionList) => ChatSessionList;
57
- }) => Promise<void>;
54
+ refreshSessions: (params?: SWRRefreshParams<ChatSessionList>) => Promise<void>;
58
55
 
59
56
  /**
60
57
  * remove session
@@ -145,6 +142,8 @@ export const createSessionSlice: StateCreator<
145
142
  },
146
143
  // 乐观更新
147
144
  optimisticData: produce((draft) => {
145
+ if (!draft) return;
146
+
148
147
  const session = draft.all.find((i) => i.id === sessionId);
149
148
  if (!session) return;
150
149