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);