@lobehub/chat 0.118.10 → 0.119.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +8 -8
  3. package/README.zh-CN.md +8 -8
  4. package/docs/Home.md +1 -1
  5. package/docs/_Sidebar.md +1 -1
  6. package/locales/de-DE/chat.json +10 -0
  7. package/locales/de-DE/common.json +1 -0
  8. package/locales/de-DE/error.json +4 -4
  9. package/locales/en-US/chat.json +10 -0
  10. package/locales/en-US/common.json +1 -0
  11. package/locales/en-US/error.json +1 -1
  12. package/locales/es-ES/chat.json +10 -0
  13. package/locales/es-ES/common.json +1 -0
  14. package/locales/fr-FR/chat.json +10 -0
  15. package/locales/fr-FR/common.json +1 -0
  16. package/locales/fr-FR/error.json +1 -2
  17. package/locales/it-IT/chat.json +10 -0
  18. package/locales/it-IT/common.json +1 -0
  19. package/locales/it-IT/error.json +5 -5
  20. package/locales/ja-JP/chat.json +10 -0
  21. package/locales/ja-JP/common.json +1 -0
  22. package/locales/ko-KR/chat.json +10 -0
  23. package/locales/ko-KR/common.json +1 -0
  24. package/locales/ko-KR/error.json +4 -4
  25. package/locales/nl-NL/chat.json +10 -0
  26. package/locales/nl-NL/common.json +1 -0
  27. package/locales/pl-PL/chat.json +10 -0
  28. package/locales/pl-PL/common.json +1 -0
  29. package/locales/pl-PL/error.json +2 -3
  30. package/locales/pt-BR/chat.json +10 -0
  31. package/locales/pt-BR/common.json +1 -0
  32. package/locales/ru-RU/chat.json +10 -0
  33. package/locales/ru-RU/common.json +1 -0
  34. package/locales/ru-RU/error.json +3 -3
  35. package/locales/tr-TR/chat.json +10 -0
  36. package/locales/tr-TR/common.json +1 -0
  37. package/locales/vi-VN/chat.json +10 -0
  38. package/locales/vi-VN/common.json +1 -0
  39. package/locales/vi-VN/error.json +1 -1
  40. package/locales/zh-CN/chat.json +10 -0
  41. package/locales/zh-CN/common.json +1 -0
  42. package/locales/zh-TW/chat.json +10 -0
  43. package/locales/zh-TW/common.json +1 -0
  44. package/package.json +6 -6
  45. package/src/app/chat/(desktop)/features/Conversation.tsx +17 -0
  46. package/src/app/chat/(desktop)/features/HotKeys.tsx +44 -0
  47. package/src/app/chat/(desktop)/index.tsx +2 -3
  48. package/src/app/chat/features/ChatHeader/ShareButton/Preview.tsx +1 -1
  49. package/src/app/chat/features/SessionListContent/List/Item/Actions.tsx +24 -3
  50. package/src/app/chat/features/TopicListContent/Topic/TopicContent.tsx +43 -2
  51. package/src/database/models/__tests__/message.test.ts +48 -0
  52. package/src/database/models/__tests__/topic.test.ts +135 -0
  53. package/src/database/models/message.ts +35 -0
  54. package/src/database/models/session.ts +11 -1
  55. package/src/database/models/topic.ts +34 -2
  56. package/src/features/ChatInput/ActionBar/Clear.tsx +0 -6
  57. package/src/features/Conversation/Actions/Assistant.tsx +16 -5
  58. package/src/features/Conversation/Actions/Error.tsx +2 -2
  59. package/src/features/Conversation/Actions/Fallback.tsx +4 -3
  60. package/src/features/Conversation/Actions/Function.tsx +3 -3
  61. package/src/features/Conversation/Actions/User.tsx +3 -3
  62. package/src/features/Conversation/Actions/customAction.ts +2 -4
  63. package/src/features/Conversation/Actions/index.ts +50 -45
  64. package/src/features/Conversation/Error/InvalidAccess.tsx +1 -1
  65. package/src/features/Conversation/Error/OpenAPIKey.tsx +1 -1
  66. package/src/features/Conversation/Error/OpenAiBizError.tsx +1 -1
  67. package/src/features/Conversation/Error/Plugin/PluginError.tsx +1 -1
  68. package/src/features/Conversation/Error/Plugin/PluginSettings.tsx +1 -1
  69. package/src/features/Conversation/Error/index.tsx +2 -2
  70. package/src/features/Conversation/Extras/Assistant.tsx +32 -29
  71. package/src/features/Conversation/Extras/User.tsx +2 -1
  72. package/src/features/Conversation/Extras/index.ts +4 -4
  73. package/src/features/Conversation/Messages/index.ts +3 -3
  74. package/src/features/Conversation/components/ChatItem/ActionsBar.tsx +58 -0
  75. package/src/features/Conversation/components/ChatItem/index.tsx +126 -0
  76. package/src/features/Conversation/components/ChatList/HistoryDivider.tsx +6 -3
  77. package/src/features/Conversation/components/ChatList/index.tsx +35 -79
  78. package/src/features/Conversation/hooks/useChatListActionsBar.tsx +43 -31
  79. package/src/features/Conversation/hooks/useInitConversation.ts +4 -1
  80. package/src/features/Conversation/index.tsx +4 -3
  81. package/src/features/Conversation/types/index.tsx +35 -0
  82. package/src/features/PluginStore/PluginItem/EditCustomPlugin.tsx +7 -2
  83. package/src/locales/default/chat.ts +10 -1
  84. package/src/locales/default/common.ts +1 -0
  85. package/src/services/config.ts +22 -0
  86. package/src/services/session.ts +6 -0
  87. package/src/services/topic.ts +4 -0
  88. package/src/store/chat/slices/message/action.ts +89 -2
  89. package/src/store/chat/slices/message/selectors.ts +10 -0
  90. package/src/store/chat/slices/topic/action.ts +22 -0
  91. package/src/store/global/slices/common/action.ts +2 -0
  92. package/src/store/session/slices/session/action.test.ts +136 -0
  93. package/src/store/session/slices/session/action.ts +26 -6
  94. package/src/types/topic.ts +1 -0
  95. package/src/utils/fetch.test.ts +131 -0
  96. package/src/utils/fetch.ts +26 -10
  97. package/src/features/Conversation/components/ChatList/ActionsBar.tsx +0 -27
  98. package/src/features/Conversation/components/ChatList/Item.tsx +0 -236
  99. package/src/features/Conversation/container/index.tsx +0 -83
  100. /package/docs/Development/{Steup-Development.md → Setup-Development.md} +0 -0
  101. /package/docs/Development/{Steup-Development.zh-CN.md → Setup-Development.zh-CN.md} +0 -0
