@lobehub/chat
Version:
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.
432 lines (363 loc) • 14.6 kB
text/typescript
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
// Note: To make the code more logic and readable, we just disable the auto sort key eslint rule
// DON'T REMOVE THE FIRST LINE
import { chainSummaryTitle } from '@lobechat/prompts';
import { TraceNameMap, UIChatMessage } from '@lobechat/types';
import isEqual from 'fast-deep-equal';
import { t } from 'i18next';
import { produce } from 'immer';
import useSWR, { SWRResponse, mutate } from 'swr';
import { StateCreator } from 'zustand/vanilla';
import { message } from '@/components/AntdStaticMethods';
import { LOADING_FLAT } from '@/const/message';
import { useClientDataSWR } from '@/libs/swr';
import { chatService } from '@/services/chat';
import { messageService } from '@/services/message';
import { topicService } from '@/services/topic';
import { CreateTopicParams } from '@/services/topic/type';
import type { ChatStore } from '@/store/chat';
import type { ChatStoreState } from '@/store/chat/initialState';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import { globalHelpers } from '@/store/global/helpers';
import { useSessionStore } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
import { useUserStore } from '@/store/user';
import { systemAgentSelectors } from '@/store/user/selectors';
import { ChatTopic } from '@/types/topic';
import { merge } from '@/utils/merge';
import { setNamespace } from '@/utils/storeDebug';
import { chatSelectors } from '../message/selectors';
import { ChatTopicDispatch, topicReducer } from './reducer';
import { topicSelectors } from './selectors';
const n = setNamespace('t');
const SWR_USE_FETCH_TOPIC = 'SWR_USE_FETCH_TOPIC';
const SWR_USE_SEARCH_TOPIC = 'SWR_USE_SEARCH_TOPIC';
export interface ChatTopicAction {
favoriteTopic: (id: string, favState: boolean) => Promise<void>;
openNewTopicOrSaveTopic: () => Promise<void>;
refreshTopic: () => Promise<void>;
removeAllTopics: () => Promise<void>;
removeSessionTopics: () => Promise<void>;
removeGroupTopics: (groupId: string) => Promise<void>;
removeTopic: (id: string) => Promise<void>;
removeUnstarredTopic: () => Promise<void>;
saveToTopic: (sessionId?: string, groupId?: string) => Promise<string | undefined>;
createTopic: (sessionId?: string, groupId?: string) => Promise<string | undefined>;
autoRenameTopicTitle: (id: string) => Promise<void>;
duplicateTopic: (id: string) => Promise<void>;
summaryTopicTitle: (topicId: string, messages: UIChatMessage[]) => Promise<void>;
switchTopic: (id?: string, skipRefreshMessage?: boolean) => Promise<void>;
updateTopicTitle: (id: string, title: string) => Promise<void>;
useFetchTopics: (
enable: boolean,
sessionId?: string,
groupId?: string,
) => SWRResponse<ChatTopic[]>;
useSearchTopics: (
keywords?: string,
sessionId?: string,
groupId?: string,
) => SWRResponse<ChatTopic[]>;
internal_updateTopicTitleInSummary: (id: string, title: string) => void;
internal_updateTopicLoading: (id: string, loading: boolean) => void;
internal_createTopic: (params: CreateTopicParams) => Promise<string>;
internal_updateTopic: (id: string, data: Partial<ChatTopic>) => Promise<void>;
internal_dispatchTopic: (payload: ChatTopicDispatch, action?: any) => void;
}
export const chatTopic: StateCreator<
ChatStore,
[['zustand/devtools', never]],
[],
ChatTopicAction
> = (set, get) => ({
// create
openNewTopicOrSaveTopic: async () => {
const { switchTopic, saveToTopic, refreshMessages, activeTopicId } = get();
const hasTopic = !!activeTopicId;
if (hasTopic) switchTopic();
else {
await saveToTopic();
refreshMessages();
}
},
createTopic: async (sessionId, groupId) => {
const { activeId, activeSessionType, internal_createTopic } = get();
const messages = chatSelectors.activeBaseChats(get());
set({ creatingTopic: true }, false, n('creatingTopic/start'));
const topicId = await internal_createTopic({
title: t('defaultTitle', { ns: 'topic' }),
messages: messages.map((m) => m.id),
...(activeSessionType === 'group'
? { groupId: groupId || activeId }
: { sessionId: sessionId || activeId }),
});
set({ creatingTopic: false }, false, n('creatingTopic/end'));
return topicId;
},
saveToTopic: async (sessionId, groupId) => {
// if there is no message, stop
const messages = chatSelectors.activeBaseChats(get());
if (messages.length === 0) return;
const { activeId, activeSessionType, summaryTopicTitle, internal_createTopic } = get();
// 1. create topic and bind these messages
const topicId = await internal_createTopic({
title: t('defaultTitle', { ns: 'topic' }),
messages: messages.map((m) => m.id),
...(activeSessionType === 'group'
? { groupId: groupId || activeId }
: { sessionId: sessionId || activeId }),
});
get().internal_updateTopicLoading(topicId, true);
// 2. auto summary topic Title
// we don't need to wait for summary, just let it run async
summaryTopicTitle(topicId, messages);
// Clear supervisor todos for temporary topic in current container after saving
try {
const { activeId, activeSessionType } = get();
let isGroupSession = activeSessionType === 'group';
if (activeSessionType === undefined) {
const sessionStore = useSessionStore.getState();
isGroupSession = sessionSelectors.isCurrentSessionGroupSession(sessionStore);
}
if (isGroupSession) {
set(
produce((state: ChatStoreState) => {
state.supervisorTodos[messageMapKey(groupId || activeId, null)] = [];
}),
false,
n('resetSupervisorTodosOnSaveToTopic', { groupId: groupId || activeId }),
);
}
} catch (error) {
if (process.env.NODE_ENV !== 'production') {
console.error('Failed to reset supervisor todos on save to topic:', error);
}
}
return topicId;
},
duplicateTopic: async (id) => {
const { refreshTopic, switchTopic } = get();
const topic = topicSelectors.getTopicById(id)(get());
if (!topic) return;
const newTitle = t('duplicateTitle', { ns: 'chat', title: topic?.title });
message.loading({
content: t('duplicateLoading', { ns: 'topic' }),
key: 'duplicateTopic',
duration: 0,
});
const newTopicId = await topicService.cloneTopic(id, newTitle);
await refreshTopic();
message.destroy('duplicateTopic');
message.success(t('duplicateSuccess', { ns: 'topic' }));
await switchTopic(newTopicId);
},
// update
summaryTopicTitle: async (topicId, messages) => {
const { internal_updateTopicTitleInSummary, internal_updateTopicLoading } = get();
const topic = topicSelectors.getTopicById(topicId)(get());
if (!topic) return;
internal_updateTopicTitleInSummary(topicId, LOADING_FLAT);
let output = '';
// Get current agent for topic
const topicConfig = systemAgentSelectors.topic(useUserStore.getState());
// Automatically summarize the topic title
await chatService.fetchPresetTaskResult({
onError: () => {
internal_updateTopicTitleInSummary(topicId, topic.title);
},
onFinish: async (text) => {
await get().internal_updateTopic(topicId, { title: text });
},
onLoadingChange: (loading) => {
internal_updateTopicLoading(topicId, loading);
},
onMessageHandle: (chunk) => {
switch (chunk.type) {
case 'text': {
output += chunk.text;
}
}
internal_updateTopicTitleInSummary(topicId, output);
},
params: merge(topicConfig, chainSummaryTitle(messages, globalHelpers.getCurrentLanguage())),
trace: get().getCurrentTracePayload({ traceName: TraceNameMap.SummaryTopicTitle, topicId }),
});
},
favoriteTopic: async (id, favorite) => {
await get().internal_updateTopic(id, { favorite });
},
updateTopicTitle: async (id, title) => {
await get().internal_updateTopic(id, { title });
},
autoRenameTopicTitle: async (id) => {
const { activeId: sessionId, summaryTopicTitle, internal_updateTopicLoading } = get();
internal_updateTopicLoading(id, true);
const messages = await messageService.getMessages(sessionId, id);
await summaryTopicTitle(id, messages);
internal_updateTopicLoading(id, false);
},
// query
useFetchTopics: (enable, containerId) =>
useClientDataSWR<ChatTopic[]>(
enable ? [SWR_USE_FETCH_TOPIC, containerId] : null,
async ([, containerId]: [string, string | undefined]) =>
topicService.getTopics({ containerId }),
{
onSuccess: (topics) => {
if (!containerId) return;
const nextMap = { ...get().topicMaps, [containerId]: topics };
// no need to update map if the topics have been init and the map is the same
if (get().topicsInit && isEqual(nextMap, get().topicMaps)) return;
set(
{ topicMaps: nextMap, topicsInit: true },
false,
n('useFetchTopics(success)', { containerId }),
);
},
},
),
useSearchTopics: (keywords, sessionId, groupId) =>
useSWR<ChatTopic[]>(
[SWR_USE_SEARCH_TOPIC, keywords, sessionId, groupId],
([, keywords, sessionId, groupId]: [
string,
string,
string | undefined,
string | undefined,
]) => topicService.searchTopics(keywords, sessionId, groupId),
{
onSuccess: (data) => {
set(
{ searchTopics: data, isSearchingTopic: false },
false,
n('useSearchTopics(success)', { keywords }),
);
},
},
),
switchTopic: async (id, skipRefreshMessage) => {
set(
{ activeTopicId: !id ? (null as any) : id, activeThreadId: undefined },
false,
n('toggleTopic'),
);
// Reset supervisor todos when switching topics in group chats
try {
const { activeId, activeSessionType, internal_cancelSupervisorDecision } = get();
// Determine group session robustly (cached flag or from session store)
let isGroupSession = activeSessionType === 'group';
if (activeSessionType === undefined) {
const sessionStore = useSessionStore.getState();
isGroupSession = sessionSelectors.isCurrentSessionGroupSession(sessionStore);
}
if (isGroupSession) {
const newKey = messageMapKey(activeId, id ?? null);
set(
produce((state: ChatStoreState) => {
state.supervisorTodos[newKey] = [];
}),
false,
n('resetSupervisorTodosOnTopicSwitch', { groupId: activeId, topicId: id ?? null }),
);
// Also cancel any pending supervisor decisions tied to this group
internal_cancelSupervisorDecision?.(activeId);
}
} catch {
// no-op: resetting todos should not block topic switching
}
if (skipRefreshMessage) return;
await get().refreshMessages();
},
// delete
removeSessionTopics: async () => {
const { switchTopic, activeId, refreshTopic } = get();
await topicService.removeTopics(activeId);
await refreshTopic();
// switch to default topic
switchTopic();
},
removeGroupTopics: async (groupId: string) => {
const { switchTopic, refreshTopic } = get();
// Get topics for this specific group from the topic map
const groupTopics = get().topicMaps[groupId] || [];
const topicIds = groupTopics.map((t) => t.id);
if (topicIds.length > 0) {
await topicService.batchRemoveTopics(topicIds);
}
await refreshTopic();
// switch to default topic
switchTopic();
},
removeAllTopics: async () => {
const { refreshTopic } = get();
await topicService.removeAllTopic();
await refreshTopic();
},
removeTopic: async (id) => {
const { activeId, activeTopicId, switchTopic, refreshTopic } = get();
// remove messages in the topic
// TODO: Need to remove because server service don't need to call it
await messageService.removeMessagesByAssistant(activeId, id);
// remove topic
await topicService.removeTopic(id);
await refreshTopic();
// switch bach to default topic
if (activeTopicId === id) switchTopic();
},
removeUnstarredTopic: async () => {
const { refreshTopic, switchTopic } = get();
const topics = topicSelectors.currentUnFavTopics(get());
await topicService.batchRemoveTopics(topics.map((t) => t.id));
await refreshTopic();
// 切换到默认 topic
switchTopic();
},
// Internal process method of the topics
internal_updateTopicTitleInSummary: (id, title) => {
get().internal_dispatchTopic(
{ type: 'updateTopic', id, value: { title } },
'updateTopicTitleInSummary',
);
},
refreshTopic: async () => {
return mutate([SWR_USE_FETCH_TOPIC, get().activeId]);
},
internal_updateTopicLoading: (id, loading) => {
set(
(state) => {
if (loading) return { topicLoadingIds: [...state.topicLoadingIds, id] };
return { topicLoadingIds: state.topicLoadingIds.filter((i) => i !== id) };
},
false,
n('updateTopicLoading'),
);
},
internal_updateTopic: async (id, data) => {
get().internal_dispatchTopic({ type: 'updateTopic', id, value: data });
get().internal_updateTopicLoading(id, true);
await topicService.updateTopic(id, data);
await get().refreshTopic();
get().internal_updateTopicLoading(id, false);
},
internal_createTopic: async (params) => {
const tmpId = Date.now().toString();
get().internal_dispatchTopic(
{ type: 'addTopic', value: { ...params, id: tmpId } },
'internal_createTopic',
);
get().internal_updateTopicLoading(tmpId, true);
const topicId = await topicService.createTopic(params);
get().internal_updateTopicLoading(tmpId, false);
get().internal_updateTopicLoading(topicId, true);
await get().refreshTopic();
get().internal_updateTopicLoading(topicId, false);
return topicId;
},
internal_dispatchTopic: (payload, action) => {
const nextTopics = topicReducer(topicSelectors.currentTopics(get()), payload);
const nextMap = { ...get().topicMaps, [get().activeId]: nextTopics };
// no need to update map if is the same
if (isEqual(nextMap, get().topicMaps)) return;
set({ topicMaps: nextMap }, false, action ?? n(`dispatchTopic/${payload.type}`));
},
});