diff --git a/CHANGELOG.md b/CHANGELOG.md index 3259bf1e6070..341ff8791d17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,14 @@ # Changelog ### [Version 1.0.6](https://github.com/bentwnghk/lobe-chat/compare/v1.0.5...v1.0.6) +### [Version 0.118.3](https://github.com/lobehub/lobe-chat/compare/v0.118.2...v0.118.3) Released on **2024-01-01** #### 🐛 Bug Fixes - **misc**: Fix metadata.ts. +- **misc**: Fix parse error of tool calls at end.
@@ -754,6 +756,7 @@ - **misc**: 暂时隐藏 Hero 模板 ([8289ae6](https://github.com/bentwnghk/lobe-chat/commit/8289ae6)) - **misc**: 更新插件文案 ([0411335](https://github.com/bentwnghk/lobe-chat/commit/0411335)) - **misc**: 补充 ChatList 的 Loading 态 ([eb3eb5d](https://github.com/bentwnghk/lobe-chat/commit/eb3eb5d)) +- **misc**: Fix parse error of tool calls at end, closes [#893](https://github.com/lobehub/lobe-chat/issues/893) ([f369b6e](https://github.com/lobehub/lobe-chat/commit/f369b6e)) diff --git a/src/const/message.test.ts b/src/const/message.test.ts new file mode 100644 index 000000000000..331dc9120241 --- /dev/null +++ b/src/const/message.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { testFunctionMessageAtEnd } from './message'; + +describe('testFunctionMessageAtEnd', () => { + it('should extract tool_calls JSON when present', () => { + const content = 'Some content before {"tool_calls": [{"tool": "example", "args": ["arg1"]}]}'; + const result = testFunctionMessageAtEnd(content); + expect(result).toEqual({ + content: '{"tool_calls": [{"tool": "example", "args": ["arg1"]}]}', + valid: true, + }); + }); + + it('should extract tool_calls JSON when there are space at end', () => { + const content = + 'Some content before {"tool_calls": [{"tool": "example", "args": ["arg1"]}]} '; + const result = testFunctionMessageAtEnd(content); + expect(result).toEqual({ + content: '{"tool_calls": [{"tool": "example", "args": ["arg1"]}]}', + valid: true, + }); + }); + + it('should not extract tool_calls JSON when in middle', () => { + const content = + 'Some content before {"tool_calls": [{"tool": "example", "args": ["arg1"]}]}, here are some end content'; + const result = testFunctionMessageAtEnd(content); + expect(result).toEqual({ content: '', valid: false }); + }); + + it('should return an empty string and valid false when JSON is not present', () => { + const content = 'Some content without the JSON structure'; + const result = testFunctionMessageAtEnd(content); + expect(result).toEqual({ content: '', valid: false }); + }); + + it('should return an empty string and valid false when content is empty', () => { + const content = ''; + const result = testFunctionMessageAtEnd(content); + + expect(result).toEqual({ content: '', valid: false }); + }); + + it('should handle null or undefined content gracefully', () => { + const nullContent = null; + const undefinedContent = undefined; + + const resultWithNull = testFunctionMessageAtEnd(nullContent as any); + const resultWithUndefined = testFunctionMessageAtEnd(undefinedContent as any); + + expect(resultWithNull).toEqual({ content: '', valid: false }); + expect(resultWithUndefined).toEqual({ content: '', valid: false }); + }); +}); diff --git a/src/const/message.ts b/src/const/message.ts index 31193031e5ae..a260fbcc927b 100644 --- a/src/const/message.ts +++ b/src/const/message.ts @@ -8,8 +8,8 @@ export const isFunctionMessageAtStart = (content: string) => { }; export const testFunctionMessageAtEnd = (content: string) => { - const regExp = /{"tool_calls":.*?}}/; - const match = content.match(regExp); + const regExp = /{"tool_calls":.*?]}$/; + const match = content?.trim().match(regExp); - return { content: match ? match[0] : '', valid: match }; + return { content: match ? match[0] : '', valid: !!match }; }; diff --git a/src/store/chat/slices/message/action.test.ts b/src/store/chat/slices/message/action.test.ts index 31b17dbce500..56992a021d35 100644 --- a/src/store/chat/slices/message/action.test.ts +++ b/src/store/chat/slices/message/action.test.ts @@ -3,10 +3,12 @@ import useSWR, { mutate } from 'swr'; import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LOADING_FLAT } from '@/const/message'; +import { DEFAULT_AGENT_CONFIG } from '@/const/settings'; import { chatService } from '@/services/chat'; import { messageService } from '@/services/message'; import { topicService } from '@/services/topic'; import { chatSelectors } from '@/store/chat/selectors'; +import { agentSelectors } from '@/store/session/selectors'; import { ChatMessage } from '@/types/message'; import { useChatStore } from '../../store'; @@ -41,6 +43,12 @@ vi.mock('@/store/chat/selectors', () => ({ }, })); +vi.mock('@/store/session/selectors', () => ({ + agentSelectors: { + currentAgentConfig: vi.fn(), + }, +})); + const realCoreProcessMessage = useChatStore.getState().coreProcessMessage; const realRefreshMessages = useChatStore.getState().refreshMessages; // Mock state @@ -57,6 +65,8 @@ const mockState = { beforeEach(() => { vi.clearAllMocks(); useChatStore.setState(mockState, false); + + (agentSelectors.currentAgentConfig as Mock).mockImplementation(() => DEFAULT_AGENT_CONFIG); }); afterEach(() => { vi.restoreAllMocks(); @@ -226,29 +236,126 @@ describe('chatMessage actions', () => { expect(result.current.coreProcessMessage).toHaveBeenCalled(); }); - // it('should auto-create topic and switch to it if enabled and threshold is reached', async () => { - // const { result } = renderHook(() => useChatStore()); - // const message = 'Test message'; - // const autoCreateTopicThreshold = 5; - // const enableAutoCreateTopic = true; - // - // // Mock state with the necessary settings - // useChatStore.setState({ - // ...mockState, - // messages: Array(autoCreateTopicThreshold).fill({}), // Fill with dummy messages to reach threshold - // }); - // - // // Mock messageService.create to resolve with a message id - // (messageService.create as vi.Mock).mockResolvedValue('new-message-id'); - // - // await act(async () => { - // await result.current.sendMessage(message); - // }); - // - // expect(result.current.saveToTopic).toHaveBeenCalled(); - // expect(result.current.switchTopic).toHaveBeenCalled(); - // }); - // 其他可能的测试用例... + describe('auto-create topic', () => { + it('should not auto-create topic if enableAutoCreateTopic is false', async () => { + const { result } = renderHook(() => useChatStore()); + const message = 'Test message'; + const autoCreateTopicThreshold = 5; + const enableAutoCreateTopic = false; + + // Mock messageService.create to resolve with a message id + (messageService.create as Mock).mockResolvedValue('new-message-id'); + + // Mock agent config to simulate auto-create topic behavior + (agentSelectors.currentAgentConfig as Mock).mockImplementation(() => ({ + autoCreateTopicThreshold, + enableAutoCreateTopic, + })); + + // Mock the currentChats selector to return a list that does not reach the threshold + (chatSelectors.currentChats as Mock).mockReturnValue( + Array.from({ length: autoCreateTopicThreshold + 1 }, (_, i) => ({ id: `msg-${i}` })), + ); + + // Mock saveToTopic and switchTopic to simulate not being called + const saveToTopicMock = vi.fn(); + const switchTopicMock = vi.fn(); + + await act(async () => { + useChatStore.setState({ + ...mockState, + activeTopicId: undefined, + saveToTopic: saveToTopicMock, + switchTopic: switchTopicMock, + }); + + await result.current.sendMessage(message); + }); + + expect(saveToTopicMock).not.toHaveBeenCalled(); + expect(switchTopicMock).not.toHaveBeenCalled(); + }); + + it('should auto-create topic and switch to it if enabled and threshold is reached', async () => { + const { result } = renderHook(() => useChatStore()); + const message = 'Test message'; + const autoCreateTopicThreshold = 5; + const enableAutoCreateTopic = true; + + // Mock agent config to simulate auto-create topic behavior + (agentSelectors.currentAgentConfig as Mock).mockImplementation(() => ({ + autoCreateTopicThreshold, + enableAutoCreateTopic, + })); + + // Mock messageService.create to resolve with a message id + (messageService.create as Mock).mockResolvedValue('new-message-id'); + + // Mock the currentChats selector to return a list that reaches the threshold + (chatSelectors.currentChats as Mock).mockReturnValue( + Array.from({ length: autoCreateTopicThreshold }, (_, i) => ({ id: `msg-${i}` })), + ); + + // Mock saveToTopic to resolve with a topic id and switchTopic to switch to the new topic + const saveToTopicMock = vi.fn(() => Promise.resolve('new-topic-id')); + const switchTopicMock = vi.fn(); + + act(() => { + useChatStore.setState({ + ...mockState, + activeTopicId: undefined, + saveToTopic: saveToTopicMock, + switchTopic: switchTopicMock, + }); + }); + + await act(async () => { + await result.current.sendMessage(message); + }); + + expect(saveToTopicMock).toHaveBeenCalled(); + expect(switchTopicMock).toHaveBeenCalledWith('new-topic-id'); + }); + + it('should not auto-create topic if autoCreateTopicThreshold is not reached', async () => { + const { result } = renderHook(() => useChatStore()); + const message = 'Test message'; + const autoCreateTopicThreshold = 5; + const enableAutoCreateTopic = true; + + // Mock messageService.create to resolve with a message id + (messageService.create as Mock).mockResolvedValue('new-message-id'); + + // Mock agent config to simulate auto-create topic behavior + (agentSelectors.currentAgentConfig as Mock).mockImplementation(() => ({ + autoCreateTopicThreshold, + enableAutoCreateTopic, + })); + + // Mock the currentChats selector to return a list that does not reach the threshold + (chatSelectors.currentChats as Mock).mockReturnValue( + Array.from({ length: autoCreateTopicThreshold - 1 }, (_, i) => ({ id: `msg-${i}` })), + ); + + // Mock saveToTopic and switchTopic to simulate not being called + const saveToTopicMock = vi.fn(); + const switchTopicMock = vi.fn(); + + await act(async () => { + useChatStore.setState({ + ...mockState, + activeTopicId: undefined, + saveToTopic: saveToTopicMock, + switchTopic: switchTopicMock, + }); + + await result.current.sendMessage(message); + }); + + expect(saveToTopicMock).not.toHaveBeenCalled(); + expect(switchTopicMock).not.toHaveBeenCalled(); + }); + }); }); describe('resendMessage action', () => { @@ -483,4 +590,80 @@ describe('chatMessage actions', () => { }); }); }); + + describe('fetchAIChatMessage', () => { + it('should fetch AI chat message and return content', async () => { + const { result } = renderHook(() => useChatStore()); + const messages = [{ id: 'message-id', content: 'Hello', role: 'user' }] as ChatMessage[]; + const assistantMessageId = 'assistant-message-id'; + const aiResponse = 'Hello, human!'; + + // Mock chatService.createAssistantMessage to resolve with AI response + (chatService.createAssistantMessage as Mock).mockResolvedValue(new Response(aiResponse)); + + await act(async () => { + const response = await result.current.fetchAIChatMessage(messages, assistantMessageId); + expect(response.content).toEqual(aiResponse); + expect(response.isFunctionCall).toEqual(false); + expect(response.functionCallAtEnd).toEqual(false); + expect(response.functionCallContent).toEqual(''); + }); + }); + + it('should handle function call message at start of AI response', async () => { + const { result } = renderHook(() => useChatStore()); + const messages = [{ id: 'message-id', content: 'Hello', role: 'user' }] as ChatMessage[]; + const assistantMessageId = 'assistant-message-id'; + const aiResponse = + '{"tool_calls":[{"id":"call_sbca","type":"function","function":{"name":"pluginName____apiName","arguments":{"key":"value"}}}]}'; + + // Mock chatService.createAssistantMessage to resolve with AI response containing function call + (chatService.createAssistantMessage as Mock).mockResolvedValue(new Response(aiResponse)); + + await act(async () => { + const response = await result.current.fetchAIChatMessage(messages, assistantMessageId); + expect(response.content).toEqual(aiResponse); + expect(response.isFunctionCall).toEqual(true); + expect(response.functionCallAtEnd).toEqual(false); + expect(response.functionCallContent).toEqual(''); + }); + }); + + it('should handle function message at end of AI response', async () => { + const { result } = renderHook(() => useChatStore()); + const messages = [{ id: 'message-id', content: 'Hello', role: 'user' }] as ChatMessage[]; + const assistantMessageId = 'assistant-message-id'; + const aiResponse = + 'Hello, human! {"tool_calls":[{"id":"call_sbca","type":"function","function":{"name":"pluginName____apiName","arguments":{"key":"value"}}}]}'; + + // Mock chatService.createAssistantMessage to resolve with AI response containing function call at end + (chatService.createAssistantMessage as Mock).mockResolvedValue(new Response(aiResponse)); + + await act(async () => { + const response = await result.current.fetchAIChatMessage(messages, assistantMessageId); + expect(response.content).toEqual(aiResponse); + expect(response.isFunctionCall).toEqual(true); + expect(response.functionCallAtEnd).toEqual(true); + expect(response.functionCallContent).toEqual( + '{"tool_calls":[{"id":"call_sbca","type":"function","function":{"name":"pluginName____apiName","arguments":{"key":"value"}}}]}', + ); + }); + }); + + it('should handle errors during AI response fetching', async () => { + const { result } = renderHook(() => useChatStore()); + const messages = [{ id: 'message-id', content: 'Hello', role: 'user' }] as ChatMessage[]; + const assistantMessageId = 'assistant-message-id'; + + // Mock chatService.createAssistantMessage to reject with an error + const errorMessage = 'Error fetching AI response'; + (chatService.createAssistantMessage as Mock).mockRejectedValue(new Error(errorMessage)); + + await act(async () => { + await expect( + result.current.fetchAIChatMessage(messages, assistantMessageId), + ).rejects.toThrow(errorMessage); + }); + }); + }); }); diff --git a/src/store/chat/slices/message/action.ts b/src/store/chat/slices/message/action.ts index 123b7a0c66a0..977c16a978a1 100644 --- a/src/store/chat/slices/message/action.ts +++ b/src/store/chat/slices/message/action.ts @@ -382,7 +382,7 @@ export const chatMessage: StateCreator< toggleChatLoading(false, undefined, n('generateMessage(end)') as string); // also exist message like this: - // 请稍等,我帮您查询一下。{"tool_calls": {"name": "plugin-identifier____recommendClothes____standalone", "arguments": "{\n "mood": "",\n "gender": "man"\n}"}} + // 请稍等,我帮您查询一下。{"tool_calls":[{"id":"call_sbca","type":"function","function":{"name":"pluginName____apiName","arguments":{"key":"value"}}}]} if (!isFunctionCall) { const { content, valid } = testFunctionMessageAtEnd(output);