@@ -8,6 +8,7 @@ export default {
8
8
  confirmRemoveSessionItemAlert: '即将删除该助手,删除后该将无法找回,请确认你的操作',
9
9
  defaultAgent: '自定义助手',
10
10
  defaultSession: '自定义助手',
11
+ duplicateTitle: '{{title}} 副本',
11
12
  historyRange: '历史范围',
12
13
  inbox: {
13
14
  defaultMessage:
@@ -15,9 +16,12 @@ export default {
15
16
  desc: '开启大脑集群,激发思维火花。你的智能助理,在这里与你交流一切',
16
17
  title: '随便聊聊',
17
18
  },
19
+ messageAction: {
20
+ delAndRegenerate: '删除并重新生成',
21
+ regenerate: '重新生成',
22
+ },
18
23
  newAgent: '新建助手',
19
24
  noDescription: '暂无描述',
20
-
21
25
  pin: '置顶',
22
26
  pinOff: '取消置顶',
23
27
  regenerate: '重新生成',
@@ -52,6 +56,11 @@ export default {
52
56
  used: '使用',
53
57
  },
54
58
  topic: {
59
+ actions: {
60
+ autoRename: '智能重命名',
61
+ duplicate: '创建副本',
62
+ export: '导出话题',
63
+ },
55
64
  confirmRemoveAll: '即将删除全部话题,删除后将不可恢复,请谨慎操作。',
56
65
  confirmRemoveTopic: '即将删除该话题,删除后将不可恢复,请谨慎操作。',
57
66
  confirmRemoveUnstarred: '即将删除未收藏话题,删除后将不可恢复,请谨慎操作。',
@@ -17,6 +17,7 @@ export default {
17
17
  defaultAgent: '自定义助手',
18
18
  defaultSession: '自定义助手',
19
19
  delete: '删除',
20
+ duplicate: '创建副本',
20
21
  edit: '编辑',
21
22
  export: '导出配置',
22
23
  exportType: {
@@ -127,6 +127,28 @@ class ConfigService {
127
127
  exportConfigFile(config, `${session.meta?.title}-session`);
128
128
  };
129
129
 
130
+ /**
131
+ * export a topic
132
+ */
133
+ exportSingleTopic = async (sessionId: string, topicId: string) => {
134
+ const session = this.getSession(sessionId);
135
+ if (!session) return;
136
+
137
+ const messages = await messageService.getMessages(sessionId, topicId);
138
+ const topics = await topicService.getTopics({ sessionId });
139
+
140
+ const topic = topics.find((item) => item.id === topicId);
141
+ if (!topic) return;
142
+
143
+ const config = createConfigFile('singleSession', {
144
+ messages,
145
+ sessions: [session],
146
+ topics: [topic],
147
+ });
148
+
149
+ exportConfigFile(config, `${topic.title}-topic`);
150
+ };
151
+
130
152
  exportSingleAgent = async (id: string) => {
131
153
  const agent = this.getAgent(id);
132
154
  if (!agent) return;
@@ -58,6 +58,12 @@ class SessionService {
58
58
  async searchSessions(keyword: string) {
59
59
  return SessionModel.queryByKeyword(keyword);
60
60
  }
61
+
62
+ async duplicateSession(id: string, newTitle: string): Promise<string | undefined> {
63
+ const res = await SessionModel.duplicate(id, newTitle);
64
+
65
+ if (res) return res?.id;
66
+ }
61
67
  }
62
68
 
63
69
  export const sessionService = new SessionService();
@@ -51,6 +51,10 @@ class TopicService {
51
51
  async searchTopics(keyword: string) {
52
52
  return TopicModel.queryByKeyword(keyword);
53
53
  }
54
+
55
+ async duplicateTopic(id: string, newTitle?: string) {
56
+ return TopicModel.duplicateTopic(id, newTitle);
57
+ }
54
58
  }
55
59
 
56
60
  export const topicService = new TopicService();
@@ -71,6 +71,12 @@ export interface ChatMessageAction {
71
71
  action?: string,
72
72
  ) => AbortController | undefined;
73
73
  refreshMessages: () => Promise<void>;
74
+ createSmoothMessage: (id: string) => {
75
+ startAnimation: (speed?: number) => Promise<void>;
76
+ stopAnimation: () => void;
77
+ outputQueue: string[];
78
+ isAnimationActive: boolean;
79
+ };
74
80
  }
75
81
 
76
82
  const getAgentConfig = () => agentSelectors.currentAgentConfig(useSessionStore.getState());
@@ -297,7 +303,7 @@ export const chatMessage: StateCreator<
297
303
  set({ messages }, false, n(`dispatchMessage/${payload.type}`, payload));
298
304
  },
299
305
  fetchAIChatMessage: async (messages, assistantId) => {
300
- const { toggleChatLoading, refreshMessages, updateMessageContent } = get();
306
+ const { toggleChatLoading, refreshMessages, updateMessageContent, createSmoothMessage } = get();
301
307
 
302
308
  const abortController = toggleChatLoading(
303
309
  true,
@@ -366,19 +372,35 @@ export const chatMessage: StateCreator<
366
372
  let functionCallAtEnd = false;
367
373
  let functionCallContent = '';
368
374
 
375
+ const { startAnimation, stopAnimation, outputQueue, isAnimationActive } =
376
+ createSmoothMessage(assistantId);
377
+
369
378
  await fetchSSE(fetcher, {
370
379
  onErrorHandle: async (error) => {
371
380
  await messageService.updateMessageError(assistantId, error);
372
381
  await refreshMessages();
373
382
  },
383
+ onAbort: async () => {
384
+ stopAnimation();
385
+ },
374
386
  onFinish: async (content) => {
387
+ stopAnimation();
388
+
389
+ // 如果还有内容没有显示,则继续动画
390
+ if (outputQueue.length > 0) {
391
+ await startAnimation(15);
392
+ }
393
+
394
+ // 如果没有剩余内容,直接更新消息内容
375
395
  // update the content after fetch result
376
396
  await updateMessageContent(assistantId, content);
377
397
  },
378
398
  onMessageHandle: async (text) => {
379
399
  output += text;
400
+ outputQueue.push(...text.split(''));
380
401
 
381
- await updateMessageContent(assistantId, output);
402
+ // 如果是第一次接收到消息,启动打字动画
403
+ if (!isAnimationActive) startAnimation();
382
404
 
383
405
  // is this message is just a function call
384
406
  if (isFunctionMessageAtStart(output)) isFunctionCall = true;
@@ -416,4 +438,69 @@ export const chatMessage: StateCreator<
416
438
  window.removeEventListener('beforeunload', preventLeavingFn);
417
439
  }
418
440
  },
441
+
442
+ createSmoothMessage: (id) => {
443
+ const { dispatchMessage } = get();
444
+
445
+ let buffer = '';
446
+ // why use queue: https://shareg.pt/GLBrjpK
447
+ let outputQueue: string[] = [];
448
+
449
+ // eslint-disable-next-line no-undef
450
+ let animationTimeoutId: NodeJS.Timeout | null = null;
451
+ let isAnimationActive = false;
452
+
453
+ // when you need to stop the animation, call this function
454
+ const stopAnimation = () => {
455
+ isAnimationActive = false;
456
+ if (animationTimeoutId !== null) {
457
+ clearTimeout(animationTimeoutId);
458
+ animationTimeoutId = null;
459
+ }
460
+ };
461
+
462
+ // define startAnimation function to display the text in buffer smooth
463
+ // when you need to start the animation, call this function
464
+ const startAnimation = (speed = 2) =>
465
+ new Promise<void>((resolve) => {
466
+ if (isAnimationActive) {
467
+ resolve();
468
+ return;
469
+ }
470
+
471
+ isAnimationActive = true;
472
+
473
+ const updateText = () => {
474
+ // 如果动画已经不再激活,则停止更新文本
475
+ if (!isAnimationActive) {
476
+ clearTimeout(animationTimeoutId!);
477
+ animationTimeoutId = null;
478
+ resolve();
479
+ }
480
+
481
+ // 如果还有文本没有显示
482
+ // 检查队列中是否有字符待显示
483
+ if (outputQueue.length > 0) {
484
+ // 从队列中获取前两个字符(如果存在)
485
+ const charsToAdd = outputQueue.splice(0, speed).join('');
486
+ buffer += charsToAdd;
487
+
488
+ // 更新消息内容,这里可能需要结合实际情况调整
489
+ dispatchMessage({ id, key: 'content', type: 'updateMessage', value: buffer });
490
+
491
+ // 设置下一个字符的延迟
492
+ animationTimeoutId = setTimeout(updateText, 16); // 16 毫秒的延迟模拟打字机效果
493
+ } else {
494
+ // 当所有字符都显示完毕时,清除动画状态
495
+ isAnimationActive = false;
496
+ animationTimeoutId = null;
497
+ resolve();
498
+ }
499
+ };
500
+
501
+ updateText();
502
+ });
503
+
504
+ return { startAnimation, stopAnimation, outputQueue, isAnimationActive };
505
+ },
419
506
  });
@@ -84,6 +84,12 @@ const currentChatsWithGuideMessage =
84
84
  return [emptyInboxGuideMessage];
85
85
  };
86
86
 
87
+ const currentChatIDsWithGuideMessage = (s: ChatStore) => {
88
+ const meta = agentSelectors.currentAgentMeta(useSessionStore.getState());
89
+
90
+ return currentChatsWithGuideMessage(meta)(s).map((s) => s.id);
91
+ };
92
+
87
93
  const currentChatsWithHistoryConfig = (s: ChatStore): ChatMessage[] => {
88
94
  const chats = currentChats(s);
89
95
  const config = agentSelectors.currentAgentConfig(useSessionStore.getState());
@@ -109,11 +115,15 @@ const getFunctionMessageProps =
109
115
 
110
116
  const getMessageById = (id: string) => (s: ChatStore) => chatHelpers.getMessageById(s.messages, id);
111
117
 
118
+ const latestMessage = (s: ChatStore) => currentChats(s).at(-1);
119
+
112
120
  export const chatSelectors = {
113
121
  chatsMessageString,
122
+ currentChatIDsWithGuideMessage,
114
123
  currentChats,
115
124
  currentChatsWithGuideMessage,
116
125
  currentChatsWithHistoryConfig,
117
126
  getFunctionMessageProps,
118
127
  getMessageById,
128
+ latestMessage,
119
129
  };
@@ -30,6 +30,8 @@ export interface ChatTopicAction {
30
30
  removeTopic: (id: string) => Promise<void>;
31
31
  removeUnstarredTopic: () => void;
32
32
  saveToTopic: () => Promise<string | undefined>;
33
+ autoRenameTopicTitle: (id: string) => Promise<void>;
34
+ duplicateTopic: (id: string) => Promise<void>;
33
35
  summaryTopicTitle: (topicId: string, messages: ChatMessage[]) => Promise<void>;
34
36
  switchTopic: (id?: string) => Promise<void>;
35
37
  updateTopicTitleInSummary: (id: string, title: string) => void;
@@ -78,6 +80,19 @@ export const chatTopic: StateCreator<
78
80
 
79
81
  return topicId;
80
82
  },
83
+ duplicateTopic: async (id) => {
84
+ const { refreshTopic, switchTopic } = get();
85
+
86
+ const topic = topicSelectors.getTopicById(id)(get());
87
+ if (!topic) return;
88
+
89
+ const newTitle = t('duplicateTitle', { ns: 'chat', title: topic?.title });
90
+
91
+ const newTopicId = await topicService.duplicateTopic(id, newTitle);
92
+ await refreshTopic();
93
+
94
+ switchTopic(newTopicId);
95
+ },
81
96
  // update
82
97
  summaryTopicTitle: async (topicId, messages) => {
83
98
  const { updateTopicTitleInSummary, updateTopicLoading, refreshTopic } = get();
@@ -115,6 +130,13 @@ export const chatTopic: StateCreator<
115
130
  await topicService.updateTitle(id, title);
116
131
  await get().refreshTopic();
117
132
  },
133
+
134
+ autoRenameTopicTitle: async (id) => {
135
+ const { activeId: sessionId, summaryTopicTitle } = get();
136
+ const messages = await messageService.getMessages(sessionId, id);
137
+
138
+ await summaryTopicTitle(id, messages);
139
+ },
118
140
  // query
119
141
  useFetchTopics: (sessionId) =>
120
142
  useSWR<ChatTopic[]>(sessionId, async (sessionId) => topicService.getTopics({ sessionId }), {
@@ -75,6 +75,8 @@ export const createCommonSlice: StateCreator<
75
75
  },
76
76
  useCheckLatestVersion: () =>
77
77
  useSWR('checkLatestVersion', globalService.getLatestVersion, {
78
+ // check latest version every 30 minutes
79
+ focusThrottleInterval: 1000 * 60 * 30,
78
80
  onSuccess: (data: string) => {
79
81
  if (gt(data, CURRENT_VERSION))
80
82
  set({ hasNewVersion: true, latestVersion: data }, false, n('checkLatestVersion'));
@@ -0,0 +1,136 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { INBOX_SESSION_ID } from '@/const/session';
6
+ import { SESSION_CHAT_URL } from '@/const/url';
7
+ import { sessionService } from '@/services/session';
8
+ import { useSessionStore } from '@/store/session';
9
+ import { LobeSessionType } from '@/types/session';
10
+
11
+ // Mock sessionService 和其他依赖项
12
+ vi.mock('@/services/session', () => ({
13
+ sessionService: {
14
+ removeAllSessions: vi.fn(),
15
+ createNewSession: vi.fn(),
16
+ duplicateSession: vi.fn(),
17
+ updateSessionGroup: vi.fn(),
18
+ removeSession: vi.fn(),
19
+ getSessions: vi.fn(),
20
+ searchSessions: vi.fn(),
21
+ },
22
+ }));
23
+
24
+ // Mock router
25
+ const mockRouterPush = vi.fn();
26
+
27
+ const mockRefresh = vi.fn();
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ useSessionStore.setState({
31
+ refreshSessions: mockRefresh,
32
+ router: {
33
+ push: mockRouterPush,
34
+ } as unknown as AppRouterInstance,
35
+ });
36
+ });
37
+
38
+ afterEach(() => {
39
+ vi.restoreAllMocks();
40
+ });
41
+
42
+ describe('SessionAction', () => {
43
+ describe('clearSessions', () => {
44
+ it('should clear all sessions and refresh the list', async () => {
45
+ const { result } = renderHook(() => useSessionStore());
46
+
47
+ await act(async () => {
48
+ await result.current.clearSessions();
49
+ });
50
+
51
+ expect(sessionService.removeAllSessions).toHaveBeenCalled();
52
+ expect(mockRefresh).toHaveBeenCalled(); // 假设 refreshSessions 调用了 getSessions
53
+ });
54
+ });
55
+
56
+ describe('createSession', () => {
57
+ it('should create a new session and switch to it', async () => {
58
+ const { result } = renderHook(() => useSessionStore());
59
+ const newSessionId = 'new-session-id';
60
+ vi.mocked(sessionService.createNewSession).mockResolvedValue(newSessionId);
61
+
62
+ let createdSessionId;
63
+
64
+ await act(async () => {
65
+ createdSessionId = await result.current.createSession({ config: { displayMode: 'docs' } });
66
+ });
67
+
68
+ const call = vi.mocked(sessionService.createNewSession).mock.calls[0];
69
+ expect(call[0]).toEqual(LobeSessionType.Agent);
70
+ expect(call[1]).toMatchObject({ config: { displayMode: 'docs' } });
71
+
72
+ expect(createdSessionId).toBe(newSessionId);
73
+ expect(mockRouterPush).toHaveBeenCalledWith(
74
+ SESSION_CHAT_URL(newSessionId, result.current.isMobile),
75
+ );
76
+ });
77
+ });
78
+
79
+ describe('duplicateSession', () => {
80
+ it('should duplicate a session and switch to the new one', async () => {
81
+ const { result } = renderHook(() => useSessionStore());
82
+ const sessionId = 'session-id';
83
+ const duplicatedSessionId = 'duplicated-session-id';
84
+ vi.mocked(sessionService.duplicateSession).mockResolvedValue(duplicatedSessionId);
85
+
86
+ await act(async () => {
87
+ await result.current.duplicateSession(sessionId);
88
+ });
89
+
90
+ expect(sessionService.duplicateSession).toHaveBeenCalledWith(sessionId, undefined);
91
+ });
92
+ });
93
+
94
+ describe('removeSession', () => {
95
+ it('should remove a session and refresh the list', async () => {
96
+ const { result } = renderHook(() => useSessionStore());
97
+ const sessionId = 'session-id';
98
+
99
+ await act(async () => {
100
+ await result.current.removeSession(sessionId);
101
+ });
102
+
103
+ expect(sessionService.removeSession).toHaveBeenCalledWith(sessionId);
104
+ expect(mockRefresh).toHaveBeenCalled();
105
+ });
106
+ });
107
+
108
+ describe('switchSession', () => {
109
+ it('should switch to the provided session id', async () => {
110
+ const { result } = renderHook(() => useSessionStore());
111
+ const sessionId = 'session-id';
112
+
113
+ act(() => {
114
+ result.current.switchSession(sessionId);
115
+ });
116
+
117
+ expect(result.current.activeId).toBe(sessionId);
118
+ expect(mockRouterPush).toHaveBeenCalledWith(
119
+ SESSION_CHAT_URL(sessionId, result.current.isMobile),
120
+ );
121
+ });
122
+
123
+ it('should switch to the inbox session id if none is provided', async () => {
124
+ const { result } = renderHook(() => useSessionStore());
125
+
126
+ act(() => {
127
+ result.current.switchSession();
128
+ });
129
+
130
+ expect(result.current.activeId).toBe(INBOX_SESSION_ID);
131
+ expect(mockRouterPush).toHaveBeenCalledWith(
132
+ SESSION_CHAT_URL(INBOX_SESSION_ID, result.current.isMobile),
133
+ );
134
+ });
135
+ });
136
+ });
@@ -1,3 +1,5 @@
1
+ import { message } from 'antd';
2
+ import { t } from 'i18next';
1
3
  import useSWR, { SWRResponse, mutate } from 'swr';
2
4
  import { DeepPartial } from 'utility-types';
3
5
  import { StateCreator } from 'zustand/vanilla';
@@ -17,7 +19,9 @@ import {
17
19
  import { merge } from '@/utils/merge';
18
20
  import { setNamespace } from '@/utils/storeDebug';
19
21
 
22
+ import { agentSelectors } from '../agent/selectors';
20
23
  import { initLobeSession } from './initialState';
24
+ import { sessionSelectors } from './selectors';
21
25
 
22
26
  const n = setNamespace('session');
23
27
 
@@ -39,7 +43,7 @@ export interface SessionAction {
39
43
  * @returns sessionId
40
44
  */
41
45
  createSession: (agent?: DeepPartial<LobeAgentSettings>) => Promise<string>;
42
-
46
+ duplicateSession: (id: string) => Promise<void>;
43
47
  /**
44
48
  * Pins or unpins a session.
45
49
  *
@@ -48,20 +52,16 @@ export interface SessionAction {
48
52
  * @returns {Promise<void>} A promise that resolves when the session is successfully pinned or unpinned.
49
53
  */
50
54
  pinSession: (id: string, pinned?: boolean) => Promise<void>;
51
-
52
55
  /**
53
56
  * re-fetch the data
54
57
  */
55
58
  refreshSessions: () => Promise<void>;
56
-
57
59
  /**
58
60
  * remove session
59
61
  * @param id - sessionId
60
62
  */
61
63
  removeSession: (id: string) => void;
62
-
63
64
  switchBackToChat: () => void;
64
-
65
65
  /**
66
66
  * switch session url
67
67
  */
@@ -70,7 +70,6 @@ export interface SessionAction {
70
70
  * A custom hook that uses SWR to fetch sessions data.
71
71
  */
72
72
  useFetchSessions: () => SWRResponse<any>;
73
-
74
73
  useSearchSessions: (keyword?: string) => SWRResponse<any>;
75
74
  }
76
75
 
@@ -109,6 +108,27 @@ export const createSessionSlice: StateCreator<
109
108
  return id;
110
109
  },
111
110
 
111
+ duplicateSession: async (id) => {
112
+ const { switchSession, refreshSessions } = get();
113
+ const session = sessionSelectors.getSessionById(id)(get());
114
+
115
+ if (!session) return;
116
+ const title = agentSelectors.getTitle(session.meta);
117
+
118
+ const newTitle = t('duplicateTitle', { ns: 'chat', title: title });
119
+
120
+ const newId = await sessionService.duplicateSession(id, newTitle);
121
+
122
+ // duplicate Session Error
123
+ if (!newId) {
124
+ message.error('复制失败');
125
+ return;
126
+ }
127
+
128
+ await refreshSessions();
129
+ switchSession(newId);
130
+ },
131
+
112
132
  pinSession: async (sessionId, pinned) => {
113
133
  await sessionService.updateSessionGroup(sessionId, pinned ? 'pinned' : 'default');
114
134
 
@@ -2,6 +2,7 @@ import { BaseDataModel } from '@/types/meta';
2
2
 
3
3
  export interface ChatTopic extends Omit<BaseDataModel, 'meta'> {
4
4
  favorite?: boolean;
5
+ sessionId?: string;
5
6
  title: string;
6
7
  }
7
8
 
@@ -0,0 +1,131 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { ErrorResponse, ErrorType } from '@/types/fetch';
4
+
5
+ import { fetchAIFactory, fetchSSE, getMessageError } from './fetch';
6
+
7
+ // 模拟 i18next
8
+ vi.mock('i18next', () => ({
9
+ t: vi.fn((key) => `translated_${key}`),
10
+ }));
11
+
12
+ // 模拟 Response
13
+ const createMockResponse = (body: any, ok: boolean, status: number = 200) => ({
14
+ ok,
15
+ status,
16
+ json: vi.fn(async () => body),
17
+ clone: vi.fn(function () {
18
+ // @ts-ignore
19
+ return this;
20
+ }),
21
+ text: vi.fn(async () => JSON.stringify(body)),
22
+ body: {
23
+ getReader: () => {
24
+ let done = false;
25
+ return {
26
+ read: () => {
27
+ if (!done) {
28
+ done = true;
29
+ return Promise.resolve({
30
+ value: new TextEncoder().encode(JSON.stringify(body)),
31
+ done: false,
32
+ });
33
+ } else {
34
+ return Promise.resolve({ done: true });
35
+ }
36
+ },
37
+ };
38
+ },
39
+ },
40
+ });
41
+
42
+ // 在每次测试后清理所有模拟
43
+ afterEach(() => {
44
+ vi.restoreAllMocks();
45
+ });
46
+
47
+ describe('getMessageError', () => {
48
+ it('should handle business error correctly', async () => {
49
+ const mockErrorResponse: ErrorResponse = {
50
+ body: 'Error occurred',
51
+ errorType: 'InvalidAccessCode',
52
+ };
53
+ const mockResponse = createMockResponse(mockErrorResponse, false, 400);
54
+
55
+ const error = await getMessageError(mockResponse as any);
56
+
57
+ expect(error).toEqual({
58
+ body: mockErrorResponse.body,
59
+ message: 'translated_response.InvalidAccessCode',
60
+ type: mockErrorResponse.errorType,
61
+ });
62
+ expect(mockResponse.json).toHaveBeenCalled();
63
+ });
64
+
65
+ it('should handle regular error correctly', async () => {
66
+ const mockResponse = createMockResponse({}, false, 500);
67
+ mockResponse.json.mockImplementationOnce(() => {
68
+ throw new Error('Failed to parse');
69
+ });
70
+
71
+ const error = await getMessageError(mockResponse as any);
72
+
73
+ expect(error).toEqual({
74
+ message: 'translated_response.500',
75
+ type: 500,
76
+ });
77
+ expect(mockResponse.json).toHaveBeenCalled();
78
+ });
79
+ });
80
+
81
+ describe('fetchAIFactory', () => {
82
+ it('should handle successful response', async () => {
83
+ const fetcher = async (params: any, options: any) =>
84
+ new Response('AI response', { status: 200 });
85
+ const params = {
86
+ /* mock params */
87
+ };
88
+ const onMessageHandle = vi.fn();
89
+ const onFinish = vi.fn();
90
+ const onError = vi.fn();
91
+ const onLoadingChange = vi.fn();
92
+ const abortController = new AbortController();
93
+
94
+ const fetchAIFn = fetchAIFactory(fetcher);
95
+ const result = await fetchAIFn({
96
+ params,
97
+ onMessageHandle,
98
+ onFinish,
99
+ onError,
100
+ onLoadingChange,
101
+ abortController,
102
+ });
103
+
104
+ expect(result).toBe('AI response');
105
+ expect(onMessageHandle).toHaveBeenCalled();
106
+ expect(onFinish).toHaveBeenCalled();
107
+ expect(onError).not.toHaveBeenCalled();
108
+ expect(onLoadingChange).toHaveBeenCalledTimes(2);
109
+ });
110
+
111
+ it('should handle error response', async () => {
112
+ const fetcher = async (params: any, options: any) =>
113
+ new Response(null, { status: 404, statusText: 'Not Found' });
114
+ const params = {
115
+ /* mock params */
116
+ };
117
+ const onError = vi.fn();
118
+ const onLoadingChange = vi.fn();
119
+ const abortController = new AbortController();
120
+
121
+ const fetchAIFn = fetchAIFactory(fetcher);
122
+ await fetchAIFn({ params, onError, onLoadingChange, abortController });
123
+
124
+ expect(onError).toHaveBeenCalledWith(expect.any(Error), {
125
+ message: 'translated_response.404',
126
+ type: 404,
127
+ });
128
+ expect(onLoadingChange).toHaveBeenCalledTimes(3);
129
+ expect(onLoadingChange.mock.lastCall).toEqual([false]);
130
+ });
131
+ });