Skip to content

Commit 71354ff

Browse files
committed
fix: adapt messages
1 parent fb1b2db commit 71354ff

2 files changed

Lines changed: 852 additions & 215 deletions

File tree

packages/global/core/chat/adapt.ts

Lines changed: 114 additions & 167 deletions
Original file line numberDiff line numberDiff line change
@@ -52,67 +52,15 @@ export const simpleUserContentPart = (content: ChatCompletionContentPart[]) => {
5252
/**
5353
* 规整 assistant 拆分字段消息。
5454
*
55-
* FastGPT 历史可能把 reasoning、text、tools 拆成多个 value;转成 GPT message 时需要
56-
* 重新合并成 provider 能接受的 assistant payload
57-
* - reasoning 不能作为孤立 assistant message 发送,必须附着到后续 text/tool/function。
58-
* - 连续 reasoning 使用空行拼接,再附着到同一轮的后续载体上
59-
* - dataId/hideInUI 不同表示不同上下文边界,不能跨边界合并
60-
* - 没有可附着目标的孤立 reasoning 会被丢弃,避免生成非法上下文
55+
* FastGPT 历史为了 UI 展示会把 reasoning、text、tools 拆成多个 value;转成 GPT message
56+
* 时需要合并为 provider 能接受的 assistant message
57+
* - 只合并相邻 assistant message,不跨 user/tool/system/function 等 role 处理
58+
* - reasoning_content 和 content 直接字符串拼接;这些拆分通常来自历史兼容,不能额外插入换行
59+
* - tool_calls 合并为同一个数组;function_call 理论上一轮只有一个,异常重复时以后者覆盖前者
60+
* - dataId/hideInUI 不同表示来自不同轮次或不同可见性上下文,不能跨边界合并
6161
*/
6262
export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam[]) => {
63-
type AssistantToolCallMessage = Extract<ChatCompletionMessageParam, { role: 'assistant' }> & {
64-
tool_calls: ChatCompletionMessageToolCall[];
65-
};
66-
67-
type ToolMessage = Extract<ChatCompletionMessageParam, { role: 'tool' }> & {
68-
tool_call_id: string;
69-
};
70-
71-
// 多段 reasoning 属于同一个待发送 payload 时,用空行拼接,便于上下文阅读。
72-
const appendSeparatedText = (current: string | undefined, next: string) => {
73-
if (!next) return current;
74-
return current ? `${current}\n\n${next}` : next;
75-
};
76-
77-
const hasAssistantToolCalls = (message: ChatCompletionMessageParam) =>
78-
message.role === ChatCompletionRequestMessageRoleEnum.Assistant &&
79-
Array.isArray(message.tool_calls) &&
80-
message.tool_calls.length > 0;
81-
82-
const hasAssistantFunctionCall = (message: ChatCompletionMessageParam) =>
83-
message.role === ChatCompletionRequestMessageRoleEnum.Assistant &&
84-
Boolean(message.function_call);
85-
86-
const isAssistantFieldMessage = (message?: ChatCompletionMessageParam) => {
87-
if (message?.role !== ChatCompletionRequestMessageRoleEnum.Assistant) return false;
88-
89-
return (
90-
!message.tool_calls &&
91-
(typeof message.content === 'string' || typeof message.reasoning_content === 'string')
92-
);
93-
};
94-
95-
const isAssistantToolCallMessage = (
96-
message?: ChatCompletionMessageParam
97-
): message is AssistantToolCallMessage => {
98-
if (message?.role !== ChatCompletionRequestMessageRoleEnum.Assistant) return false;
99-
100-
const hasNoAssistantText =
101-
message.content === undefined || message.content === null || message.content === '';
102-
103-
return (
104-
hasAssistantToolCalls(message) &&
105-
hasNoAssistantText &&
106-
typeof message.reasoning_content !== 'string'
107-
);
108-
};
109-
110-
const isToolResponseForToolCalls = (
111-
message: ChatCompletionMessageParam | undefined,
112-
toolCallIds: Set<string>
113-
): message is ToolMessage =>
114-
message?.role === ChatCompletionRequestMessageRoleEnum.Tool &&
115-
toolCallIds.has(message.tool_call_id || '');
63+
type AssistantMessage = Extract<ChatCompletionMessageParam, { role: 'assistant' }>;
11664

11765
const hasSameAssistantContext = (
11866
message: ChatCompletionMessageParam | undefined,
@@ -121,94 +69,55 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam
12169
(message?.hideInUI ?? false) === (assistantMessage.hideInUI ?? false) &&
12270
message?.dataId === assistantMessage.dataId;
12371

72+
const appendText = (current: unknown, next: unknown) => {
73+
if (typeof next !== 'string') return current;
74+
return typeof current === 'string' ? `${current}${next}` : next;
75+
};
76+
77+
const mergeAssistantMessage = (current: AssistantMessage, next: AssistantMessage) => {
78+
current.reasoning_content = appendText(current.reasoning_content, next.reasoning_content) as
79+
| string
80+
| undefined;
81+
current.content = appendText(current.content, next.content) as AssistantMessage['content'];
82+
83+
if (Array.isArray(next.tool_calls) && next.tool_calls.length) {
84+
current.tool_calls = [...(current.tool_calls || []), ...next.tool_calls];
85+
}
86+
87+
if (next.function_call) {
88+
current.function_call = next.function_call;
89+
}
90+
};
91+
12492
const mergedMessages: ChatCompletionMessageParam[] = [];
12593

12694
for (let index = 0; index < messages.length; index++) {
12795
const currentMessage = messages[index];
128-
if (!isAssistantFieldMessage(currentMessage)) {
96+
if (currentMessage.role !== ChatCompletionRequestMessageRoleEnum.Assistant) {
12997
mergedMessages.push(currentMessage);
13098
continue;
13199
}
132100

133-
const assistantMessage: ChatCompletionMessageParam = { ...currentMessage };
101+
const assistantMessage: AssistantMessage = {
102+
...currentMessage,
103+
...(Array.isArray(currentMessage.tool_calls)
104+
? { tool_calls: [...currentMessage.tool_calls] }
105+
: {})
106+
};
134107
let cursor = index + 1;
135108

136-
// 先消费同一上下文内连续的 assistant 字段消息,例如 reasoning -> reasoning -> text。
137-
while (isAssistantFieldMessage(messages[cursor])) {
138-
const nextMessage = messages[cursor];
139-
if (!hasSameAssistantContext(nextMessage, assistantMessage)) break;
140-
141-
const nextReasoning =
142-
typeof nextMessage.reasoning_content === 'string' ? nextMessage.reasoning_content : '';
143-
const nextContent = typeof nextMessage.content === 'string' ? nextMessage.content : '';
144-
145-
// text 后再次出现 reasoning 通常已经是下一段思考,不能挂回前一个 answer。
146-
if (nextReasoning && typeof assistantMessage.content === 'string') {
147-
break;
148-
}
149-
150-
if (nextReasoning) {
151-
assistantMessage.reasoning_content = appendSeparatedText(
152-
typeof assistantMessage.reasoning_content === 'string'
153-
? assistantMessage.reasoning_content
154-
: undefined,
155-
nextReasoning
156-
);
157-
}
158-
159-
if (typeof nextMessage.content === 'string') {
160-
if (typeof assistantMessage.content === 'string') {
161-
assistantMessage.content += nextContent;
162-
} else {
163-
assistantMessage.content = nextContent;
164-
}
165-
}
166-
109+
while (
110+
messages[cursor]?.role === ChatCompletionRequestMessageRoleEnum.Assistant &&
111+
hasSameAssistantContext(messages[cursor], assistantMessage)
112+
) {
113+
mergeAssistantMessage(
114+
assistantMessage,
115+
messages[cursor] as Extract<ChatCompletionMessageParam, { role: 'assistant' }>
116+
);
167117
cursor++;
168118
}
169119

170-
const toolCalls: ChatCompletionMessageToolCall[] = [];
171-
const toolResponses: ChatCompletionMessageParam[] = [];
172-
173-
// 再消费紧随其后的 tool_calls 与对应 tool response,恢复为一个 assistant payload。
174-
let assistantToolMessage: ChatCompletionMessageParam | undefined = messages[cursor];
175-
while (isAssistantToolCallMessage(assistantToolMessage)) {
176-
if (!hasSameAssistantContext(assistantToolMessage, assistantMessage)) break;
177-
178-
const currentToolCalls = assistantToolMessage.tool_calls;
179-
const currentToolCallIds = new Set(currentToolCalls.map((toolCall) => toolCall.id));
180-
const currentToolResponses: ChatCompletionMessageParam[] = [];
181-
let responseCursor = cursor + 1;
182-
183-
// 只消费属于当前 tool_calls 的 response,防止把下一轮工具结果串进来。
184-
while (isToolResponseForToolCalls(messages[responseCursor], currentToolCallIds)) {
185-
currentToolResponses.push(messages[responseCursor]);
186-
responseCursor++;
187-
}
188-
189-
toolCalls.push(...currentToolCalls);
190-
toolResponses.push(...currentToolResponses);
191-
cursor = responseCursor;
192-
assistantToolMessage = messages[cursor];
193-
}
194-
195-
if (!toolCalls.length) {
196-
// 孤立 reasoning 没有合法载体;只有 text/function_call 才能保留。
197-
if (
198-
typeof assistantMessage.content === 'string' ||
199-
hasAssistantFunctionCall(assistantMessage)
200-
) {
201-
mergedMessages.push(assistantMessage);
202-
}
203-
index = cursor - 1;
204-
continue;
205-
}
206-
207-
mergedMessages.push({
208-
...assistantMessage,
209-
tool_calls: toolCalls
210-
} as ChatCompletionMessageParam);
211-
mergedMessages.push(...toolResponses);
120+
mergedMessages.push(assistantMessage);
212121
index = cursor - 1;
213122
}
214123

@@ -289,6 +198,13 @@ export const chats2GPTMessages = ({
289198
}
290199
} else {
291200
const aiResults: ChatCompletionMessageParam[] = [];
201+
const pendingRuntimeToolResponses: ChatCompletionToolMessageParam[] = [];
202+
const flushPendingRuntimeToolResponses = () => {
203+
if (!pendingRuntimeToolResponses.length) return;
204+
205+
aiResults.push(...pendingRuntimeToolResponses);
206+
pendingRuntimeToolResponses.length = 0;
207+
};
292208

293209
item.value.forEach((value) => {
294210
/* Plan agent 产生的上下文都需要合并到一个 toolCall 里。
@@ -297,6 +213,15 @@ export const chats2GPTMessages = ({
297213
*/
298214
if (value.planId && !value.plan) return;
299215

216+
const startsNewAssistantPayload =
217+
Boolean(value.plan) ||
218+
typeof value.reasoning?.content === 'string' ||
219+
typeof value.text?.content === 'string';
220+
221+
if (startsNewAssistantPayload) {
222+
flushPendingRuntimeToolResponses();
223+
}
224+
300225
const hasTools = Array.isArray(value.tools) && value.tools.length > 0;
301226

302227
if (reserveReason && typeof value.reasoning?.content === 'string') {
@@ -345,7 +270,7 @@ export const chats2GPTMessages = ({
345270
tool_calls
346271
});
347272

348-
aiResults.push(...toolResponse);
273+
pendingRuntimeToolResponses.push(...toolResponse);
349274
}
350275
if (value.plan) {
351276
// 查找该 Plan 产生的上下文,组成一个 toolcall
@@ -390,6 +315,7 @@ export const chats2GPTMessages = ({
390315
}
391316
});
392317

318+
flushPendingRuntimeToolResponses();
393319
results = results.concat(mergeAssistantFieldMessages(aiResults));
394320
}
395321
});
@@ -400,9 +326,9 @@ export const chats2GPTMessages = ({
400326
/**
401327
* 将 GPT messages 转回 FastGPT ChatItem。
402328
*
403-
* GPTMessages2Chats 只恢复当前 message 已经聚合好的结构,不跨 message 追踪 reasoning
404-
* reasoning_content 必须和 content/tool_calls/function_call 在同一个 assistant message 上才会恢复;
405-
* 孤立 reasoning 会被过滤,避免把下一轮或未知归属的思考误挂到后续消息
329+
* GPTMessages2Chats 会先清洗连续 assistant message,再做 message -> chat value 的结构转换
330+
* 这样不同 provider 或历史兼容格式拆出的 reasoning/text/tool_calls,都会先归一成一轮
331+
* assistant payload
406332
*/
407333
export const GPTMessages2Chats = ({
408334
messages,
@@ -415,7 +341,8 @@ export const GPTMessages2Chats = ({
415341
reserveReason?: boolean;
416342
getToolInfo?: (name: string) => { name: string; avatar: string };
417343
}): ChatItemMiniType[] => {
418-
const chatMessages = messages
344+
const normalizedMessages = mergeAssistantFieldMessages(messages);
345+
const chatMessages = normalizedMessages
419346
.map((item) => {
420347
const obj = GPT2Chat[item.role];
421348

@@ -498,13 +425,24 @@ export const GPTMessages2Chats = ({
498425
item.role === ChatCompletionRequestMessageRoleEnum.Assistant
499426
) {
500427
const value: AIChatItemValueItemType[] = [];
501-
const assistantValue: AIChatItemValueItemType = {};
428+
const valueVisibility = item.hideInUI ? { hideInUI: item.hideInUI } : {};
429+
const reasoning: Pick<AIChatItemValueItemType, 'reasoning'> =
430+
typeof item.reasoning_content === 'string' && item.reasoning_content && reserveReason
431+
? { reasoning: { content: item.reasoning_content } }
432+
: {};
433+
let hasAttachedReasoning = false;
502434

503435
if (typeof item.content === 'string' && item.content) {
504-
assistantValue.text = {
505-
content: item.content
506-
};
436+
value.push({
437+
...valueVisibility,
438+
...reasoning,
439+
text: {
440+
content: item.content
441+
}
442+
});
443+
hasAttachedReasoning = Boolean(reasoning.reasoning);
507444
}
445+
508446
if (item.tool_calls && reserveTool) {
509447
const toolCalls = item.tool_calls as ChatCompletionMessageToolCall[];
510448

@@ -514,7 +452,7 @@ export const GPTMessages2Chats = ({
514452
return [];
515453
}
516454
let toolResponse =
517-
messages.find(
455+
normalizedMessages.find(
518456
(msg) =>
519457
msg.role === ChatCompletionRequestMessageRoleEnum.Tool &&
520458
msg.tool_call_id === tool.id
@@ -535,43 +473,52 @@ export const GPTMessages2Chats = ({
535473
}
536474
];
537475
});
538-
assistantValue.tools = tools;
476+
if (tools.length) {
477+
value.push({
478+
...valueVisibility,
479+
...(!hasAttachedReasoning ? reasoning : {}),
480+
tools
481+
});
482+
hasAttachedReasoning = hasAttachedReasoning || Boolean(reasoning.reasoning);
483+
}
539484
}
485+
540486
if (item.function_call && reserveTool) {
541487
const functionCall = item.function_call as ChatCompletionMessageFunctionCall;
542-
const functionResponse = messages.find(
488+
const functionResponse = normalizedMessages.find(
543489
(msg) =>
544490
msg.role === ChatCompletionRequestMessageRoleEnum.Function &&
545491
msg.name === item.function_call?.name
546492
) as ChatCompletionFunctionMessageParam;
547493

548494
if (functionResponse) {
549-
assistantValue.tool = {
550-
id: functionCall.id || '',
551-
toolName: functionCall.toolName || '',
552-
toolAvatar: functionCall.toolAvatar || '',
553-
functionName: functionCall.name,
554-
params: functionCall.arguments,
555-
response: functionResponse.content || ''
556-
};
495+
value.push({
496+
...valueVisibility,
497+
...(!hasAttachedReasoning ? reasoning : {}),
498+
tool: {
499+
id: functionCall.id || '',
500+
toolName: functionCall.toolName || '',
501+
toolAvatar: functionCall.toolAvatar || '',
502+
functionName: functionCall.name,
503+
params: functionCall.arguments,
504+
response: functionResponse.content || ''
505+
}
506+
});
507+
hasAttachedReasoning = hasAttachedReasoning || Boolean(reasoning.reasoning);
557508
}
558509
}
559-
if (
560-
typeof item.reasoning_content === 'string' &&
561-
item.reasoning_content &&
562-
reserveReason &&
563-
(assistantValue.text || assistantValue.tools || assistantValue.tool)
564-
) {
565-
assistantValue.reasoning = {
566-
content: item.reasoning_content
567-
};
568-
}
569-
if (assistantValue.text || assistantValue.tools || assistantValue.tool) {
570-
value.push(assistantValue);
510+
511+
if (reasoning.reasoning && !hasAttachedReasoning) {
512+
value.push({
513+
...valueVisibility,
514+
...reasoning
515+
});
571516
}
517+
572518
if (item.interactive) {
573519
value.push({
574-
interactive: item.interactive
520+
interactive: item.interactive,
521+
...valueVisibility
575522
});
576523
}
577524

0 commit comments

Comments
 (0)