@lobehub/chat 1.128.0 → 1.128.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.github/workflows/test.yml +8 -1
  2. package/CHANGELOG.md +25 -0
  3. package/changelog/v1.json +9 -0
  4. package/next.config.ts +8 -1
  5. package/package.json +71 -69
  6. package/packages/context-engine/ARCHITECTURE.md +425 -0
  7. package/packages/context-engine/package.json +40 -0
  8. package/packages/context-engine/src/base/BaseProcessor.ts +87 -0
  9. package/packages/context-engine/src/base/BaseProvider.ts +22 -0
  10. package/packages/context-engine/src/index.ts +32 -0
  11. package/packages/context-engine/src/pipeline.ts +219 -0
  12. package/packages/context-engine/src/processors/HistoryTruncate.ts +76 -0
  13. package/packages/context-engine/src/processors/InputTemplate.ts +83 -0
  14. package/packages/context-engine/src/processors/MessageCleanup.ts +87 -0
  15. package/packages/context-engine/src/processors/MessageContent.ts +298 -0
  16. package/packages/context-engine/src/processors/PlaceholderVariables.ts +196 -0
  17. package/packages/context-engine/src/processors/ToolCall.ts +186 -0
  18. package/packages/context-engine/src/processors/ToolMessageReorder.ts +113 -0
  19. package/packages/context-engine/src/processors/__tests__/HistoryTruncate.test.ts +175 -0
  20. package/packages/context-engine/src/processors/__tests__/InputTemplate.test.ts +243 -0
  21. package/packages/context-engine/src/processors/__tests__/MessageContent.test.ts +394 -0
  22. package/packages/context-engine/src/processors/__tests__/PlaceholderVariables.test.ts +334 -0
  23. package/packages/context-engine/src/processors/__tests__/ToolMessageReorder.test.ts +186 -0
  24. package/packages/context-engine/src/processors/index.ts +15 -0
  25. package/packages/context-engine/src/providers/HistorySummary.ts +102 -0
  26. package/packages/context-engine/src/providers/InboxGuide.ts +102 -0
  27. package/packages/context-engine/src/providers/SystemRoleInjector.ts +64 -0
  28. package/packages/context-engine/src/providers/ToolSystemRole.ts +118 -0
  29. package/packages/context-engine/src/providers/__tests__/HistorySummaryProvider.test.ts +112 -0
  30. package/packages/context-engine/src/providers/__tests__/InboxGuideProvider.test.ts +121 -0
  31. package/packages/context-engine/src/providers/__tests__/SystemRoleInjector.test.ts +200 -0
  32. package/packages/context-engine/src/providers/__tests__/ToolSystemRoleProvider.test.ts +140 -0
  33. package/packages/context-engine/src/providers/index.ts +11 -0
  34. package/packages/context-engine/src/types.ts +201 -0
  35. package/packages/context-engine/vitest.config.mts +10 -0
  36. package/packages/database/package.json +1 -1
  37. package/packages/prompts/src/prompts/systemRole/index.ts +1 -1
  38. package/packages/utils/src/index.ts +2 -0
  39. package/packages/utils/src/uriParser.test.ts +29 -0
  40. package/packages/utils/src/uriParser.ts +24 -0
  41. package/src/services/{__tests__ → chat}/chat.test.ts +22 -1032
  42. package/src/services/chat/clientModelRuntime.test.ts +385 -0
  43. package/src/services/chat/clientModelRuntime.ts +34 -0
  44. package/src/services/chat/contextEngineering.test.ts +848 -0
  45. package/src/services/chat/contextEngineering.ts +123 -0
  46. package/src/services/chat/helper.ts +61 -0
  47. package/src/services/{chat.ts → chat/index.ts} +24 -366
  48. package/src/services/chat/types.ts +9 -0
  49. package/src/services/models.ts +1 -1
  50. package/src/store/aiInfra/slices/aiModel/selectors.ts +2 -2
  51. package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +1 -40
  52. /package/src/services/{__tests__ → chat}/__snapshots__/chat.test.ts.snap +0 -0
