UNPKG

@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.

592 lines (519 loc) 20.5 kB
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */ // Disable the auto sort key eslint rule to make the code more logic and readable import { DEFAULT_AGENT_CHAT_CONFIG, INBOX_SESSION_ID, isDesktop } from '@lobechat/const'; import { knowledgeBaseQAPrompts } from '@lobechat/prompts'; import { ChatImageItem, ChatTopic, ChatVideoItem, MessageSemanticSearchChunk, SendMessageParams, SendMessageServerResponse, TraceNameMap, UIChatMessage, } from '@lobechat/types'; import { TRPCClientError } from '@trpc/client'; import { t } from 'i18next'; import { produce } from 'immer'; import { StateCreator } from 'zustand/vanilla'; import { aiChatService } from '@/services/aiChat'; import { chatService } from '@/services/chat'; import { messageService } from '@/services/message'; import { getAgentStoreState } from '@/store/agent'; import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/slices/chat'; import { aiModelSelectors, aiProviderSelectors, getAiInfraStoreState } from '@/store/aiInfra'; import { MainSendMessageOperation } from '@/store/chat/slices/aiChat/initialState'; import type { ChatStore } from '@/store/chat/store'; import { getFileStoreState } from '@/store/file/store'; import { getSessionStoreState } from '@/store/session'; import { WebBrowsingManifest } from '@/tools/web-browsing'; import { setNamespace } from '@/utils/storeDebug'; import { chatSelectors, topicSelectors } from '../../../selectors'; import { messageMapKey } from '../../../utils/messageMapKey'; const n = setNamespace('ai'); export interface AIGenerateV2Action { /** * Sends a new message to the AI chat system */ sendMessageInServer: (params: SendMessageParams) => Promise<void>; /** * Cancels sendMessageInServer operation for a specific topic/session */ cancelSendMessageInServer: (topicId?: string) => void; clearSendMessageError: () => void; internal_refreshAiChat: (params: { topics?: ChatTopic[]; messages: UIChatMessage[]; sessionId: string; topicId?: string; }) => void; /** * Executes the core processing logic for AI messages * including preprocessing and postprocessing steps */ internal_execAgentRuntime: (params: { messages: UIChatMessage[]; userMessageId: string; assistantMessageId: string; isWelcomeQuestion?: boolean; inSearchWorkflow?: boolean; /** * the RAG query content, should be embedding and used in the semantic search */ ragQuery?: string; threadId?: string; inPortalThread?: boolean; traceId?: string; }) => Promise<void>; /** * Toggle sendMessageInServer operation state */ internal_toggleSendMessageOperation: ( key: string | { sessionId: string; topicId?: string | null }, loading: boolean, cancelReason?: string, ) => AbortController | undefined; internal_updateSendMessageOperation: ( key: string | { sessionId: string; topicId?: string | null }, value: Partial<MainSendMessageOperation> | null, actionName?: any, ) => void; } export const generateAIChatV2: StateCreator< ChatStore, [['zustand/devtools', never]], [], AIGenerateV2Action > = (set, get) => ({ sendMessageInServer: async ({ message, files, onlyAddUserMessage, isWelcomeQuestion }) => { const { activeTopicId, activeId, activeThreadId, internal_execAgentRuntime, mainInputEditor } = get(); if (!activeId) return; const fileIdList = files?.map((f) => f.id); const hasFile = !!fileIdList && fileIdList.length > 0; // if message is empty or no files, then stop if (!message && !hasFile) return; if (onlyAddUserMessage) { await get().addUserMessage({ message, fileList: fileIdList }); return; } const messages = chatSelectors.activeBaseChats(get()); const chatConfig = agentChatConfigSelectors.currentChatConfig(getAgentStoreState()); const autoCreateThreshold = chatConfig.autoCreateTopicThreshold ?? DEFAULT_AGENT_CHAT_CONFIG.autoCreateTopicThreshold; const shouldCreateNewTopic = !activeTopicId && !!chatConfig.enableAutoCreateTopic && messages.length + 2 >= autoCreateThreshold; // 构造服务端模式临时消息的本地媒体预览(优先使用 S3 URL) const filesInStore = getFileStoreState().chatUploadFileList; const tempImages: ChatImageItem[] = filesInStore .filter((f) => f.file?.type?.startsWith('image')) .map((f) => ({ id: f.id, url: f.fileUrl || f.base64Url || f.previewUrl || '', alt: f.file?.name || f.id, })); const tempVideos: ChatVideoItem[] = filesInStore .filter((f) => f.file?.type?.startsWith('video')) .map((f) => ({ id: f.id, url: f.fileUrl || f.base64Url || f.previewUrl || '', alt: f.file?.name || f.id, })); // use optimistic update to avoid the slow waiting const tempId = get().internal_createTmpMessage({ content: message, // if message has attached with files, then add files to message and the agent files: fileIdList, role: 'user', sessionId: activeId, // if there is activeTopicId,then add topicId to message topicId: activeTopicId, threadId: activeThreadId, imageList: tempImages.length > 0 ? tempImages : undefined, videoList: tempVideos.length > 0 ? tempVideos : undefined, }); get().internal_toggleMessageLoading(true, tempId); const operationKey = messageMapKey(activeId, activeTopicId); // Start tracking sendMessageInServer operation with AbortController const abortController = get().internal_toggleSendMessageOperation(operationKey, true)!; const jsonState = mainInputEditor?.getJSONState(); get().internal_updateSendMessageOperation( operationKey, { inputSendErrorMsg: undefined, inputEditorTempState: jsonState }, 'creatingMessage/start', ); let data: SendMessageServerResponse | undefined; try { const { model, provider } = agentSelectors.currentAgentConfig(getAgentStoreState()); data = await aiChatService.sendMessageInServer( { newUserMessage: { content: message, files: fileIdList, }, // if there is activeTopicId,then add topicId to message topicId: activeTopicId, threadId: activeThreadId, newTopic: shouldCreateNewTopic ? { topicMessageIds: messages.map((m) => m.id), title: t('defaultTitle', { ns: 'topic' }), } : undefined, sessionId: activeId === INBOX_SESSION_ID ? undefined : activeId, newAssistantMessage: { model, provider: provider! }, }, abortController, ); // refresh the total data get().internal_refreshAiChat({ messages: data.messages, topics: data.topics, sessionId: activeId, topicId: data.topicId, }); if (data.isCreateNewTopic && data.topicId) { await get().switchTopic(data.topicId, true); } } catch (e) { if (e instanceof TRPCClientError) { const isAbort = e.message.includes('aborted') || e.name === 'AbortError'; // Check if error is due to cancellation if (!isAbort) { get().internal_updateSendMessageOperation(operationKey, { inputSendErrorMsg: e.message }); get().mainInputEditor?.setJSONState(jsonState); } } } finally { // Stop tracking sendMessageInServer operation get().internal_toggleSendMessageOperation(operationKey, false); } // remove temporally message if (data?.isCreateNewTopic) { get().internal_dispatchMessage( { type: 'deleteMessage', id: tempId }, { topicId: activeTopicId, sessionId: activeId }, ); } get().internal_toggleMessageLoading(false, tempId); get().internal_updateSendMessageOperation( operationKey, { inputEditorTempState: null }, 'creatingMessage/finished', ); if (!data) return; // update assistant update to make it rerank getSessionStoreState().triggerSessionUpdate(get().activeId); // Get the current messages to generate AI response // remove the latest assistant message id const baseMessages = chatSelectors .activeBaseChats(get()) .filter((item) => item.id !== data.assistantMessageId); if (data.topicId) get().internal_updateTopicLoading(data.topicId, true); const summaryTitle = async () => { // check activeTopic and then auto update topic title if (data.isCreateNewTopic) { await get().summaryTopicTitle(data.topicId, data.messages); return; } if (!data.topicId) return; const topic = topicSelectors.getTopicById(data.topicId)(get()); if (topic && !topic.title) { const chats = chatSelectors.getBaseChatsByKey(messageMapKey(activeId, topic.id))(get()); await get().summaryTopicTitle(topic.id, chats); } }; summaryTitle().catch(console.error); try { await internal_execAgentRuntime({ messages: baseMessages, userMessageId: data.userMessageId, assistantMessageId: data.assistantMessageId, isWelcomeQuestion, ragQuery: get().internal_shouldUseRAG() ? message : undefined, threadId: activeThreadId, }); // // // if there is relative files, then add files to agent // // only available in server mode const userFiles = chatSelectors.currentUserFiles(get()).map((f) => f.id); await getAgentStoreState().addFilesToAgent(userFiles, false); } catch (e) { console.error(e); } finally { if (data.topicId) get().internal_updateTopicLoading(data.topicId, false); } }, cancelSendMessageInServer: (topicId?: string) => { const { activeId, activeTopicId } = get(); // Determine which operation to cancel const targetTopicId = topicId ?? activeTopicId; const operationKey = messageMapKey(activeId, targetTopicId); // Cancel the specific operation get().internal_toggleSendMessageOperation( operationKey, false, 'User cancelled sendMessageInServer operation', ); // Only clear creating message state if it's the active session if (operationKey === messageMapKey(activeId, activeTopicId)) { const editorTempState = get().mainSendMessageOperations[operationKey]?.inputEditorTempState; if (editorTempState) get().mainInputEditor?.setJSONState(editorTempState); } }, clearSendMessageError: () => { get().internal_updateSendMessageOperation( { sessionId: get().activeId, topicId: get().activeTopicId }, null, 'clearSendMessageError', ); }, internal_refreshAiChat: ({ topics, messages, sessionId, topicId }) => { set( { topicMaps: topics ? { ...get().topicMaps, [sessionId]: topics } : get().topicMaps, messagesMap: { ...get().messagesMap, [messageMapKey(sessionId, topicId)]: messages }, }, false, 'refreshAiChat', ); }, internal_execAgentRuntime: async (params) => { const { assistantMessageId: assistantId, userMessageId, ragQuery, messages: originalMessages, } = params; const { internal_fetchAIChatMessage, triggerToolCalls, refreshMessages, internal_updateMessageRAG, } = get(); // create a new array to avoid the original messages array change const messages = [...originalMessages]; const agentStoreState = getAgentStoreState(); const { model, provider, chatConfig } = agentSelectors.currentAgentConfig(agentStoreState); let fileChunks: MessageSemanticSearchChunk[] | undefined; let ragQueryId; // go into RAG flow if there is ragQuery flag if (ragQuery) { // 1. get the relative chunks from semantic search const { chunks, queryId, rewriteQuery } = await get().internal_retrieveChunks( userMessageId, ragQuery, // should skip the last content messages.map((m) => m.content).slice(0, messages.length - 1), ); ragQueryId = queryId; const lastMsg = messages.pop() as UIChatMessage; // 2. build the retrieve context messages const knowledgeBaseQAContext = knowledgeBaseQAPrompts({ chunks, userQuery: lastMsg.content, rewriteQuery, knowledge: agentSelectors.currentEnabledKnowledge(agentStoreState), }); // 3. add the retrieve context messages to the messages history messages.push({ ...lastMsg, content: (lastMsg.content + '\n\n' + knowledgeBaseQAContext).trim(), }); fileChunks = chunks.map((c) => ({ id: c.id, similarity: c.similarity })); if (fileChunks.length > 0) { await internal_updateMessageRAG(assistantId, { ragQueryId, fileChunks }); } } // 3. place a search with the search working model if this model is not support tool use const aiInfraStoreState = getAiInfraStoreState(); const isModelSupportToolUse = aiModelSelectors.isModelSupportToolUse( model, provider!, )(aiInfraStoreState); const isProviderHasBuiltinSearch = aiProviderSelectors.isProviderHasBuiltinSearch(provider!)( aiInfraStoreState, ); const isModelHasBuiltinSearch = aiModelSelectors.isModelHasBuiltinSearch( model, provider!, )(aiInfraStoreState); const isModelBuiltinSearchInternal = aiModelSelectors.isModelBuiltinSearchInternal( model, provider!, )(aiInfraStoreState); const useModelBuiltinSearch = agentChatConfigSelectors.useModelBuiltinSearch(agentStoreState); const useModelSearch = ((isProviderHasBuiltinSearch || isModelHasBuiltinSearch) && useModelBuiltinSearch) || isModelBuiltinSearchInternal; const isAgentEnableSearch = agentChatConfigSelectors.isAgentEnableSearch(agentStoreState); if (isAgentEnableSearch && !useModelSearch && !isModelSupportToolUse) { const { model, provider } = agentChatConfigSelectors.searchFCModel(agentStoreState); let isToolsCalling = false; let isError = false; const abortController = get().internal_toggleChatLoading( true, assistantId, n('generateMessage(start)', { messageId: assistantId, messages }), ); get().internal_toggleSearchWorkflow(true, assistantId); await chatService.fetchPresetTaskResult({ params: { messages, model, provider, plugins: [WebBrowsingManifest.identifier] }, onFinish: async (_, { toolCalls, usage }) => { if (toolCalls && toolCalls.length > 0) { get().internal_toggleToolCallingStreaming(assistantId, undefined); // update tools calling await get().internal_updateMessageContent(assistantId, '', { toolCalls, metadata: usage, model, provider, }); } }, trace: { traceId: params.traceId, sessionId: get().activeId, topicId: get().activeTopicId, traceName: TraceNameMap.SearchIntentRecognition, }, abortController, onMessageHandle: async (chunk) => { if (chunk.type === 'tool_calls') { get().internal_toggleSearchWorkflow(false, assistantId); get().internal_toggleToolCallingStreaming(assistantId, chunk.isAnimationActives); get().internal_dispatchMessage({ id: assistantId, type: 'updateMessage', value: { tools: get().internal_transformToolCalls(chunk.tool_calls) }, }); isToolsCalling = true; } if (chunk.type === 'text') { abortController!.abort('not fc'); } }, onErrorHandle: async (error) => { isError = true; await messageService.updateMessageError(assistantId, error); await refreshMessages(); }, }); get().internal_toggleChatLoading( false, assistantId, n('generateMessage(start)', { messageId: assistantId, messages }), ); get().internal_toggleSearchWorkflow(false, assistantId); // if there is error, then stop if (isError) return; // if it's the function call message, trigger the function method if (isToolsCalling) { get().internal_toggleMessageInToolsCalling(true, assistantId); await refreshMessages(); await triggerToolCalls(assistantId, { threadId: params?.threadId, inPortalThread: params?.inPortalThread, }); // then story the workflow return; } } // 4. fetch the AI response const { isFunctionCall, content } = await internal_fetchAIChatMessage({ messages, messageId: assistantId, params, model, provider: provider!, }); // 5. if it's the function call message, trigger the function method if (isFunctionCall) { get().internal_toggleMessageInToolsCalling(true, assistantId); await refreshMessages(); await triggerToolCalls(assistantId, { threadId: params?.threadId, inPortalThread: params?.inPortalThread, }); } else { // 显示桌面通知(仅在桌面端且窗口隐藏时) if (isDesktop) { try { // 动态导入桌面通知服务,避免在非桌面端环境中导入 const { desktopNotificationService } = await import( '@/services/electron/desktopNotification' ); await desktopNotificationService.showNotification({ body: content, title: t('notification.finishChatGeneration', { ns: 'electron' }), }); } catch (error) { // 静默处理错误,不影响正常流程 console.error('Desktop notification error:', error); } } } // 6. summary history if context messages is larger than historyCount const historyCount = agentChatConfigSelectors.historyCount(agentStoreState); if ( agentChatConfigSelectors.enableHistoryCount(agentStoreState) && chatConfig.enableCompressHistory && originalMessages.length > historyCount ) { // after generation: [u1,a1,u2,a2,u3,a3] // but the `originalMessages` is still: [u1,a1,u2,a2,u3] // So if historyCount=2, we need to summary [u1,a1,u2,a2] // because user find UI is [u1,a1,u2,a2 | u3,a3] const historyMessages = originalMessages.slice(0, -historyCount + 1); await get().internal_summaryHistory(historyMessages); } }, internal_updateSendMessageOperation: (key, value, actionName) => { const operationKey = typeof key === 'string' ? key : messageMapKey(key.sessionId, key.topicId); set( produce((draft) => { if (!draft.mainSendMessageOperations[operationKey]) draft.mainSendMessageOperations[operationKey] = value; else { if (value === null) { delete draft.mainSendMessageOperations[operationKey]; } else { draft.mainSendMessageOperations[operationKey] = { ...draft.mainSendMessageOperations[operationKey], ...value, }; } } }), false, actionName ?? n('updateSendMessageOperation', { operationKey, value }), ); }, internal_toggleSendMessageOperation: (key, loading: boolean, cancelReason?: string) => { if (loading) { const abortController = new AbortController(); get().internal_updateSendMessageOperation( key, { isLoading: true, abortController }, n('toggleSendMessageOperation(start)', { key }), ); return abortController; } else { const operationKey = typeof key === 'string' ? key : messageMapKey(key.sessionId, key.topicId); const operation = get().mainSendMessageOperations[operationKey]; // If cancelReason is provided, abort the operation first if (cancelReason && operation?.isLoading) { operation.abortController?.abort(cancelReason); } get().internal_updateSendMessageOperation( key, { isLoading: false, abortController: null }, n('toggleSendMessageOperation(stop)', { key, cancelReason }), ); return undefined; } }, });