@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.
- package/CHANGELOG.md +61 -0
- package/README.md +8 -8
- package/README.zh-CN.md +8 -8
- package/docs/Home.md +1 -1
- package/docs/_Sidebar.md +1 -1
- package/locales/de-DE/chat.json +10 -0
- package/locales/de-DE/common.json +1 -0
- package/locales/de-DE/error.json +4 -4
- package/locales/en-US/chat.json +10 -0
- package/locales/en-US/common.json +1 -0
- package/locales/en-US/error.json +1 -1
- package/locales/es-ES/chat.json +10 -0
- package/locales/es-ES/common.json +1 -0
- package/locales/fr-FR/chat.json +10 -0
- package/locales/fr-FR/common.json +1 -0
- package/locales/fr-FR/error.json +1 -2
- package/locales/it-IT/chat.json +10 -0
- package/locales/it-IT/common.json +1 -0
- package/locales/it-IT/error.json +5 -5
- package/locales/ja-JP/chat.json +10 -0
- package/locales/ja-JP/common.json +1 -0
- package/locales/ko-KR/chat.json +10 -0
- package/locales/ko-KR/common.json +1 -0
- package/locales/ko-KR/error.json +4 -4
- package/locales/nl-NL/chat.json +10 -0
- package/locales/nl-NL/common.json +1 -0
- package/locales/pl-PL/chat.json +10 -0
- package/locales/pl-PL/common.json +1 -0
- package/locales/pl-PL/error.json +2 -3
- package/locales/pt-BR/chat.json +10 -0
- package/locales/pt-BR/common.json +1 -0
- package/locales/ru-RU/chat.json +10 -0
- package/locales/ru-RU/common.json +1 -0
- package/locales/ru-RU/error.json +3 -3
- package/locales/tr-TR/chat.json +10 -0
- package/locales/tr-TR/common.json +1 -0
- package/locales/vi-VN/chat.json +10 -0
- package/locales/vi-VN/common.json +1 -0
- package/locales/vi-VN/error.json +1 -1
- package/locales/zh-CN/chat.json +10 -0
- package/locales/zh-CN/common.json +1 -0
- package/locales/zh-TW/chat.json +10 -0
- package/locales/zh-TW/common.json +1 -0
- package/package.json +6 -6
- package/src/app/chat/(desktop)/features/Conversation.tsx +17 -0
- package/src/app/chat/(desktop)/features/HotKeys.tsx +44 -0
- package/src/app/chat/(desktop)/index.tsx +2 -3
- package/src/app/chat/features/ChatHeader/ShareButton/Preview.tsx +1 -1
- package/src/app/chat/features/SessionListContent/List/Item/Actions.tsx +24 -3
- package/src/app/chat/features/TopicListContent/Topic/TopicContent.tsx +43 -2
- package/src/database/models/__tests__/message.test.ts +48 -0
- package/src/database/models/__tests__/topic.test.ts +135 -0
- package/src/database/models/message.ts +35 -0
- package/src/database/models/session.ts +11 -1
- package/src/database/models/topic.ts +34 -2
- package/src/features/ChatInput/ActionBar/Clear.tsx +0 -6
- package/src/features/Conversation/Actions/Assistant.tsx +16 -5
- package/src/features/Conversation/Actions/Error.tsx +2 -2
- package/src/features/Conversation/Actions/Fallback.tsx +4 -3
- package/src/features/Conversation/Actions/Function.tsx +3 -3
- package/src/features/Conversation/Actions/User.tsx +3 -3
- package/src/features/Conversation/Actions/customAction.ts +2 -4
- package/src/features/Conversation/Actions/index.ts +50 -45
- package/src/features/Conversation/Error/InvalidAccess.tsx +1 -1
- package/src/features/Conversation/Error/OpenAPIKey.tsx +1 -1
- package/src/features/Conversation/Error/OpenAiBizError.tsx +1 -1
- package/src/features/Conversation/Error/Plugin/PluginError.tsx +1 -1
- package/src/features/Conversation/Error/Plugin/PluginSettings.tsx +1 -1
- package/src/features/Conversation/Error/index.tsx +2 -2
- package/src/features/Conversation/Extras/Assistant.tsx +32 -29
- package/src/features/Conversation/Extras/User.tsx +2 -1
- package/src/features/Conversation/Extras/index.ts +4 -4
- package/src/features/Conversation/Messages/index.ts +3 -3
- package/src/features/Conversation/components/ChatItem/ActionsBar.tsx +58 -0
- package/src/features/Conversation/components/ChatItem/index.tsx +126 -0
- package/src/features/Conversation/components/ChatList/HistoryDivider.tsx +6 -3
- package/src/features/Conversation/components/ChatList/index.tsx +35 -79
- package/src/features/Conversation/hooks/useChatListActionsBar.tsx +43 -31
- package/src/features/Conversation/hooks/useInitConversation.ts +4 -1
- package/src/features/Conversation/index.tsx +4 -3
- package/src/features/Conversation/types/index.tsx +35 -0
- package/src/features/PluginStore/PluginItem/EditCustomPlugin.tsx +7 -2
- package/src/locales/default/chat.ts +10 -1
- package/src/locales/default/common.ts +1 -0
- package/src/services/config.ts +22 -0
- package/src/services/session.ts +6 -0
- package/src/services/topic.ts +4 -0
- package/src/store/chat/slices/message/action.ts +89 -2
- package/src/store/chat/slices/message/selectors.ts +10 -0
- package/src/store/chat/slices/topic/action.ts +22 -0
- package/src/store/global/slices/common/action.ts +2 -0
- package/src/store/session/slices/session/action.test.ts +136 -0
- package/src/store/session/slices/session/action.ts +26 -6
- package/src/types/topic.ts +1 -0
- package/src/utils/fetch.test.ts +131 -0
- package/src/utils/fetch.ts +26 -10
- package/src/features/Conversation/components/ChatList/ActionsBar.tsx +0 -27
- package/src/features/Conversation/components/ChatList/Item.tsx +0 -236
- package/src/features/Conversation/container/index.tsx +0 -83
- /package/docs/Development/{Steup-Development.md → Setup-Development.md} +0 -0
- /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: '即将删除未收藏话题,删除后将不可恢复,请谨慎操作。',
|
package/src/services/config.ts
CHANGED
|
@@ -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;
|
package/src/services/session.ts
CHANGED
|
@@ -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();
|
package/src/services/topic.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/types/topic.ts
CHANGED
|
@@ -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
|
+
});
|