@@ -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 */
6262export 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 */
407333export 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