@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.
339 lines (283 loc) • 11.3 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 isEqual from 'fast-deep-equal';
import { t } from 'i18next';
import useSWR, { SWRResponse, mutate } from 'swr';
import { StateCreator } from 'zustand/vanilla';
import { chainSummaryTitle } from '@/chains/summaryTitle';
import { message } from '@/components/AntdStaticMethods';
import { LOADING_FLAT } from '@/const/message';
import { TraceNameMap } from '@/const/trace';
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 { useUserStore } from '@/store/user';
import { systemAgentSelectors } from '@/store/user/selectors';
import { ChatMessage } from '@/types/message';
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>;
removeTopic: (id: string) => Promise<void>;
removeUnstarredTopic: () => Promise<void>;
saveToTopic: () => Promise<string | undefined>;
createTopic: () => Promise<string | undefined>;
autoRenameTopicTitle: (id: string) => Promise<void>;
duplicateTopic: (id: string) => Promise<void>;
summaryTopicTitle: (topicId: string, messages: ChatMessage[]) => Promise<void>;
switchTopic: (id?: string, skipRefreshMessage?: boolean) => Promise<void>;
updateTopicTitle: (id: string, title: string) => Promise<void>;
useFetchTopics: (enable: boolean, sessionId: string) => SWRResponse<ChatTopic[]>;
useSearchTopics: (keywords?: string, sessionId?: 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 () => {
const { activeId, internal_createTopic } = get();
const messages = chatSelectors.activeBaseChats(get());
set({ creatingTopic: true }, false, n('creatingTopic/start'));
const topicId = await internal_createTopic({
sessionId: activeId,
title: t('defaultTitle', { ns: 'topic' }),
messages: messages.map((m) => m.id),
});
set({ creatingTopic: false }, false, n('creatingTopic/end'));
return topicId;
},
saveToTopic: async () => {
// if there is no message, stop
const messages = chatSelectors.activeBaseChats(get());
if (messages.length === 0) return;
const { activeId, summaryTopicTitle, internal_createTopic } = get();
// 1. create topic and bind these messages
const topicId = await internal_createTopic({
sessionId: activeId,
title: t('defaultTitle', { ns: 'topic' }),
messages: messages.map((m) => m.id),
});
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);
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)),
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, sessionId) =>
useClientDataSWR<ChatTopic[]>(
enable ? [SWR_USE_FETCH_TOPIC, sessionId] : null,
async ([, sessionId]: [string, string]) => topicService.getTopics({ sessionId }),
{
suspense: true,
fallbackData: [],
onSuccess: (topics) => {
const nextMap = { ...get().topicMaps, [sessionId]: 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)', { sessionId }),
);
},
},
),
useSearchTopics: (keywords, sessionId) =>
useSWR<ChatTopic[]>(
[SWR_USE_SEARCH_TOPIC, keywords, sessionId],
([, keywords, sessionId]: [string, string, string]) =>
topicService.searchTopics(keywords, sessionId),
{
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'),
);
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();
},
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}`));
},
});