Skip to content

Commit

Permalink
Merge remote-tracking branch 'upstream/main'
Browse files Browse the repository at this point in the history
  • Loading branch information
bentwnghk committed Jan 1, 2024
2 parents 4890e45 + cf70b35 commit eb68223
Show file tree
Hide file tree
Showing 5 changed files with 268 additions and 27 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<sup>Released on **2024-01-01**</sup>

#### 🐛 Bug Fixes

- **misc**: Fix metadata.ts.
- **misc**: Fix parse error of tool calls at end.

<br/>

Expand Down Expand Up @@ -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))

</details>

Expand Down
55 changes: 55 additions & 0 deletions src/const/message.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
6 changes: 3 additions & 3 deletions src/const/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
229 changes: 206 additions & 23 deletions src/store/chat/slices/message/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -57,6 +65,8 @@ const mockState = {
beforeEach(() => {
vi.clearAllMocks();
useChatStore.setState(mockState, false);

(agentSelectors.currentAgentConfig as Mock).mockImplementation(() => DEFAULT_AGENT_CONFIG);
});
afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
});
2 changes: 1 addition & 1 deletion src/store/chat/slices/message/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down

0 comments on commit eb68223

Please sign in to comment.