@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.
- package/.github/workflows/test.yml +8 -1
- package/CHANGELOG.md +25 -0
- package/changelog/v1.json +9 -0
- package/next.config.ts +8 -1
- package/package.json +71 -69
- package/packages/context-engine/ARCHITECTURE.md +425 -0
- package/packages/context-engine/package.json +40 -0
- package/packages/context-engine/src/base/BaseProcessor.ts +87 -0
- package/packages/context-engine/src/base/BaseProvider.ts +22 -0
- package/packages/context-engine/src/index.ts +32 -0
- package/packages/context-engine/src/pipeline.ts +219 -0
- package/packages/context-engine/src/processors/HistoryTruncate.ts +76 -0
- package/packages/context-engine/src/processors/InputTemplate.ts +83 -0
- package/packages/context-engine/src/processors/MessageCleanup.ts +87 -0
- package/packages/context-engine/src/processors/MessageContent.ts +298 -0
- package/packages/context-engine/src/processors/PlaceholderVariables.ts +196 -0
- package/packages/context-engine/src/processors/ToolCall.ts +186 -0
- package/packages/context-engine/src/processors/ToolMessageReorder.ts +113 -0
- package/packages/context-engine/src/processors/__tests__/HistoryTruncate.test.ts +175 -0
- package/packages/context-engine/src/processors/__tests__/InputTemplate.test.ts +243 -0
- package/packages/context-engine/src/processors/__tests__/MessageContent.test.ts +394 -0
- package/packages/context-engine/src/processors/__tests__/PlaceholderVariables.test.ts +334 -0
- package/packages/context-engine/src/processors/__tests__/ToolMessageReorder.test.ts +186 -0
- package/packages/context-engine/src/processors/index.ts +15 -0
- package/packages/context-engine/src/providers/HistorySummary.ts +102 -0
- package/packages/context-engine/src/providers/InboxGuide.ts +102 -0
- package/packages/context-engine/src/providers/SystemRoleInjector.ts +64 -0
- package/packages/context-engine/src/providers/ToolSystemRole.ts +118 -0
- package/packages/context-engine/src/providers/__tests__/HistorySummaryProvider.test.ts +112 -0
- package/packages/context-engine/src/providers/__tests__/InboxGuideProvider.test.ts +121 -0
- package/packages/context-engine/src/providers/__tests__/SystemRoleInjector.test.ts +200 -0
- package/packages/context-engine/src/providers/__tests__/ToolSystemRoleProvider.test.ts +140 -0
- package/packages/context-engine/src/providers/index.ts +11 -0
- package/packages/context-engine/src/types.ts +201 -0
- package/packages/context-engine/vitest.config.mts +10 -0
- package/packages/database/package.json +1 -1
- package/packages/prompts/src/prompts/systemRole/index.ts +1 -1
- package/packages/utils/src/index.ts +2 -0
- package/packages/utils/src/uriParser.test.ts +29 -0
- package/packages/utils/src/uriParser.ts +24 -0
- package/src/services/{__tests__ → chat}/chat.test.ts +22 -1032
- package/src/services/chat/clientModelRuntime.test.ts +385 -0
- package/src/services/chat/clientModelRuntime.ts +34 -0
- package/src/services/chat/contextEngineering.test.ts +848 -0
- package/src/services/chat/contextEngineering.ts +123 -0
- package/src/services/chat/helper.ts +61 -0
- package/src/services/{chat.ts → chat/index.ts} +24 -366
- package/src/services/chat/types.ts +9 -0
- package/src/services/models.ts +1 -1
- package/src/store/aiInfra/slices/aiModel/selectors.ts +2 -2
- package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +1 -40
- /package/src/services/{__tests__ → chat}/__snapshots__/chat.test.ts.snap +0 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { filesPrompts } from '@lobechat/prompts';
|
|
2
|
+
import { imageUrlToBase64, isLocalUrl, parseDataUri } from '@lobechat/utils';
|
|
3
|
+
import debug from 'debug';
|
|
4
|
+
|
|
5
|
+
import { BaseProcessor } from '../base/BaseProcessor';
|
|
6
|
+
import type { PipelineContext, ProcessorOptions } from '../types';
|
|
7
|
+
|
|
8
|
+
const log = debug('context-engine:processor:MessageContentProcessor');
|
|
9
|
+
|
|
10
|
+
export interface FileContextConfig {
|
|
11
|
+
/** Whether to enable file context injection */
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
/** Whether to include file URLs in file context prompts */
|
|
14
|
+
includeFileUrl?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MessageContentConfig {
|
|
18
|
+
/** File context configuration */
|
|
19
|
+
fileContext?: FileContextConfig;
|
|
20
|
+
/** Function to check if vision is supported */
|
|
21
|
+
isCanUseVision?: (model: string, provider: string) => boolean | undefined;
|
|
22
|
+
/** Model name */
|
|
23
|
+
model: string;
|
|
24
|
+
/** Provider name */
|
|
25
|
+
provider: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UserMessageContentPart {
|
|
29
|
+
image_url?: {
|
|
30
|
+
detail?: string;
|
|
31
|
+
url: string;
|
|
32
|
+
};
|
|
33
|
+
signature?: string;
|
|
34
|
+
text?: string;
|
|
35
|
+
thinking?: string;
|
|
36
|
+
type: 'text' | 'image_url' | 'thinking';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Message Content Processor
|
|
41
|
+
* Responsible for handling content format conversion of user and assistant messages
|
|
42
|
+
*/
|
|
43
|
+
export class MessageContentProcessor extends BaseProcessor {
|
|
44
|
+
readonly name = 'MessageContentProcessor';
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
private config: MessageContentConfig,
|
|
48
|
+
options: ProcessorOptions = {},
|
|
49
|
+
) {
|
|
50
|
+
super(options);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
|
|
54
|
+
const clonedContext = this.cloneContext(context);
|
|
55
|
+
|
|
56
|
+
let processedCount = 0;
|
|
57
|
+
let userMessagesProcessed = 0;
|
|
58
|
+
let assistantMessagesProcessed = 0;
|
|
59
|
+
|
|
60
|
+
// 处理每条消息的内容
|
|
61
|
+
for (let i = 0; i < clonedContext.messages.length; i++) {
|
|
62
|
+
const message = clonedContext.messages[i];
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
let updatedMessage = message;
|
|
66
|
+
|
|
67
|
+
if (message.role === 'user') {
|
|
68
|
+
updatedMessage = await this.processUserMessage(message);
|
|
69
|
+
if (updatedMessage !== message) {
|
|
70
|
+
userMessagesProcessed++;
|
|
71
|
+
processedCount++;
|
|
72
|
+
}
|
|
73
|
+
} else if (message.role === 'assistant') {
|
|
74
|
+
updatedMessage = await this.processAssistantMessage(message);
|
|
75
|
+
if (updatedMessage !== message) {
|
|
76
|
+
assistantMessagesProcessed++;
|
|
77
|
+
processedCount++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (updatedMessage !== message) {
|
|
82
|
+
clonedContext.messages[i] = updatedMessage;
|
|
83
|
+
log(`Processed message content ${message.id}, role: ${message.role}`);
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
log.extend('error')(`Error processing message ${message.id} content: ${error}`);
|
|
87
|
+
// 继续处理其他消息
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 更新元数据
|
|
92
|
+
clonedContext.metadata.messageContentProcessed = processedCount;
|
|
93
|
+
clonedContext.metadata.userMessagesProcessed = userMessagesProcessed;
|
|
94
|
+
clonedContext.metadata.assistantMessagesProcessed = assistantMessagesProcessed;
|
|
95
|
+
|
|
96
|
+
log(
|
|
97
|
+
`Message content processing completed, processed ${processedCount} messages (user: ${userMessagesProcessed}, assistant: ${assistantMessagesProcessed})`,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return this.markAsExecuted(clonedContext);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Process user message content
|
|
105
|
+
*/
|
|
106
|
+
private async processUserMessage(message: any): Promise<any> {
|
|
107
|
+
// Check if images or files need processing
|
|
108
|
+
const hasImages = message.imageList && message.imageList.length > 0;
|
|
109
|
+
const hasFiles = message.fileList && message.fileList.length > 0;
|
|
110
|
+
|
|
111
|
+
// If no images and files, return plain text content directly
|
|
112
|
+
if (!hasImages && !hasFiles) {
|
|
113
|
+
return {
|
|
114
|
+
...message,
|
|
115
|
+
content: message.content,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const contentParts: UserMessageContentPart[] = [];
|
|
120
|
+
|
|
121
|
+
// Add text content
|
|
122
|
+
let textContent = message.content || '';
|
|
123
|
+
|
|
124
|
+
// Add file context (if file context is enabled and has files or images)
|
|
125
|
+
if ((hasFiles || hasImages) && this.config.fileContext?.enabled) {
|
|
126
|
+
const filesContext = filesPrompts({
|
|
127
|
+
addUrl: this.config.fileContext.includeFileUrl ?? true,
|
|
128
|
+
fileList: message.fileList,
|
|
129
|
+
imageList: message.imageList,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (filesContext) {
|
|
133
|
+
textContent = (textContent + '\n\n' + filesContext).trim();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Add text part
|
|
138
|
+
if (textContent) {
|
|
139
|
+
contentParts.push({
|
|
140
|
+
text: textContent,
|
|
141
|
+
type: 'text',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Process image content
|
|
146
|
+
if (hasImages && this.config.isCanUseVision?.(this.config.model, this.config.provider)) {
|
|
147
|
+
const imageContentParts = await this.processImageList(message.imageList || []);
|
|
148
|
+
contentParts.push(...imageContentParts);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 明确返回的字段,只保留必要的消息字段
|
|
152
|
+
const hasFileContext = (hasFiles || hasImages) && this.config.fileContext?.enabled;
|
|
153
|
+
const hasVisionContent =
|
|
154
|
+
hasImages && this.config.isCanUseVision?.(this.config.model, this.config.provider);
|
|
155
|
+
|
|
156
|
+
// 如果只有文本内容且没有添加文件上下文也没有视觉内容,返回纯文本
|
|
157
|
+
if (
|
|
158
|
+
contentParts.length === 1 &&
|
|
159
|
+
contentParts[0].type === 'text' &&
|
|
160
|
+
!hasFileContext &&
|
|
161
|
+
!hasVisionContent
|
|
162
|
+
) {
|
|
163
|
+
return {
|
|
164
|
+
content: contentParts[0].text,
|
|
165
|
+
createdAt: message.createdAt,
|
|
166
|
+
id: message.id,
|
|
167
|
+
meta: message.meta,
|
|
168
|
+
role: message.role,
|
|
169
|
+
updatedAt: message.updatedAt,
|
|
170
|
+
// 保留其他可能需要的字段,但移除已处理的文件相关字段
|
|
171
|
+
...(message.tools && { tools: message.tools }),
|
|
172
|
+
...(message.tool_calls && { tool_calls: message.tool_calls }),
|
|
173
|
+
...(message.tool_call_id && { tool_call_id: message.tool_call_id }),
|
|
174
|
+
...(message.name && { name: message.name }),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 返回结构化内容
|
|
179
|
+
return {
|
|
180
|
+
content: contentParts,
|
|
181
|
+
createdAt: message.createdAt,
|
|
182
|
+
id: message.id,
|
|
183
|
+
meta: message.meta,
|
|
184
|
+
role: message.role,
|
|
185
|
+
updatedAt: message.updatedAt,
|
|
186
|
+
// 保留其他可能需要的字段,但移除已处理的文件相关字段
|
|
187
|
+
...(message.tools && { tools: message.tools }),
|
|
188
|
+
...(message.tool_calls && { tool_calls: message.tool_calls }),
|
|
189
|
+
...(message.tool_call_id && { tool_call_id: message.tool_call_id }),
|
|
190
|
+
...(message.name && { name: message.name }),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 处理助手消息内容
|
|
196
|
+
*/
|
|
197
|
+
private async processAssistantMessage(message: any): Promise<any> {
|
|
198
|
+
// 检查是否有推理内容(thinking mode)
|
|
199
|
+
const shouldIncludeThinking = message.reasoning && !!message.reasoning?.signature;
|
|
200
|
+
|
|
201
|
+
if (shouldIncludeThinking) {
|
|
202
|
+
const contentParts: UserMessageContentPart[] = [
|
|
203
|
+
{
|
|
204
|
+
signature: message.reasoning!.signature,
|
|
205
|
+
thinking: message.reasoning!.content,
|
|
206
|
+
type: 'thinking',
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
text: message.content,
|
|
210
|
+
type: 'text',
|
|
211
|
+
},
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
...message,
|
|
216
|
+
content: contentParts,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 检查是否有图片(助手消息也可能包含图片)
|
|
221
|
+
const hasImages = message.imageList && message.imageList.length > 0;
|
|
222
|
+
|
|
223
|
+
if (hasImages && this.config.isCanUseVision?.(this.config.model, this.config.provider)) {
|
|
224
|
+
// 创建结构化内容
|
|
225
|
+
const contentParts: UserMessageContentPart[] = [];
|
|
226
|
+
|
|
227
|
+
if (message.content) {
|
|
228
|
+
contentParts.push({
|
|
229
|
+
text: message.content,
|
|
230
|
+
type: 'text',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 处理图片内容
|
|
235
|
+
const imageContentParts = await this.processImageList(message.imageList || []);
|
|
236
|
+
contentParts.push(...imageContentParts);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
...message,
|
|
240
|
+
content: contentParts,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 普通助手消息,返回纯文本内容
|
|
245
|
+
return {
|
|
246
|
+
...message,
|
|
247
|
+
content: message.content,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 处理图片列表
|
|
253
|
+
*/
|
|
254
|
+
private async processImageList(imageList: any[]): Promise<UserMessageContentPart[]> {
|
|
255
|
+
if (!imageList || imageList.length === 0) {
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return Promise.all(
|
|
260
|
+
imageList.map(async (image) => {
|
|
261
|
+
const { type } = parseDataUri(image.url);
|
|
262
|
+
|
|
263
|
+
let processedUrl = image.url;
|
|
264
|
+
if (type === 'url' && isLocalUrl(image.url)) {
|
|
265
|
+
const { base64, mimeType } = await imageUrlToBase64(image.url);
|
|
266
|
+
processedUrl = `data:${mimeType};base64,${base64}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
image_url: { detail: 'auto', url: processedUrl },
|
|
271
|
+
type: 'image_url',
|
|
272
|
+
} as UserMessageContentPart;
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 验证内容部分格式
|
|
279
|
+
*/
|
|
280
|
+
private validateContentPart(part: UserMessageContentPart): boolean {
|
|
281
|
+
if (!part || !part.type) return false;
|
|
282
|
+
|
|
283
|
+
switch (part.type) {
|
|
284
|
+
case 'text': {
|
|
285
|
+
return typeof part.text === 'string';
|
|
286
|
+
}
|
|
287
|
+
case 'image_url': {
|
|
288
|
+
return !!(part.image_url && part.image_url.url);
|
|
289
|
+
}
|
|
290
|
+
case 'thinking': {
|
|
291
|
+
return !!(part.thinking && part.signature);
|
|
292
|
+
}
|
|
293
|
+
default: {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
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:PlaceholderVariablesProcessor');
|
|
7
|
+
|
|
8
|
+
const placeholderVariablesRegex = /{{(.*?)}}/g;
|
|
9
|
+
|
|
10
|
+
export interface PlaceholderVariablesConfig {
|
|
11
|
+
/** Recursive parsing depth, default is 2 */
|
|
12
|
+
depth?: number;
|
|
13
|
+
/** Variable generators mapping, key is variable name, value is generator function */
|
|
14
|
+
variableGenerators: Record<string, () => string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extract all {{variable}} placeholder variable names from text
|
|
19
|
+
* @param text String containing template variables
|
|
20
|
+
* @returns Array of variable names, e.g. ['date', 'nickname']
|
|
21
|
+
*/
|
|
22
|
+
const extractPlaceholderVariables = (text: string): string[] => {
|
|
23
|
+
const matches = [...text.matchAll(placeholderVariablesRegex)];
|
|
24
|
+
return matches.map((m) => m[1].trim());
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Replace template variables with actual values, supporting recursive parsing of nested variables
|
|
29
|
+
* @param text - Original text containing variables
|
|
30
|
+
* @param variableGenerators - Variable generators mapping
|
|
31
|
+
* @param depth - Recursive depth, default 2, set higher to support {{date}} within {{text}}
|
|
32
|
+
* @returns Text with variables replaced
|
|
33
|
+
*/
|
|
34
|
+
export const parsePlaceholderVariables = (
|
|
35
|
+
text: string,
|
|
36
|
+
variableGenerators: Record<string, () => string>,
|
|
37
|
+
depth = 2,
|
|
38
|
+
): string => {
|
|
39
|
+
let result = text;
|
|
40
|
+
|
|
41
|
+
// Recursive parsing to handle cases like {{text}} containing additional preset variables
|
|
42
|
+
for (let i = 0; i < depth; i++) {
|
|
43
|
+
try {
|
|
44
|
+
const extractedVariables = extractPlaceholderVariables(result);
|
|
45
|
+
const availableVariables = Object.fromEntries(
|
|
46
|
+
extractedVariables
|
|
47
|
+
.map((key) => [key, variableGenerators[key]?.()])
|
|
48
|
+
.filter(([, value]) => value !== undefined),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Only perform replacement when there are available variables
|
|
52
|
+
if (Object.keys(availableVariables).length === 0) break;
|
|
53
|
+
|
|
54
|
+
// Replace variables one by one to avoid lodash template's error handling for undefined variables
|
|
55
|
+
let tempResult = result;
|
|
56
|
+
for (const [key, value] of Object.entries(availableVariables)) {
|
|
57
|
+
const regex = new RegExp(
|
|
58
|
+
`{{\\s*${key.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&')}\\s*}}`,
|
|
59
|
+
'g',
|
|
60
|
+
);
|
|
61
|
+
// @ts-ignore
|
|
62
|
+
tempResult = tempResult.replace(regex, value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (tempResult === result) break;
|
|
66
|
+
result = tempResult;
|
|
67
|
+
} catch {
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return result;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse message content and replace placeholder variables
|
|
77
|
+
* @param messages Original messages array
|
|
78
|
+
* @param variableGenerators Variable generators mapping
|
|
79
|
+
* @param depth Recursive parsing depth, default is 2
|
|
80
|
+
* @returns Processed messages array
|
|
81
|
+
*/
|
|
82
|
+
export const parsePlaceholderVariablesMessages = (
|
|
83
|
+
messages: any[],
|
|
84
|
+
variableGenerators: Record<string, () => string>,
|
|
85
|
+
depth = 2,
|
|
86
|
+
): any[] =>
|
|
87
|
+
messages.map((message) => {
|
|
88
|
+
if (!message?.content) return message;
|
|
89
|
+
|
|
90
|
+
const { content } = message;
|
|
91
|
+
|
|
92
|
+
// Handle string type directly
|
|
93
|
+
if (typeof content === 'string') {
|
|
94
|
+
return { ...message, content: parsePlaceholderVariables(content, variableGenerators, depth) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Handle array type by processing text elements
|
|
98
|
+
if (Array.isArray(content)) {
|
|
99
|
+
return {
|
|
100
|
+
...message,
|
|
101
|
+
content: content.map((item) =>
|
|
102
|
+
item?.type === 'text'
|
|
103
|
+
? { ...item, text: parsePlaceholderVariables(item.text, variableGenerators, depth) }
|
|
104
|
+
: item,
|
|
105
|
+
),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return message;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* PlaceholderVariables Processor
|
|
114
|
+
* Responsible for handling placeholder variable replacement in messages
|
|
115
|
+
*/
|
|
116
|
+
export class PlaceholderVariablesProcessor extends BaseProcessor {
|
|
117
|
+
readonly name = 'PlaceholderVariablesProcessor';
|
|
118
|
+
|
|
119
|
+
constructor(
|
|
120
|
+
private config: PlaceholderVariablesConfig,
|
|
121
|
+
options: ProcessorOptions = {},
|
|
122
|
+
) {
|
|
123
|
+
super(options);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
|
|
127
|
+
const clonedContext = this.cloneContext(context);
|
|
128
|
+
|
|
129
|
+
let processedCount = 0;
|
|
130
|
+
const depth = this.config.depth ?? 2;
|
|
131
|
+
|
|
132
|
+
log(
|
|
133
|
+
`Starting placeholder variables processing with ${Object.keys(this.config.variableGenerators).length} generators`,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// 处理每条消息的占位符变量
|
|
137
|
+
for (let i = 0; i < clonedContext.messages.length; i++) {
|
|
138
|
+
const message = clonedContext.messages[i];
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const originalMessage = JSON.stringify(message);
|
|
142
|
+
const processedMessage = this.processMessagePlaceholders(message, depth);
|
|
143
|
+
|
|
144
|
+
if (JSON.stringify(processedMessage) !== originalMessage) {
|
|
145
|
+
clonedContext.messages[i] = processedMessage;
|
|
146
|
+
processedCount++;
|
|
147
|
+
log(`Processed placeholders in message ${message.id}, role: ${message.role}`);
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
log.extend('error')(`Error processing placeholders in message ${message.id}: ${error}`);
|
|
151
|
+
// 继续处理其他消息
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 更新元数据
|
|
156
|
+
clonedContext.metadata.placeholderVariablesProcessed = processedCount;
|
|
157
|
+
|
|
158
|
+
log(`Placeholder variables processing completed, processed ${processedCount} messages`);
|
|
159
|
+
|
|
160
|
+
return this.markAsExecuted(clonedContext);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 处理单个消息的占位符变量
|
|
165
|
+
*/
|
|
166
|
+
private processMessagePlaceholders(message: any, depth: number): any {
|
|
167
|
+
if (!message?.content) return message;
|
|
168
|
+
|
|
169
|
+
const { content } = message;
|
|
170
|
+
|
|
171
|
+
// Handle string type directly
|
|
172
|
+
if (typeof content === 'string') {
|
|
173
|
+
return {
|
|
174
|
+
...message,
|
|
175
|
+
content: parsePlaceholderVariables(content, this.config.variableGenerators, depth),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Handle array type by processing text elements
|
|
180
|
+
if (Array.isArray(content)) {
|
|
181
|
+
return {
|
|
182
|
+
...message,
|
|
183
|
+
content: content.map((item) =>
|
|
184
|
+
item?.type === 'text'
|
|
185
|
+
? {
|
|
186
|
+
...item,
|
|
187
|
+
text: parsePlaceholderVariables(item.text, this.config.variableGenerators, depth),
|
|
188
|
+
}
|
|
189
|
+
: item,
|
|
190
|
+
),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return message;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
|
|
3
|
+
import { BaseProcessor } from '../base/BaseProcessor';
|
|
4
|
+
import type { MessageToolCall, PipelineContext, ProcessorOptions } from '../types';
|
|
5
|
+
|
|
6
|
+
const log = debug('context-engine:processor:ToolCallProcessor');
|
|
7
|
+
|
|
8
|
+
export interface ToolCallConfig {
|
|
9
|
+
/** Function to generate tool calling name */
|
|
10
|
+
genToolCallingName?: (identifier: string, apiName: string, type?: string) => string;
|
|
11
|
+
/** Function to check if function calling is supported */
|
|
12
|
+
isCanUseFC?: (model: string, provider: string) => boolean;
|
|
13
|
+
/** Model name */
|
|
14
|
+
model: string;
|
|
15
|
+
/** Provider name */
|
|
16
|
+
provider: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Tool Call Processor
|
|
21
|
+
* Responsible for converting ChatMessage format tool calls to OpenAI format
|
|
22
|
+
*/
|
|
23
|
+
export class ToolCallProcessor extends BaseProcessor {
|
|
24
|
+
readonly name = 'ToolCallProcessor';
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
private config: ToolCallConfig,
|
|
28
|
+
options: ProcessorOptions = {},
|
|
29
|
+
) {
|
|
30
|
+
super(options);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
|
|
34
|
+
const clonedContext = this.cloneContext(context);
|
|
35
|
+
|
|
36
|
+
const supportTools = this.config.isCanUseFC
|
|
37
|
+
? this.config.isCanUseFC(this.config.model, this.config.provider)
|
|
38
|
+
: true;
|
|
39
|
+
|
|
40
|
+
let processedCount = 0;
|
|
41
|
+
let toolCallsConverted = 0;
|
|
42
|
+
let toolMessagesConverted = 0;
|
|
43
|
+
|
|
44
|
+
// 处理每条消息的工具调用
|
|
45
|
+
for (let i = 0; i < clonedContext.messages.length; i++) {
|
|
46
|
+
const message = clonedContext.messages[i];
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const updatedMessage = await this.processMessage(message, supportTools);
|
|
50
|
+
|
|
51
|
+
if (updatedMessage !== message) {
|
|
52
|
+
processedCount++;
|
|
53
|
+
clonedContext.messages[i] = updatedMessage;
|
|
54
|
+
|
|
55
|
+
// 统计转换的工具调用和工具消息数量
|
|
56
|
+
if (message.role === 'assistant' && message.tools) {
|
|
57
|
+
toolCallsConverted += message.tools.length;
|
|
58
|
+
}
|
|
59
|
+
if (message.role === 'tool') {
|
|
60
|
+
toolMessagesConverted++;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
log(`处理消息 ${message.id},角色: ${message.role}`);
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
log.extend('error')(`处理消息 ${message.id} 工具调用时出错: ${error}`);
|
|
67
|
+
// 继续处理其他消息
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 更新元数据
|
|
72
|
+
clonedContext.metadata.toolCallProcessed = processedCount;
|
|
73
|
+
clonedContext.metadata.toolCallsConverted = toolCallsConverted;
|
|
74
|
+
clonedContext.metadata.toolMessagesConverted = toolMessagesConverted;
|
|
75
|
+
clonedContext.metadata.supportTools = supportTools;
|
|
76
|
+
|
|
77
|
+
log(
|
|
78
|
+
`Tool call processing completed, processed ${processedCount} messages, converted ${toolCallsConverted} tool calls, ${toolMessagesConverted} tool messages`,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return this.markAsExecuted(clonedContext);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 处理单条消息的工具调用
|
|
86
|
+
*/
|
|
87
|
+
private async processMessage(message: any, supportTools: boolean): Promise<any> {
|
|
88
|
+
switch (message.role) {
|
|
89
|
+
case 'assistant': {
|
|
90
|
+
return this.processAssistantMessage(message, supportTools);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
case 'tool': {
|
|
94
|
+
return this.processToolMessage(message, supportTools);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
default: {
|
|
98
|
+
return message;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 处理助手消息的工具调用
|
|
105
|
+
*/
|
|
106
|
+
private processAssistantMessage(message: any, supportTools: boolean): any {
|
|
107
|
+
// 检查是否有工具调用
|
|
108
|
+
const hasTools = message.tools && message.tools.length > 0;
|
|
109
|
+
const hasEmptyToolCalls = message.tool_calls && message.tool_calls.length === 0;
|
|
110
|
+
|
|
111
|
+
if (!supportTools || (!hasTools && hasEmptyToolCalls)) {
|
|
112
|
+
// 如果不支持工具或只有空的工具调用,返回普通消息(移除工具相关属性)
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
114
|
+
const { tools, tool_calls, ...messageWithoutTools } = message;
|
|
115
|
+
return messageWithoutTools;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!hasTools) {
|
|
119
|
+
// 如果没有 tools 但有其他工具调用属性,只移除 tools
|
|
120
|
+
return message;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 将 tools 转换为 tool_calls 格式
|
|
124
|
+
const tool_calls = message.tools.map(
|
|
125
|
+
(tool: any): MessageToolCall => ({
|
|
126
|
+
function: {
|
|
127
|
+
arguments: tool.arguments,
|
|
128
|
+
name: this.config.genToolCallingName
|
|
129
|
+
? this.config.genToolCallingName(tool.identifier, tool.apiName, tool.type)
|
|
130
|
+
: `${tool.identifier}.${tool.apiName}`,
|
|
131
|
+
},
|
|
132
|
+
id: tool.id,
|
|
133
|
+
type: 'function',
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
return { ...message, tool_calls };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 处理工具消息
|
|
142
|
+
*/
|
|
143
|
+
private processToolMessage(message: any, supportTools: boolean): any {
|
|
144
|
+
if (!supportTools) {
|
|
145
|
+
// 如果不支持工具,将工具消息转换为用户消息
|
|
146
|
+
return {
|
|
147
|
+
...message,
|
|
148
|
+
name: undefined,
|
|
149
|
+
plugin: undefined,
|
|
150
|
+
role: 'user',
|
|
151
|
+
tool_call_id: undefined,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 生成工具名称
|
|
156
|
+
const toolName = message.plugin
|
|
157
|
+
? this.config.genToolCallingName
|
|
158
|
+
? this.config.genToolCallingName(
|
|
159
|
+
message.plugin.identifier,
|
|
160
|
+
message.plugin.apiName,
|
|
161
|
+
message.plugin.type,
|
|
162
|
+
)
|
|
163
|
+
: `${message.plugin.identifier}.${message.plugin.apiName}`
|
|
164
|
+
: undefined;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
...message,
|
|
168
|
+
name: toolName,
|
|
169
|
+
// 保留 tool_call_id 用于关联
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 验证工具调用格式
|
|
175
|
+
*/
|
|
176
|
+
private validateToolCall(tool: any): boolean {
|
|
177
|
+
return !!(tool && tool.id && tool.identifier && tool.apiName && tool.arguments);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 验证工具消息格式
|
|
182
|
+
*/
|
|
183
|
+
private validateToolMessage(message: any): boolean {
|
|
184
|
+
return !!(message && message.tool_call_id && message.content !== undefined);
|
|
185
|
+
}
|
|
186
|
+
}
|