@@ -0,0 +1,113 @@
1
+ import debug from 'debug';
2
+
3
+ import { BaseProcessor } from '../base/BaseProcessor';
4
+ import type { PipelineContext, ProcessorOptions } from '../types';
5
+
6
+ const log = debug('context-engine:processor:ToolMessageReorder');
7
+
8
+ /**
9
+ * Reorder tool messages to ensure that tool messages are displayed in the correct order.
10
+ * see https://github.com/lobehub/lobe-chat/pull/3155
11
+ */
12
+ export class ToolMessageReorder extends BaseProcessor {
13
+ readonly name = 'ToolMessageReorder';
14
+
15
+ constructor(options: ProcessorOptions = {}) {
16
+ super(options);
17
+ }
18
+
19
+ protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
20
+ const clonedContext = this.cloneContext(context);
21
+
22
+ // 重新排序消息
23
+ const reorderedMessages = this.reorderToolMessages(clonedContext.messages);
24
+
25
+ const originalCount = clonedContext.messages.length;
26
+ const reorderedCount = reorderedMessages.length;
27
+
28
+ clonedContext.messages = reorderedMessages;
29
+
30
+ // 更新元数据
31
+ clonedContext.metadata.toolMessageReorder = {
32
+ originalCount,
33
+ removedInvalidTools: originalCount - reorderedCount,
34
+ reorderedCount,
35
+ };
36
+
37
+ if (originalCount !== reorderedCount) {
38
+ log(
39
+ 'Tool message reordering completed, removed',
40
+ originalCount - reorderedCount,
41
+ 'invalid tool messages',
42
+ );
43
+ } else {
44
+ log('Tool message reordering completed, message order optimized');
45
+ }
46
+
47
+ return this.markAsExecuted(clonedContext);
48
+ }
49
+
50
+ /**
51
+ * 重新排序工具消息
52
+ */
53
+ private reorderToolMessages(messages: any[]): any[] {
54
+ // 1. 先收集所有 assistant 消息中的有效 tool_call_id
55
+ const validToolCallIds = new Set<string>();
56
+ messages.forEach((message) => {
57
+ if (message.role === 'assistant' && message.tool_calls) {
58
+ message.tool_calls.forEach((toolCall: any) => {
59
+ validToolCallIds.add(toolCall.id);
60
+ });
61
+ }
62
+ });
63
+
64
+ // 2. 收集所有有效的 tool 消息
65
+ const toolMessages: Record<string, any> = {};
66
+ messages.forEach((message) => {
67
+ if (
68
+ message.role === 'tool' &&
69
+ message.tool_call_id &&
70
+ validToolCallIds.has(message.tool_call_id)
71
+ ) {
72
+ toolMessages[message.tool_call_id] = message;
73
+ }
74
+ });
75
+
76
+ // 3. 重新排序消息
77
+ const reorderedMessages: any[] = [];
78
+ messages.forEach((message) => {
79
+ // 跳过无效的 tool 消息
80
+ if (
81
+ message.role === 'tool' &&
82
+ (!message.tool_call_id || !validToolCallIds.has(message.tool_call_id))
83
+ ) {
84
+ log('Skipping invalid tool message:', message.id);
85
+ return;
86
+ }
87
+
88
+ // 检查是否已经添加过该 tool 消息
89
+ const hasPushed = reorderedMessages.some(
90
+ (m) => !!message.tool_call_id && m.tool_call_id === message.tool_call_id,
91
+ );
92
+
93
+ if (hasPushed) return;
94
+
95
+ reorderedMessages.push(message);
96
+
97
+ // 如果是 assistant 消息且有 tool_calls,添加对应的 tool 消息
98
+ if (message.role === 'assistant' && message.tool_calls) {
99
+ message.tool_calls.forEach((toolCall: any) => {
100
+ const correspondingToolMessage = toolMessages[toolCall.id];
101
+ if (correspondingToolMessage) {
102
+ reorderedMessages.push(correspondingToolMessage);
103
+ delete toolMessages[toolCall.id];
104
+ }
105
+ });
106
+ }
107
+ });
108
+
109
+ return reorderedMessages;
110
+ }
111
+
112
+ // 简化:移除验证/统计等辅助方法
113
+ }
@@ -0,0 +1,175 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { HistoryTruncateProcessor, getSlicedMessages } from '../HistoryTruncate';
4
+
5
+ describe('HistoryTruncateProcessor', () => {
6
+ describe('getSlicedMessages', () => {
7
+ const messages = [
8
+ { id: '1', content: 'First', role: 'user' },
9
+ { id: '2', content: 'Second', role: 'assistant' },
10
+ { id: '3', content: 'Third', role: 'user' },
11
+ { id: '4', content: 'Fourth', role: 'assistant' },
12
+ { id: '5', content: 'Fifth', role: 'user' },
13
+ ];
14
+
15
+ it('should return all messages when history count is disabled', () => {
16
+ const result = getSlicedMessages(messages, { enableHistoryCount: false });
17
+ expect(result).toEqual(messages);
18
+ });
19
+
20
+ it('should return all messages when historyCount is undefined', () => {
21
+ const result = getSlicedMessages(messages, {
22
+ enableHistoryCount: true,
23
+ historyCount: undefined,
24
+ });
25
+ expect(result).toEqual(messages);
26
+ });
27
+
28
+ it('should return last N messages based on historyCount', () => {
29
+ const result = getSlicedMessages(messages, {
30
+ enableHistoryCount: true,
31
+ historyCount: 2,
32
+ });
33
+ expect(result).toEqual([
34
+ { id: '4', content: 'Fourth', role: 'assistant' },
35
+ { id: '5', content: 'Fifth', role: 'user' },
36
+ ]);
37
+ });
38
+
39
+ it('should include new user message in count when includeNewUserMessage is true', () => {
40
+ const result = getSlicedMessages(messages, {
41
+ enableHistoryCount: true,
42
+ historyCount: 3,
43
+ });
44
+
45
+ expect(result).toEqual([
46
+ { id: '3', content: 'Third', role: 'user' },
47
+ { id: '4', content: 'Fourth', role: 'assistant' },
48
+ { id: '5', content: 'Fifth', role: 'user' },
49
+ ]);
50
+ });
51
+
52
+ it('should return empty array when historyCount is 0', () => {
53
+ const result = getSlicedMessages(messages, {
54
+ enableHistoryCount: true,
55
+ historyCount: 0,
56
+ });
57
+ expect(result).toEqual([]);
58
+ });
59
+
60
+ it('should return empty array when historyCount is negative', () => {
61
+ const result = getSlicedMessages(messages, {
62
+ enableHistoryCount: true,
63
+ historyCount: -1,
64
+ });
65
+ expect(result).toEqual([]);
66
+ });
67
+
68
+ it('should return all messages when historyCount exceeds array length', () => {
69
+ const result = getSlicedMessages(messages, {
70
+ enableHistoryCount: true,
71
+ historyCount: 10,
72
+ });
73
+ expect(result).toEqual(messages);
74
+ });
75
+
76
+ it('should handle empty message array', () => {
77
+ const result = getSlicedMessages([], {
78
+ enableHistoryCount: true,
79
+ historyCount: 2,
80
+ });
81
+ expect(result).toEqual([]);
82
+ });
83
+ });
84
+
85
+ describe('HistoryTruncateProcessor', () => {
86
+ it('should truncate messages based on configuration', async () => {
87
+ const processor = new HistoryTruncateProcessor({
88
+ enableHistoryCount: true,
89
+ historyCount: 3,
90
+ });
91
+
92
+ const context = {
93
+ initialState: {
94
+ messages: [],
95
+ model: 'gpt-4',
96
+ provider: 'openai',
97
+ systemRole: '',
98
+ tools: [],
99
+ },
100
+ messages: [
101
+ { id: '1', content: 'First', role: 'user', createdAt: Date.now(), updatedAt: Date.now() },
102
+ {
103
+ id: '2',
104
+ content: 'Second',
105
+ role: 'assistant',
106
+ createdAt: Date.now(),
107
+ updatedAt: Date.now(),
108
+ },
109
+ { id: '3', content: 'Third', role: 'user', createdAt: Date.now(), updatedAt: Date.now() },
110
+ {
111
+ id: '4',
112
+ content: 'Fourth',
113
+ role: 'assistant',
114
+ createdAt: Date.now(),
115
+ updatedAt: Date.now(),
116
+ },
117
+ { id: '5', content: 'Fifth', role: 'user', createdAt: Date.now(), updatedAt: Date.now() },
118
+ ],
119
+ metadata: {
120
+ model: 'gpt-4',
121
+ maxTokens: 4096,
122
+ },
123
+ isAborted: false,
124
+ };
125
+
126
+ const result = await processor.process(context);
127
+
128
+ expect(result.messages).toHaveLength(3); // 2 + 1 for new user message
129
+ expect(result.messages).toEqual([
130
+ expect.objectContaining({ content: 'Third' }),
131
+ expect.objectContaining({ content: 'Fourth' }),
132
+ expect.objectContaining({ content: 'Fifth' }),
133
+ ]);
134
+ expect(result.metadata.historyTruncated).toBe(2);
135
+ expect(result.metadata.finalMessageCount).toBe(3);
136
+ });
137
+
138
+ it('should not truncate when history count is disabled', async () => {
139
+ const processor = new HistoryTruncateProcessor({
140
+ enableHistoryCount: false,
141
+ });
142
+
143
+ const context = {
144
+ initialState: {
145
+ messages: [],
146
+ model: 'gpt-4',
147
+ provider: 'openai',
148
+ systemRole: '',
149
+ tools: [],
150
+ },
151
+ messages: [
152
+ { id: '1', content: 'First', role: 'user', createdAt: Date.now(), updatedAt: Date.now() },
153
+ {
154
+ id: '2',
155
+ content: 'Second',
156
+ role: 'assistant',
157
+ createdAt: Date.now(),
158
+ updatedAt: Date.now(),
159
+ },
160
+ ],
161
+ metadata: {
162
+ model: 'gpt-4',
163
+ maxTokens: 4096,
164
+ },
165
+ isAborted: false,
166
+ };
167
+
168
+ const result = await processor.process(context);
169
+
170
+ expect(result.messages).toHaveLength(2);
171
+ expect(result.metadata.historyTruncated).toBe(0);
172
+ expect(result.metadata.finalMessageCount).toBe(2);
173
+ });
174
+ });
175
+ });
@@ -0,0 +1,243 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { InputTemplateProcessor } from '../InputTemplate';
4
+
5
+ describe('InputTemplateProcessor', () => {
6
+ it('should apply template to user messages', async () => {
7
+ const processor = new InputTemplateProcessor({
8
+ inputTemplate: 'Template: {{text}} - End',
9
+ });
10
+
11
+ const context = {
12
+ initialState: {
13
+ messages: [],
14
+ model: 'gpt-4',
15
+ provider: 'openai',
16
+ systemRole: '',
17
+ tools: [],
18
+ },
19
+ messages: [
20
+ {
21
+ id: '1',
22
+ role: 'user',
23
+ content: 'Original user message',
24
+ createdAt: Date.now(),
25
+ updatedAt: Date.now(),
26
+ },
27
+ {
28
+ id: '2',
29
+ role: 'assistant',
30
+ content: 'Assistant response',
31
+ createdAt: Date.now(),
32
+ updatedAt: Date.now(),
33
+ },
34
+ ],
35
+ metadata: {
36
+ model: 'gpt-4',
37
+ maxTokens: 4096,
38
+ },
39
+ isAborted: false,
40
+ };
41
+
42
+ const result = await processor.process(context);
43
+
44
+ expect(result.messages[0].content).toBe('Template: Original user message - End');
45
+ expect(result.messages[1].content).toBe('Assistant response'); // Assistant message unchanged
46
+ expect(result.metadata.inputTemplateProcessed).toBe(1);
47
+ });
48
+
49
+ it('should skip processing when no template is configured', async () => {
50
+ const processor = new InputTemplateProcessor({});
51
+
52
+ const context = {
53
+ initialState: {
54
+ messages: [],
55
+ model: 'gpt-4',
56
+ provider: 'openai',
57
+ systemRole: '',
58
+ tools: [],
59
+ },
60
+ messages: [
61
+ {
62
+ id: '1',
63
+ role: 'user',
64
+ content: 'User message',
65
+ createdAt: Date.now(),
66
+ updatedAt: Date.now(),
67
+ },
68
+ ],
69
+ metadata: {
70
+ model: 'gpt-4',
71
+ maxTokens: 4096,
72
+ },
73
+ isAborted: false,
74
+ };
75
+
76
+ const result = await processor.process(context);
77
+
78
+ expect(result.messages[0].content).toBe('User message'); // Unchanged
79
+ expect(result.metadata.inputTemplateProcessed).toBeUndefined();
80
+ });
81
+
82
+ it('should handle template without {{text}} placeholder', async () => {
83
+ const processor = new InputTemplateProcessor({
84
+ inputTemplate: 'Static template content',
85
+ });
86
+
87
+ const context = {
88
+ initialState: {
89
+ messages: [],
90
+ model: 'gpt-4',
91
+ provider: 'openai',
92
+ systemRole: '',
93
+ tools: [],
94
+ },
95
+ messages: [
96
+ {
97
+ id: '1',
98
+ role: 'user',
99
+ content: 'Original message',
100
+ createdAt: Date.now(),
101
+ updatedAt: Date.now(),
102
+ },
103
+ ],
104
+ metadata: {
105
+ model: 'gpt-4',
106
+ maxTokens: 4096,
107
+ },
108
+ isAborted: false,
109
+ };
110
+
111
+ const result = await processor.process(context);
112
+
113
+ expect(result.messages[0].content).toBe('Static template content');
114
+ expect(result.metadata.inputTemplateProcessed).toBe(1);
115
+ });
116
+
117
+ it('should handle template compilation errors gracefully', async () => {
118
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
119
+
120
+ const processor = new InputTemplateProcessor({
121
+ inputTemplate: '<%- invalid javascript code %>',
122
+ });
123
+
124
+ const context = {
125
+ initialState: {
126
+ messages: [],
127
+ model: 'gpt-4',
128
+ provider: 'openai',
129
+ systemRole: '',
130
+ tools: [],
131
+ },
132
+ messages: [
133
+ {
134
+ id: '1',
135
+ role: 'user',
136
+ content: 'User message',
137
+ createdAt: Date.now(),
138
+ updatedAt: Date.now(),
139
+ },
140
+ ],
141
+ metadata: {
142
+ model: 'gpt-4',
143
+ maxTokens: 4096,
144
+ },
145
+ isAborted: false,
146
+ };
147
+
148
+ const result = await processor.process(context);
149
+
150
+ // Should skip processing due to compilation error
151
+ expect(result.messages[0].content).toBe('User message'); // Original content preserved
152
+ expect(result.metadata.inputTemplateProcessed).toBe(0);
153
+
154
+ consoleSpy.mockRestore();
155
+ });
156
+
157
+ it('should handle template application errors gracefully', async () => {
158
+ const processor = new InputTemplateProcessor({
159
+ inputTemplate: '{{text}} <%- throw new Error("Application error") %>',
160
+ });
161
+
162
+ const context = {
163
+ initialState: {
164
+ messages: [],
165
+ model: 'gpt-4',
166
+ provider: 'openai',
167
+ systemRole: '',
168
+ tools: [],
169
+ },
170
+ messages: [
171
+ {
172
+ id: '1',
173
+ role: 'user',
174
+ content: 'User message',
175
+ createdAt: Date.now(),
176
+ updatedAt: Date.now(),
177
+ },
178
+ ],
179
+ metadata: {
180
+ model: 'gpt-4',
181
+ maxTokens: 4096,
182
+ },
183
+ isAborted: false,
184
+ };
185
+
186
+ const result = await processor.process(context);
187
+
188
+ // Should keep original message when template application fails
189
+ expect(result.messages[0].content).toBe('User message');
190
+ expect(result.metadata.inputTemplateProcessed).toBe(0);
191
+ });
192
+
193
+ it('should only process user messages, not assistant messages', async () => {
194
+ const processor = new InputTemplateProcessor({
195
+ inputTemplate: 'Processed: {{text}}',
196
+ });
197
+
198
+ const context = {
199
+ initialState: {
200
+ messages: [],
201
+ model: 'gpt-4',
202
+ provider: 'openai',
203
+ systemRole: '',
204
+ tools: [],
205
+ },
206
+ messages: [
207
+ {
208
+ id: '1',
209
+ role: 'user',
210
+ content: 'User message',
211
+ createdAt: Date.now(),
212
+ updatedAt: Date.now(),
213
+ },
214
+ {
215
+ id: '2',
216
+ role: 'assistant',
217
+ content: 'Assistant message',
218
+ createdAt: Date.now(),
219
+ updatedAt: Date.now(),
220
+ },
221
+ {
222
+ id: '3',
223
+ role: 'system',
224
+ content: 'System message',
225
+ createdAt: Date.now(),
226
+ updatedAt: Date.now(),
227
+ },
228
+ ],
229
+ metadata: {
230
+ model: 'gpt-4',
231
+ maxTokens: 4096,
232
+ },
233
+ isAborted: false,
234
+ };
235
+
236
+ const result = await processor.process(context);
237
+
238
+ expect(result.messages[0].content).toBe('Processed: User message');
239
+ expect(result.messages[1].content).toBe('Assistant message'); // Unchanged
240
+ expect(result.messages[2].content).toBe('System message'); // Unchanged
241
+ expect(result.metadata.inputTemplateProcessed).toBe(1);
242
+ });
243
+ });