Commit 05254ce0 by Archer Committed by GitHub

perf: adapt messages (#6959)

parent 0f55590a
...@@ -15,3 +15,4 @@ description: 'FastGPT V4.14.21 更新说明' ...@@ -15,3 +15,4 @@ description: 'FastGPT V4.14.21 更新说明'
1. 工作流默认选中模型未回传表单值,导致看到的模型和实际运行模型不一致。 1. 工作流默认选中模型未回传表单值,导致看到的模型和实际运行模型不一致。
2. 工作流自动保存时,存在边丢失风险。 2. 工作流自动保存时,存在边丢失风险。
3. admin 修改系统通知弹窗时候报错。 3. admin 修改系统通知弹窗时候报错。
4. 工作流混用思考/非思考模型,可能出现独立 reason 字段上下文,导致模型调用报错。
...@@ -276,7 +276,7 @@ ...@@ -276,7 +276,7 @@
"content/self-host/upgrading/4-14/4149.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4149.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4149.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4149.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-15/4150.mdx": "2026-05-20T17:52:26+08:00", "content/self-host/upgrading/4-15/4150.mdx": "2026-05-20T17:52:26+08:00",
"content/self-host/upgrading/4-15/41502.mdx": "2026-05-18T19:47:26+08:00", "content/self-host/upgrading/4-15/41502.mdx": "2026-05-21T16:33:20+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......
...@@ -29,10 +29,18 @@ export const GPT2Chat = { ...@@ -29,10 +29,18 @@ export const GPT2Chat = {
[ChatCompletionRequestMessageRoleEnum.Tool]: ChatRoleEnum.AI [ChatCompletionRequestMessageRoleEnum.Tool]: ChatRoleEnum.AI
}; };
/**
* 将 OpenAI/GPT message role 映射为 FastGPT 内部聊天角色。
* function/tool message 本质上属于 AI 轮次的工具上下文,因此统一归到 AI。
*/
export function adaptRole_Message2Chat(role: `${ChatCompletionRequestMessageRoleEnum}`) { export function adaptRole_Message2Chat(role: `${ChatCompletionRequestMessageRoleEnum}`) {
return GPT2Chat[role]; return GPT2Chat[role];
} }
/**
* 压缩用户 content part:单纯文本保持旧版 string 结构,多模态或多段内容保留数组。
* 这样既兼容历史文本模型上下文,又不会丢失图片/文件等结构化输入。
*/
export const simpleUserContentPart = (content: ChatCompletionContentPart[]) => { export const simpleUserContentPart = (content: ChatCompletionContentPart[]) => {
if (content.length === 1 && content[0].type === 'text') { if (content.length === 1 && content[0].type === 'text') {
return content[0].text; return content[0].text;
...@@ -40,7 +48,7 @@ export const simpleUserContentPart = (content: ChatCompletionContentPart[]) => { ...@@ -40,7 +48,7 @@ export const simpleUserContentPart = (content: ChatCompletionContentPart[]) => {
return content; return content;
}; };
// 获取最后一个压缩的 messages // 获取最后一个压缩检查点的位置。检查点会替代它之前的普通上下文。
const getLatestCheckpointPosition = (messages: ChatItemMiniType[]) => { const getLatestCheckpointPosition = (messages: ChatItemMiniType[]) => {
for (let index = messages.length - 1; index >= 0; index--) { for (let index = messages.length - 1; index >= 0; index--) {
const item = messages[index]; const item = messages[index];
...@@ -59,7 +67,10 @@ const getLatestCheckpointPosition = (messages: ChatItemMiniType[]) => { ...@@ -59,7 +67,10 @@ const getLatestCheckpointPosition = (messages: ChatItemMiniType[]) => {
return; return;
}; };
// 找到最后一个包含压缩的 messages,且只取 compressIndex 后面的 value /**
* 根据最后一个 contextCheckpoint 重建待请求的历史。
* checkpoint 前只保留 System 历史,避免压缩后的上下文又叠加旧对话导致重复计入。
*/
const getCheckpointAwareMessages = (messages: ChatItemMiniType[]) => { const getCheckpointAwareMessages = (messages: ChatItemMiniType[]) => {
const checkpointPosition = getLatestCheckpointPosition(messages); const checkpointPosition = getLatestCheckpointPosition(messages);
if (!checkpointPosition) return messages; if (!checkpointPosition) return messages;
...@@ -83,6 +94,16 @@ const getCheckpointAwareMessages = (messages: ChatItemMiniType[]) => { ...@@ -83,6 +94,16 @@ const getCheckpointAwareMessages = (messages: ChatItemMiniType[]) => {
return [...systemMessages, ...checkpointAndRecentMessages]; return [...systemMessages, ...checkpointAndRecentMessages];
}; };
/**
* 规整 assistant 拆分字段消息。
*
* FastGPT 历史为了 UI 展示会把 reasoning、text、tools 拆成多个 value;转成 GPT message
* 时需要合并为 provider 能接受的 assistant message:
* - reasoning 不能作为孤立 assistant item 发送,必须附着到后续 text/tool/function payload。
* - 连续 reasoning 可以合并,reasoning 后接 text/tool_calls 时附着到同一个 assistant message。
* - dataId/hideInUI 不同表示来自不同轮次或不同可见性上下文,不能跨边界合并。
* - 没有可附着目标的孤立 reasoning 会被丢弃,避免生成只有 reasoning_content 的非法上下文。
*/
export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam[]) => { export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam[]) => {
type AssistantToolCallMessage = Extract<ChatCompletionMessageParam, { role: 'assistant' }> & { type AssistantToolCallMessage = Extract<ChatCompletionMessageParam, { role: 'assistant' }> & {
tool_calls: ChatCompletionMessageToolCall[]; tool_calls: ChatCompletionMessageToolCall[];
...@@ -92,6 +113,21 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam ...@@ -92,6 +113,21 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam
tool_call_id: string; tool_call_id: string;
}; };
// 多段 reasoning 代表同一次 assistant payload 的连续思考,使用空行拼接便于 UI 和上下文阅读。
const appendSeparatedText = (current: string | undefined, next: string) => {
if (!next) return current;
return current ? `${current}\n\n${next}` : next;
};
const hasAssistantToolCalls = (message: ChatCompletionMessageParam) =>
message.role === ChatCompletionRequestMessageRoleEnum.Assistant &&
Array.isArray(message.tool_calls) &&
message.tool_calls.length > 0;
const hasAssistantFunctionCall = (message: ChatCompletionMessageParam) =>
message.role === ChatCompletionRequestMessageRoleEnum.Assistant &&
Boolean(message.function_call);
const isAssistantFieldMessage = (message?: ChatCompletionMessageParam) => { const isAssistantFieldMessage = (message?: ChatCompletionMessageParam) => {
if (message?.role !== ChatCompletionRequestMessageRoleEnum.Assistant) return false; if (message?.role !== ChatCompletionRequestMessageRoleEnum.Assistant) return false;
...@@ -107,8 +143,7 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam ...@@ -107,8 +143,7 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam
if (message?.role !== ChatCompletionRequestMessageRoleEnum.Assistant) return false; if (message?.role !== ChatCompletionRequestMessageRoleEnum.Assistant) return false;
return ( return (
Array.isArray(message.tool_calls) && hasAssistantToolCalls(message) &&
message.tool_calls.length > 0 &&
typeof message.content !== 'string' && typeof message.content !== 'string' &&
typeof message.reasoning_content !== 'string' typeof message.reasoning_content !== 'string'
); );
...@@ -121,10 +156,12 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam ...@@ -121,10 +156,12 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam
message?.role === ChatCompletionRequestMessageRoleEnum.Tool && message?.role === ChatCompletionRequestMessageRoleEnum.Tool &&
toolCallIds.has(message.tool_call_id || ''); toolCallIds.has(message.tool_call_id || '');
const hasSameVisibility = ( const hasSameAssistantContext = (
message: ChatCompletionMessageParam | undefined, message: ChatCompletionMessageParam | undefined,
assistantMessage: ChatCompletionMessageParam assistantMessage: ChatCompletionMessageParam
) => (message?.hideInUI ?? false) === (assistantMessage.hideInUI ?? false); ) =>
(message?.hideInUI ?? false) === (assistantMessage.hideInUI ?? false) &&
message?.dataId === assistantMessage.dataId;
const mergedMessages: ChatCompletionMessageParam[] = []; const mergedMessages: ChatCompletionMessageParam[] = [];
...@@ -138,24 +175,27 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam ...@@ -138,24 +175,27 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam
const assistantMessage: ChatCompletionMessageParam = { ...currentMessage }; const assistantMessage: ChatCompletionMessageParam = { ...currentMessage };
let cursor = index + 1; let cursor = index + 1;
// 先吞掉同一轮连续的 assistant 字段消息,例如 reasoning -> reasoning -> text。
while (isAssistantFieldMessage(messages[cursor])) { while (isAssistantFieldMessage(messages[cursor])) {
const nextMessage = messages[cursor]; const nextMessage = messages[cursor];
if (!hasSameVisibility(nextMessage, assistantMessage)) break; if (!hasSameAssistantContext(nextMessage, assistantMessage)) break;
const nextReasoning = const nextReasoning =
typeof nextMessage.reasoning_content === 'string' ? nextMessage.reasoning_content : ''; typeof nextMessage.reasoning_content === 'string' ? nextMessage.reasoning_content : '';
const nextContent = typeof nextMessage.content === 'string' ? nextMessage.content : ''; const nextContent = typeof nextMessage.content === 'string' ? nextMessage.content : '';
if ( // text 后再次出现 reasoning 通常已经是下一段思考,不能挂回前一个 answer。
nextReasoning && if (nextReasoning && typeof assistantMessage.content === 'string') {
(typeof assistantMessage.reasoning_content === 'string' ||
typeof assistantMessage.content === 'string')
) {
break; break;
} }
if (nextReasoning) { if (nextReasoning) {
assistantMessage.reasoning_content = nextReasoning; assistantMessage.reasoning_content = appendSeparatedText(
typeof assistantMessage.reasoning_content === 'string'
? assistantMessage.reasoning_content
: undefined,
nextReasoning
);
} }
if (typeof nextMessage.content === 'string') { if (typeof nextMessage.content === 'string') {
...@@ -172,22 +212,22 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam ...@@ -172,22 +212,22 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam
const toolCalls: ChatCompletionMessageToolCall[] = []; const toolCalls: ChatCompletionMessageToolCall[] = [];
const toolResponses: ChatCompletionMessageParam[] = []; const toolResponses: ChatCompletionMessageParam[] = [];
// 再把紧随其后的 tool_calls 与它们的 tool response 合并到同一个 assistant payload。
let assistantToolMessage: ChatCompletionMessageParam | undefined = messages[cursor]; let assistantToolMessage: ChatCompletionMessageParam | undefined = messages[cursor];
while (isAssistantToolCallMessage(assistantToolMessage)) { while (isAssistantToolCallMessage(assistantToolMessage)) {
if (!hasSameVisibility(assistantToolMessage, assistantMessage)) break; if (!hasSameAssistantContext(assistantToolMessage, assistantMessage)) break;
const currentToolCalls = assistantToolMessage.tool_calls; const currentToolCalls = assistantToolMessage.tool_calls;
const currentToolCallIds = new Set(currentToolCalls.map((toolCall) => toolCall.id)); const currentToolCallIds = new Set(currentToolCalls.map((toolCall) => toolCall.id));
const currentToolResponses: ChatCompletionMessageParam[] = []; const currentToolResponses: ChatCompletionMessageParam[] = [];
let responseCursor = cursor + 1; let responseCursor = cursor + 1;
// 只消费属于当前 tool_calls 的 response,防止把下一轮工具结果串进来。
while (isToolResponseForToolCalls(messages[responseCursor], currentToolCallIds)) { while (isToolResponseForToolCalls(messages[responseCursor], currentToolCallIds)) {
currentToolResponses.push(messages[responseCursor]); currentToolResponses.push(messages[responseCursor]);
responseCursor++; responseCursor++;
} }
if (!currentToolResponses.length) break;
toolCalls.push(...currentToolCalls); toolCalls.push(...currentToolCalls);
toolResponses.push(...currentToolResponses); toolResponses.push(...currentToolResponses);
cursor = responseCursor; cursor = responseCursor;
...@@ -195,7 +235,13 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam ...@@ -195,7 +235,13 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam
} }
if (!toolCalls.length) { if (!toolCalls.length) {
mergedMessages.push(assistantMessage); // 孤立 reasoning 没有可发给 provider 的合法载体;只有 text/function_call 才保留。
if (
typeof assistantMessage.content === 'string' ||
hasAssistantFunctionCall(assistantMessage)
) {
mergedMessages.push(assistantMessage);
}
index = cursor - 1; index = cursor - 1;
continue; continue;
} }
...@@ -211,14 +257,25 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam ...@@ -211,14 +257,25 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam
return mergedMessages; return mergedMessages;
}; };
/**
* 将 FastGPT 内部 ChatItem 历史转换为 GPT request messages。
*
* 关键约定:
* - reserveTool=false 时只保留自然语言上下文,不把历史工具调用带入分类/普通对话。
* - reserveReason=false 时去掉 reasoning_content,适用于问题分类等不需要思考过程的节点。
* - reserveId=true 时保留 dataId,供需要按轮次追踪的调用方使用。
* - 输出前会调用 mergeAssistantFieldMessages,保证 reasoning 不以独立 assistant message 出现。
*/
export const chats2GPTMessages = ({ export const chats2GPTMessages = ({
messages, messages,
reserveId, reserveId,
reserveTool = false reserveTool = false,
reserveReason = true
}: { }: {
messages: ChatItemMiniType[]; messages: ChatItemMiniType[];
reserveId: boolean; reserveId: boolean;
reserveTool?: boolean; reserveTool?: boolean;
reserveReason?: boolean;
}): ChatCompletionMessageParam[] => { }): ChatCompletionMessageParam[] => {
let results: ChatCompletionMessageParam[] = []; let results: ChatCompletionMessageParam[] = [];
const sourceMessages = getCheckpointAwareMessages(messages); const sourceMessages = getCheckpointAwareMessages(messages);
...@@ -272,7 +329,6 @@ export const chats2GPTMessages = ({ ...@@ -272,7 +329,6 @@ export const chats2GPTMessages = ({
} }
}; };
}; };
sourceMessages.forEach((item) => { sourceMessages.forEach((item) => {
const dataId = reserveId ? item.dataId : undefined; const dataId = reserveId ? item.dataId : undefined;
if (item.obj === ChatRoleEnum.System) { if (item.obj === ChatRoleEnum.System) {
...@@ -328,6 +384,7 @@ export const chats2GPTMessages = ({ ...@@ -328,6 +384,7 @@ export const chats2GPTMessages = ({
} else { } else {
const aiResults: ChatCompletionMessageParam[] = []; const aiResults: ChatCompletionMessageParam[] = [];
const agentAskAnswerMap = new Map<string, string>(); const agentAskAnswerMap = new Map<string, string>();
// agentAsk 的用户回答以交互记录形式存在,需要按 planId 恢复为 ask_agent tool response。
item.value.forEach((value) => { item.value.forEach((value) => {
if ( if (
value.interactive?.type === 'agentPlanAskQuery' && value.interactive?.type === 'agentPlanAskQuery' &&
...@@ -363,6 +420,7 @@ export const chats2GPTMessages = ({ ...@@ -363,6 +420,7 @@ export const chats2GPTMessages = ({
if (reasoningText) appendAssistantReasoning(reasoningText, hideInUI); if (reasoningText) appendAssistantReasoning(reasoningText, hideInUI);
if (assistantText) appendAssistantText(assistantText, hideInUI); if (assistantText) appendAssistantText(assistantText, hideInUI);
if (!normalizedToolContext) { if (!normalizedToolContext) {
// tool 元数据不完整时,保留前置 assistantText/reasoning,丢弃非法 tool_call。
return false; return false;
} }
...@@ -376,6 +434,8 @@ export const chats2GPTMessages = ({ ...@@ -376,6 +434,8 @@ export const chats2GPTMessages = ({
}; };
const appendAssistantReasoning = (content: string, hideInUI?: boolean) => { const appendAssistantReasoning = (content: string, hideInUI?: boolean) => {
if (!reserveReason || !content) return;
aiResults.push({ aiResults.push({
dataId, dataId,
role: ChatCompletionRequestMessageRoleEnum.Assistant, role: ChatCompletionRequestMessageRoleEnum.Assistant,
...@@ -406,7 +466,7 @@ export const chats2GPTMessages = ({ ...@@ -406,7 +466,7 @@ export const chats2GPTMessages = ({
item.value.forEach((value) => { item.value.forEach((value) => {
if (value.contextCheckpoint) { if (value.contextCheckpoint) {
// A checkpoint value replaces everything before it; fields on the same value are ignored. // checkpoint 会重置之前累积的 AI 字段;同一个 value 上的其他字段不再参与上下文。
results = results.concat(mergeAssistantFieldMessages(aiResults)); results = results.concat(mergeAssistantFieldMessages(aiResults));
aiResults.length = 0; aiResults.length = 0;
results.push({ results.push({
...@@ -504,7 +564,7 @@ export const chats2GPTMessages = ({ ...@@ -504,7 +564,7 @@ export const chats2GPTMessages = ({
} }
}); });
// Auto add empty assistant message // AI value 遍历结束后统一合并,处理 reasoning/text/tools 分散存储的兼容格式。
results = results.concat(mergeAssistantFieldMessages(aiResults)); results = results.concat(mergeAssistantFieldMessages(aiResults));
} }
}); });
...@@ -512,6 +572,13 @@ export const chats2GPTMessages = ({ ...@@ -512,6 +572,13 @@ export const chats2GPTMessages = ({
return results; return results;
}; };
/**
* 将 GPT messages 转回 FastGPT ChatItem。
*
* GPTMessages2Chats 只恢复当前 message 已经聚合好的结构,不再跨 message 追踪 reasoning。
* reasoning_content 必须和 content/tool_calls/function_call 在同一个 assistant message 上才会恢复;
* 孤立 reasoning 会被过滤,避免把下一轮或未知归属的思考过程误挂到后续消息。
*/
export const GPTMessages2Chats = ({ export const GPTMessages2Chats = ({
messages, messages,
reserveTool = true, reserveTool = true,
...@@ -607,30 +674,18 @@ export const GPTMessages2Chats = ({ ...@@ -607,30 +674,18 @@ export const GPTMessages2Chats = ({
) { ) {
const value: AIChatItemValueItemType[] = []; const value: AIChatItemValueItemType[] = [];
const valueVisibility = item.hideInUI ? { hideInUI: item.hideInUI } : {}; const valueVisibility = item.hideInUI ? { hideInUI: item.hideInUI } : {};
const assistantValue: AIChatItemValueItemType = {
...valueVisibility
};
if (typeof item.reasoning_content === 'string' && item.reasoning_content && reserveReason) { // text、tools、function_call 和 reasoning 归在同一个 value,避免 UI/历史再产生独立 reason item。
value.push({
reasoning: {
content: item.reasoning_content
},
...valueVisibility
});
}
if (typeof item.content === 'string' && item.content) { if (typeof item.content === 'string' && item.content) {
const lastValue = value[value.length - 1]; assistantValue.text = {
if (lastValue && lastValue.text) { content: item.content
lastValue.text.content += item.content; };
} else {
value.push({
text: {
content: item.content
},
...valueVisibility
});
}
} }
if (item.tool_calls && reserveTool) { if (item.tool_calls && reserveTool) {
// save tool calls // tool response 存在于独立 tool message 中,这里按 tool_call_id 回查并折回 ChatItem.tools。
const toolCalls = item.tool_calls as ChatCompletionMessageToolCall[]; const toolCalls = item.tool_calls as ChatCompletionMessageToolCall[];
const tools = toolCalls.flatMap<ToolModuleResponseItemType>((tool) => { const tools = toolCalls.flatMap<ToolModuleResponseItemType>((tool) => {
...@@ -656,10 +711,9 @@ export const GPTMessages2Chats = ({ ...@@ -656,10 +711,9 @@ export const GPTMessages2Chats = ({
} }
]; ];
}); });
value.push({ if (tools.length) {
tools, assistantValue.tools = tools;
...valueVisibility }
});
} }
if (item.function_call && reserveTool) { if (item.function_call && reserveTool) {
const functionCall = item.function_call as ChatCompletionMessageFunctionCall; const functionCall = item.function_call as ChatCompletionMessageFunctionCall;
...@@ -670,19 +724,29 @@ export const GPTMessages2Chats = ({ ...@@ -670,19 +724,29 @@ export const GPTMessages2Chats = ({
) as ChatCompletionFunctionMessageParam; ) as ChatCompletionFunctionMessageParam;
if (functionResponse) { if (functionResponse) {
value.push({ assistantValue.tool = {
tool: { id: functionCall.id || '',
id: functionCall.id || '', toolName: functionCall.toolName || '',
toolName: functionCall.toolName || '', toolAvatar: functionCall.toolAvatar || '',
toolAvatar: functionCall.toolAvatar || '', functionName: functionCall.name,
functionName: functionCall.name, params: functionCall.arguments,
params: functionCall.arguments, response: functionResponse.content || ''
response: functionResponse.content || '' };
},
...valueVisibility
});
} }
} }
if (
typeof item.reasoning_content === 'string' &&
item.reasoning_content &&
reserveReason &&
(assistantValue.text || assistantValue.tools || assistantValue.tool)
) {
assistantValue.reasoning = {
content: item.reasoning_content
};
}
if (assistantValue.text || assistantValue.tools || assistantValue.tool) {
value.push(assistantValue);
}
if (item.interactive) { if (item.interactive) {
value.push({ value.push({
interactive: item.interactive, interactive: item.interactive,
...@@ -706,7 +770,7 @@ export const GPTMessages2Chats = ({ ...@@ -706,7 +770,7 @@ export const GPTMessages2Chats = ({
}) })
.filter((item) => item.value.length > 0); .filter((item) => item.value.length > 0);
// Merge data with the same dataId(Sequential obj merging) // 相邻同 dataId/obj 的记录归并,保持一轮 AI 多个 value 在同一个 ChatItem 中展示。
const result = chatMessages.reduce((result: ChatItemMiniType[], currentItem) => { const result = chatMessages.reduce((result: ChatItemMiniType[], currentItem) => {
const lastItem = result[result.length - 1]; const lastItem = result[result.length - 1];
...@@ -723,6 +787,9 @@ export const GPTMessages2Chats = ({ ...@@ -723,6 +787,9 @@ export const GPTMessages2Chats = ({
return result; return result;
}; };
/**
* 将聊天 value 提取成运行时用户输入。文件保留结构,文本按展示顺序拼接。
*/
export const chatValue2RuntimePrompt = (value: ChatItemValueItemType[]): RuntimeUserPromptType => { export const chatValue2RuntimePrompt = (value: ChatItemValueItemType[]): RuntimeUserPromptType => {
const prompt: RuntimeUserPromptType = { const prompt: RuntimeUserPromptType = {
files: [], files: [],
...@@ -738,6 +805,9 @@ export const chatValue2RuntimePrompt = (value: ChatItemValueItemType[]): Runtime ...@@ -738,6 +805,9 @@ export const chatValue2RuntimePrompt = (value: ChatItemValueItemType[]): Runtime
return prompt; return prompt;
}; };
/**
* 将运行时 prompt 恢复为用户聊天 value,主要用于调试/重放入口。
*/
export const runtimePrompt2ChatsValue = (prompt: { export const runtimePrompt2ChatsValue = (prompt: {
files?: UserChatItemFileItemType[]; files?: UserChatItemFileItemType[];
text?: string; text?: string;
...@@ -760,6 +830,9 @@ export const runtimePrompt2ChatsValue = (prompt: { ...@@ -760,6 +830,9 @@ export const runtimePrompt2ChatsValue = (prompt: {
return value; return value;
}; };
/**
* 用一条 System ChatItem 包装系统提示词,便于统一走 ChatItem -> GPT message 转换链。
*/
export const getSystemPrompt_ChatItemType = (prompt?: string): ChatItemMiniType[] => { export const getSystemPrompt_ChatItemType = (prompt?: string): ChatItemMiniType[] => {
if (!prompt) return []; if (!prompt) return [];
return [ return [
......
import { ObjectIdSchema } from '../../../common/type/mongo'; import { ObjectIdSchema } from '../../../common/type/mongo';
import z from 'zod'; import z from 'zod';
import { ChatRoleEnum } from '../constants'; import { ChatRoleEnum } from '../constants';
import { UserChatItemSchema, SystemChatItemSchema, ToolModuleResponseItemSchema } from '../type'; import { UserChatItemSchema, SystemChatItemSchema } from '../type';
import { UserInputInteractiveSchema } from '../../workflow/template/system/interactive/type'; import { UserInputInteractiveSchema } from '../../workflow/template/system/interactive/type';
export enum HelperBotTypeEnum { export enum HelperBotTypeEnum {
...@@ -21,18 +21,27 @@ export const HelperBotChatSchema = z.object({ ...@@ -21,18 +21,27 @@ export const HelperBotChatSchema = z.object({
}); });
export type HelperBotChatType = z.infer<typeof HelperBotChatSchema>; export type HelperBotChatType = z.infer<typeof HelperBotChatSchema>;
const AIChatContentValueSchema = z
.object({
text: z
.object({
content: z.string()
})
.optional(),
reasoning: z
.object({
content: z.string()
})
.optional(),
hideReason: z.boolean().optional()
})
.refine((value) => value.text || value.reasoning, {
message: 'HelperBot AI content value requires text or reasoning'
});
// AI schema // AI schema
export const AIChatItemValueItemSchema = z.union([ export const AIChatItemValueItemSchema = z.union([
z.object({ AIChatContentValueSchema,
text: z.object({
content: z.string()
})
}),
z.object({
reasoning: z.object({
content: z.string()
})
}),
z.object({ z.object({
collectionForm: UserInputInteractiveSchema collectionForm: UserInputInteractiveSchema
}), }),
......
...@@ -225,6 +225,7 @@ export const AIChatItemValueSchema = z.object({ ...@@ -225,6 +225,7 @@ export const AIChatItemValueSchema = z.object({
agentStopGate: AgentLoopStopGateSchema.nullish(), agentStopGate: AgentLoopStopGateSchema.nullish(),
contextCheckpoint: ContextCheckpointValueSchema.nullish(), contextCheckpoint: ContextCheckpointValueSchema.nullish(),
tool: ToolModuleResponseItemSchema.nullish().meta({ deprecated: true }), tool: ToolModuleResponseItemSchema.nullish().meta({ deprecated: true }),
hideReason: z.boolean().optional(),
hideInUI: z.boolean().optional() hideInUI: z.boolean().optional()
}); });
......
...@@ -152,27 +152,25 @@ export const removeAIResponseCite = <T extends AIChatItemValueItemType[] | strin ...@@ -152,27 +152,25 @@ export const removeAIResponseCite = <T extends AIChatItemValueItemType[] | strin
return removeDatasetCiteText(value, false) as T; return removeDatasetCiteText(value, false) as T;
} }
return value.map<AIChatItemValueItemType>((item) => { return value.map<AIChatItemValueItemType>((item) => ({
if (item.text?.content) { ...item,
return { ...(item.text?.content
...item, ? {
text: { text: {
...item.text, ...item.text,
content: removeDatasetCiteText(item.text.content, false) content: removeDatasetCiteText(item.text.content, false)
}
} }
}; : {}),
} ...(item.reasoning?.content
if (item.reasoning?.content) { ? {
return { reasoning: {
...item, ...item.reasoning,
reasoning: { content: removeDatasetCiteText(item.reasoning.content, false)
...item.reasoning, }
content: removeDatasetCiteText(item.reasoning.content, false)
} }
}; : {})
} })) as T;
return item;
}) as T;
}; };
export const removeEmptyUserInput = (input?: UserChatItemValueItemType[]) => { export const removeEmptyUserInput = (input?: UserChatItemValueItemType[]) => {
......
...@@ -142,7 +142,36 @@ describe('mergeAssistantFieldMessages', () => { ...@@ -142,7 +142,36 @@ describe('mergeAssistantFieldMessages', () => {
]); ]);
}); });
it('should not merge assistant messages with different hideInUI visibility', () => { it('should start a new assistant payload when reasoning appears after content', () => {
const messages: ChatCompletionMessageParam[] = [
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Draft answer.'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Second step thinking.'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Final answer.'
}
];
expect(mergeAssistantFieldMessages(messages)).toEqual([
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Draft answer.'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Second step thinking.',
content: 'Final answer.'
}
]);
});
it('should drop orphan reasoning when the next assistant message has different visibility', () => {
const messages: ChatCompletionMessageParam[] = [ const messages: ChatCompletionMessageParam[] = [
{ {
role: ChatCompletionRequestMessageRoleEnum.Assistant, role: ChatCompletionRequestMessageRoleEnum.Assistant,
...@@ -155,7 +184,124 @@ describe('mergeAssistantFieldMessages', () => { ...@@ -155,7 +184,124 @@ describe('mergeAssistantFieldMessages', () => {
} }
]; ];
expect(mergeAssistantFieldMessages(messages)).toEqual(messages); expect(mergeAssistantFieldMessages(messages)).toEqual([
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Visible answer.'
}
]);
});
it('should drop orphan reasoning when the next assistant message has different dataId', () => {
const messages: ChatCompletionMessageParam[] = [
{
dataId: 'old-ai',
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Reason from another response.'
},
{
dataId: 'current-ai',
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Current answer.'
}
];
expect(mergeAssistantFieldMessages(messages)).toEqual([
{
dataId: 'current-ai',
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Current answer.'
}
]);
});
it('should not attach orphan reasoning to tool calls with different dataId', () => {
const messages: ChatCompletionMessageParam[] = [
{
dataId: 'old-ai',
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Reason from another response.'
},
{
dataId: 'current-ai',
role: ChatCompletionRequestMessageRoleEnum.Assistant,
tool_calls: [
{
id: 'call_search',
type: 'function',
function: {
name: 'search_web',
arguments: '{}'
}
}
]
},
{
dataId: 'current-ai',
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call_search',
content: '{}'
}
];
expect(mergeAssistantFieldMessages(messages)).toEqual([
{
dataId: 'current-ai',
role: ChatCompletionRequestMessageRoleEnum.Assistant,
tool_calls: [
{
id: 'call_search',
type: 'function',
function: {
name: 'search_web',
arguments: '{}'
}
}
]
},
{
dataId: 'current-ai',
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call_search',
content: '{}'
}
]);
});
it('should merge consecutive assistant reasoning fields into the next assistant payload', () => {
const messages: ChatCompletionMessageParam[] = [
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'reason 1'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'reason 2'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Final answer.'
}
];
expect(mergeAssistantFieldMessages(messages)).toEqual([
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'reason 1\n\nreason 2',
content: 'Final answer.'
}
]);
});
it('should drop orphan assistant reasoning fields', () => {
const messages: ChatCompletionMessageParam[] = [
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'reason only'
}
];
expect(mergeAssistantFieldMessages(messages)).toEqual([]);
}); });
it('should keep DeepSeek reasoning on tool call message when there is no answer text', () => { it('should keep DeepSeek reasoning on tool call message when there is no answer text', () => {
...@@ -292,7 +438,7 @@ describe('mergeAssistantFieldMessages', () => { ...@@ -292,7 +438,7 @@ describe('mergeAssistantFieldMessages', () => {
]); ]);
}); });
it('should keep tool call message separate when tool response is missing', () => { it('should attach reasoning to tool call message when tool response is missing', () => {
const messages: ChatCompletionMessageParam[] = [ const messages: ChatCompletionMessageParam[] = [
{ {
role: ChatCompletionRequestMessageRoleEnum.Assistant, role: ChatCompletionRequestMessageRoleEnum.Assistant,
...@@ -313,6 +459,47 @@ describe('mergeAssistantFieldMessages', () => { ...@@ -313,6 +459,47 @@ describe('mergeAssistantFieldMessages', () => {
} }
]; ];
expect(mergeAssistantFieldMessages(messages)).toEqual([
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Need a tool.',
tool_calls: [
{
id: 'call_search',
type: 'function',
function: {
name: 'search_web',
arguments: '{}'
}
}
]
}
]);
});
it('should keep already merged reasoning and tool calls idempotent', () => {
const messages: ChatCompletionMessageParam[] = [
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Need a tool.',
tool_calls: [
{
id: 'call_search',
type: 'function',
function: {
name: 'search_web',
arguments: '{}'
}
}
]
},
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call_search',
content: '{}'
}
];
expect(mergeAssistantFieldMessages(messages)).toEqual(messages); expect(mergeAssistantFieldMessages(messages)).toEqual(messages);
}); });
}); });
...@@ -697,6 +884,42 @@ describe('chats2GPTMessages', () => { ...@@ -697,6 +884,42 @@ describe('chats2GPTMessages', () => {
}); });
}); });
it('should not attach reasoning that appears before the latest checkpoint', () => {
const messages: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.AI,
value: [
{
reasoning: {
content: 'Old reasoning before checkpoint'
}
},
{
contextCheckpoint: '<context_checkpoint>compressed state</context_checkpoint>',
hideInUI: true
},
{
text: {
content: 'Answer after checkpoint'
}
}
]
}
];
expect(chats2GPTMessages({ messages, reserveId: false })).toEqual([
{
role: ChatCompletionRequestMessageRoleEnum.User,
content: '<context_checkpoint>compressed state</context_checkpoint>',
hideInUI: true
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Answer after checkpoint'
}
]);
});
it('should handle AI message with tool calls when reserveTool is true', () => { it('should handle AI message with tool calls when reserveTool is true', () => {
const messages: ChatItemMiniType[] = [ const messages: ChatItemMiniType[] = [
{ {
...@@ -834,12 +1057,7 @@ describe('chats2GPTMessages', () => { ...@@ -834,12 +1057,7 @@ describe('chats2GPTMessages', () => {
const result = chats2GPTMessages({ messages, reserveId: false, reserveTool: true }); const result = chats2GPTMessages({ messages, reserveId: false, reserveTool: true });
expect(result).toHaveLength(1); expect(result).toHaveLength(0);
expect(result[0]).toMatchObject({
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Need a tool'
});
expect((result[0] as any).tool_calls).toBeUndefined();
}); });
it('should normalize non-string historical tool params before restoring tool_calls', () => { it('should normalize non-string historical tool params before restoring tool_calls', () => {
...@@ -874,6 +1092,35 @@ describe('chats2GPTMessages', () => { ...@@ -874,6 +1092,35 @@ describe('chats2GPTMessages', () => {
}); });
}); });
it('should fallback to empty object arguments when historical tool params cannot stringify', () => {
const circularParams: Record<string, unknown> = {};
circularParams.self = circularParams;
const messages: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.AI,
value: [
{
tool: {
id: 'call_circular_params',
toolName: 'Search',
toolAvatar: '',
functionName: 'search_web',
params: circularParams,
response: 'ok'
} as any
}
]
}
];
const result = chats2GPTMessages({ messages, reserveId: false, reserveTool: true });
expect((result[0] as any).tool_calls?.[0].function).toEqual({
name: 'search_web',
arguments: '{}'
});
});
it('should attach assistant text and reasoning to following tool_calls', () => { it('should attach assistant text and reasoning to following tool_calls', () => {
const messages: ChatItemMiniType[] = [ const messages: ChatItemMiniType[] = [
{ {
...@@ -978,7 +1225,34 @@ describe('chats2GPTMessages', () => { ...@@ -978,7 +1225,34 @@ describe('chats2GPTMessages', () => {
expect((result[0] as any).hideInUI).toBe(true); expect((result[0] as any).hideInUI).toBe(true);
}); });
it('should preserve AI value-level hideInUI property', () => { it('should keep hideReason independent from GPT context visibility', () => {
const messages: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.AI,
value: [
{
reasoning: {
content: 'Hidden in UI but still part of reasoning history'
},
text: {
content: 'Visible answer'
},
hideReason: true
}
]
}
];
expect(chats2GPTMessages({ messages, reserveId: false })).toEqual([
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Hidden in UI but still part of reasoning history',
content: 'Visible answer'
}
]);
});
it('should drop value-level reasoning when hideInUI prevents attaching to visible text', () => {
const messages: ChatItemMiniType[] = [ const messages: ChatItemMiniType[] = [
{ {
obj: ChatRoleEnum.AI, obj: ChatRoleEnum.AI,
...@@ -1003,11 +1277,6 @@ describe('chats2GPTMessages', () => { ...@@ -1003,11 +1277,6 @@ describe('chats2GPTMessages', () => {
expect(result).toEqual([ expect(result).toEqual([
{ {
role: ChatCompletionRequestMessageRoleEnum.Assistant, role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Hidden reasoning',
hideInUI: true
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Visible answer' content: 'Visible answer'
} }
]); ]);
...@@ -1103,7 +1372,7 @@ describe('chats2GPTMessages', () => { ...@@ -1103,7 +1372,7 @@ describe('chats2GPTMessages', () => {
expect(result).toHaveLength(0); expect(result).toHaveLength(0);
}); });
it('should convert AI message with reasoning only', () => { it('should drop AI message with reasoning only', () => {
const messages: ChatItemMiniType[] = [ const messages: ChatItemMiniType[] = [
{ {
obj: ChatRoleEnum.AI, obj: ChatRoleEnum.AI,
...@@ -1113,10 +1382,7 @@ describe('chats2GPTMessages', () => { ...@@ -1113,10 +1382,7 @@ describe('chats2GPTMessages', () => {
const result = chats2GPTMessages({ messages, reserveId: false }); const result = chats2GPTMessages({ messages, reserveId: false });
expect(result).toHaveLength(1); expect(result).toHaveLength(0);
expect(result[0].role).toBe(ChatCompletionRequestMessageRoleEnum.Assistant);
expect((result[0] as any).reasoning_content).toBe('Let me think...');
expect((result[0] as any).content).toBeUndefined();
}); });
it('should merge reasoning + text into a single assistant message', () => { it('should merge reasoning + text into a single assistant message', () => {
...@@ -1138,24 +1404,121 @@ describe('chats2GPTMessages', () => { ...@@ -1138,24 +1404,121 @@ describe('chats2GPTMessages', () => {
expect((result[0] as any).content).toBe('Final answer'); expect((result[0] as any).content).toBe('Final answer');
}); });
it('should attach preceding reasoning to following tool_calls', () => { it('should concatenate continuous reasoning values before attaching to text', () => {
const messages: ChatItemMiniType[] = [ const messages: ChatItemMiniType[] = [
{ {
obj: ChatRoleEnum.AI, obj: ChatRoleEnum.AI,
value: [ value: [
{ reasoning: { content: 'Need to call a tool' } }, { reasoning: { content: 'reason 1' } },
{ { reasoning: { content: 'reason 2' } },
tools: [ { text: { content: 'Final answer' } }
{ ]
id: 'tool-1', }
toolName: 'Search', ];
toolAvatar: '',
functionName: 'search_web', const result = chats2GPTMessages({ messages, reserveId: false });
params: '{"q":"x"}',
response: '{}' expect(result).toEqual([
} {
] role: ChatCompletionRequestMessageRoleEnum.Assistant,
} reasoning_content: 'reason 1\n\nreason 2',
content: 'Final answer'
}
]);
});
it('should remove reasoning when reserveReason is false', () => {
const messages: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.AI,
value: [
{ reasoning: { content: 'hidden thought' } },
{ text: { content: 'Visible answer' } }
]
}
];
const result = chats2GPTMessages({ messages, reserveId: false, reserveReason: false });
expect(result).toEqual([
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Visible answer'
}
]);
});
it('should keep tool calls and remove reasoning when reserveReason is false', () => {
const messages: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.AI,
value: [
{
reasoning: {
content: 'Do not send this reasoning'
},
tools: [
{
id: 'tool-1',
toolName: 'Search',
toolAvatar: '',
functionName: 'search_web',
params: '{"q":"x"}',
response: '{"ok":true}'
}
]
}
]
}
];
expect(
chats2GPTMessages({
messages,
reserveId: false,
reserveTool: true,
reserveReason: false
})
).toEqual([
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
tool_calls: [
{
id: 'tool-1',
type: 'function',
function: {
name: 'search_web',
arguments: '{"q":"x"}'
}
}
]
},
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'tool-1',
content: '{"ok":true}'
}
]);
});
it('should attach preceding reasoning to following tool_calls', () => {
const messages: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.AI,
value: [
{ reasoning: { content: 'Need to call a tool' } },
{
tools: [
{
id: 'tool-1',
toolName: 'Search',
toolAvatar: '',
functionName: 'search_web',
params: '{"q":"x"}',
response: '{}'
}
]
}
] ]
} }
]; ];
...@@ -1251,6 +1614,34 @@ describe('chats2GPTMessages', () => { ...@@ -1251,6 +1614,34 @@ describe('chats2GPTMessages', () => {
]); ]);
}); });
it('should keep control assistant text and reasoning when control tool metadata is invalid', () => {
const messages: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.AI,
value: [
{
agentPlanUpdate: {
id: '',
functionName: '',
params: '{}',
response: 'Plan updated.',
assistantText: 'draft before invalid control tool',
reasoningText: 'planning'
}
}
]
}
];
expect(chats2GPTMessages({ messages, reserveId: false, reserveTool: true })).toEqual([
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'planning',
content: 'draft before invalid control tool'
}
]);
});
it('should restore agent loop control fields from chat value when reserving tools', () => { it('should restore agent loop control fields from chat value when reserving tools', () => {
const expectedMessages: ChatCompletionMessageParam[] = [ const expectedMessages: ChatCompletionMessageParam[] = [
{ {
...@@ -1410,6 +1801,117 @@ describe('chats2GPTMessages', () => { ...@@ -1410,6 +1801,117 @@ describe('chats2GPTMessages', () => {
expect((result[1] as any).reasoning_content).toBe('Step 2 thinking'); expect((result[1] as any).reasoning_content).toBe('Step 2 thinking');
expect((result[1] as any).content).toBe('Final answer'); expect((result[1] as any).content).toBe('Final answer');
}); });
it('should keep multi-step reasoning and tool calls attached without reasoning-only messages', () => {
const messages: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.AI,
value: [
{ reasoning: { content: 'reason 1' } },
{
tools: [
{
id: 'call_1',
toolName: 'Get time',
toolAvatar: '',
functionName: 'get_time',
params: '{}',
response: '{"time":"17:19:15"}'
}
]
},
{ reasoning: { content: 'reason 2' } },
{
tools: [
{
id: 'call_2',
toolName: 'Get time',
toolAvatar: '',
functionName: 'get_time',
params: '{}',
response: '{"time":"17:19:16"}'
}
]
},
{ reasoning: { content: 'reason 3' } },
{ text: { content: 'Final answer' } }
]
}
];
const withTools = chats2GPTMessages({
messages,
reserveId: false,
reserveTool: true,
reserveReason: true
});
const classifyLike = chats2GPTMessages({
messages,
reserveId: false,
reserveTool: false,
reserveReason: false
});
expect(withTools).toEqual([
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'reason 1',
tool_calls: [
{
id: 'call_1',
type: 'function',
function: {
name: 'get_time',
arguments: '{}'
}
}
]
},
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call_1',
content: '{"time":"17:19:15"}'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'reason 2',
tool_calls: [
{
id: 'call_2',
type: 'function',
function: {
name: 'get_time',
arguments: '{}'
}
}
]
},
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call_2',
content: '{"time":"17:19:16"}'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'reason 3',
content: 'Final answer'
}
]);
expect(
withTools.every(
(item) =>
item.role !== ChatCompletionRequestMessageRoleEnum.Assistant ||
Boolean((item as any).content) ||
Boolean((item as any).tool_calls)
)
).toBe(true);
expect(classifyLike).toEqual([
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Final answer'
}
]);
});
}); });
describe('GPTMessages2Chats', () => { describe('GPTMessages2Chats', () => {
...@@ -1491,9 +1993,241 @@ describe('GPTMessages2Chats', () => { ...@@ -1491,9 +1993,241 @@ describe('GPTMessages2Chats', () => {
const result = GPTMessages2Chats({ messages }); const result = GPTMessages2Chats({ messages });
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
const reasoningValue = result[0].value[0] as { reasoning?: { content: string } }; expect(result[0].value).toEqual([
expect(reasoningValue.reasoning?.content).toBe('Let me think about this...'); {
expect(result[0].value[1].text?.content).toBe('Final answer'); reasoning: {
content: 'Let me think about this...'
},
text: {
content: 'Final answer'
}
}
]);
});
it('should drop separated reasoning assistant messages before following content', () => {
const messages: ChatCompletionMessageParam[] = [
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'reason 1'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'reason 2'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Final answer'
}
];
const result = GPTMessages2Chats({ messages });
expect(result).toHaveLength(1);
expect(result[0].value).toEqual([
{
text: {
content: 'Final answer'
}
}
]);
});
it('should keep only reasoning that already exists on the attach target', () => {
const messages: ChatCompletionMessageParam[] = [
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'pending reason'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'target reason',
content: 'Final answer'
}
];
const result = GPTMessages2Chats({ messages });
expect(result).toHaveLength(1);
expect(result[0].value).toEqual([
{
reasoning: {
content: 'target reason'
},
text: {
content: 'Final answer'
}
}
]);
});
it('should ignore separated reasoning across non-assistant message boundaries', () => {
const messages: ChatCompletionMessageParam[] = [
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Reason before user boundary'
},
{
role: ChatCompletionRequestMessageRoleEnum.User,
content: 'New user turn'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Answer after user turn'
}
];
const result = GPTMessages2Chats({ messages });
expect(result).toEqual([
{
dataId: undefined,
obj: ChatRoleEnum.Human,
hideInUI: undefined,
value: [
{
text: {
content: 'New user turn'
}
}
]
},
{
dataId: undefined,
obj: ChatRoleEnum.AI,
value: [
{
text: {
content: 'Answer after user turn'
}
}
]
}
]);
});
it('should drop separated reasoning assistant message before following tool calls', () => {
const messages: ChatCompletionMessageParam[] = [
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Need current time.'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
tool_calls: [
{
id: 'call_time',
type: 'function',
function: {
name: 'get_time',
arguments: '{}'
}
}
]
},
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call_time',
content: '{"time":"2026-05-21 17:19:15"}'
}
];
const result = GPTMessages2Chats({ messages, reserveTool: true });
expect(result).toHaveLength(1);
expect(result[0].value).toEqual([
{
tools: [
{
id: 'call_time',
toolName: '',
toolAvatar: '',
functionName: 'get_time',
params: '{}',
response: '{"time":"2026-05-21 17:19:15"}'
}
]
}
]);
});
it('should not attach separated reasoning to a later answer after filtered tool calls', () => {
const messages: ChatCompletionMessageParam[] = [
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Reasoning for a tool call only.'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
tool_calls: [
{
id: 'call_search',
type: 'function',
function: {
name: 'search_web',
arguments: '{}'
}
}
]
},
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call_search',
content: '{}'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Final visible answer.'
}
];
const result = GPTMessages2Chats({ messages, reserveTool: false, reserveReason: true });
expect(result).toHaveLength(1);
expect(result[0].value).toEqual([
{
text: {
content: 'Final visible answer.'
}
}
]);
});
it('should not attach separated reasoning after filtered tool calls without tool response', () => {
const messages: ChatCompletionMessageParam[] = [
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Reasoning for a filtered tool call.'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
tool_calls: [
{
id: 'call_search',
type: 'function',
function: {
name: 'search_web',
arguments: '{}'
}
}
]
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Final visible answer.'
}
];
const result = GPTMessages2Chats({ messages, reserveTool: false, reserveReason: true });
expect(result).toHaveLength(1);
expect(result[0].value).toEqual([
{
text: {
content: 'Final visible answer.'
}
}
]);
}); });
it('should drop reasoning when reserveReason is false', () => { it('should drop reasoning when reserveReason is false', () => {
...@@ -1513,7 +2247,7 @@ describe('GPTMessages2Chats', () => { ...@@ -1513,7 +2247,7 @@ describe('GPTMessages2Chats', () => {
expect((result[0].value[0] as any).reasoning).toBeUndefined(); expect((result[0].value[0] as any).reasoning).toBeUndefined();
}); });
it('should keep only reasoning when assistant message has no content', () => { it('should drop reasoning when assistant message has no content', () => {
const messages: ChatCompletionMessageParam[] = [ const messages: ChatCompletionMessageParam[] = [
{ {
role: ChatCompletionRequestMessageRoleEnum.Assistant, role: ChatCompletionRequestMessageRoleEnum.Assistant,
...@@ -1524,10 +2258,7 @@ describe('GPTMessages2Chats', () => { ...@@ -1524,10 +2258,7 @@ describe('GPTMessages2Chats', () => {
const result = GPTMessages2Chats({ messages }); const result = GPTMessages2Chats({ messages });
expect(result).toHaveLength(1); expect(result).toHaveLength(0);
expect(result[0].value).toHaveLength(1);
const value = result[0].value[0] as { reasoning?: { content: string } };
expect(value.reasoning?.content).toBe('Thinking only');
}); });
it('should merge messages with same dataId', () => { it('should merge messages with same dataId', () => {
...@@ -1645,6 +2376,36 @@ describe('GPTMessages2Chats', () => { ...@@ -1645,6 +2376,36 @@ describe('GPTMessages2Chats', () => {
expect(toolValue.tools[0].response).toBe('{"results": []}'); expect(toolValue.tools[0].response).toBe('{"results": []}');
}); });
it('should stringify non-string tool response content', () => {
const messages: ChatCompletionMessageParam[] = [
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
tool_calls: [
{
id: 'call_1',
type: 'function',
function: {
name: 'search_web',
arguments: '{}'
}
}
]
},
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call_1',
content: [{ type: 'text', text: 'chunked result' }]
}
];
const result = GPTMessages2Chats({ messages, reserveTool: true });
expect(result).toHaveLength(1);
expect((result[0].value[0] as any).tools[0].response).toBe(
'[{"type":"text","text":"chunked result"}]'
);
});
it('should keep assistant content, reasoning and parallel tools continuous after chat roundtrip', () => { it('should keep assistant content, reasoning and parallel tools continuous after chat roundtrip', () => {
const messages: ChatCompletionMessageParam[] = [ const messages: ChatCompletionMessageParam[] = [
{ {
...@@ -1694,14 +2455,10 @@ describe('GPTMessages2Chats', () => { ...@@ -1694,14 +2455,10 @@ describe('GPTMessages2Chats', () => {
{ {
reasoning: { reasoning: {
content: 'Need weather and time.' content: 'Need weather and time.'
} },
},
{
text: { text: {
content: 'Checking both tools.' content: 'Checking both tools.'
} },
},
{
tools: [ tools: [
{ {
id: 'call_weather', id: 'call_weather',
...@@ -1856,7 +2613,34 @@ describe('GPTMessages2Chats', () => { ...@@ -1856,7 +2613,34 @@ describe('GPTMessages2Chats', () => {
expect((result[0] as any).hideInUI).toBe(true); expect((result[0] as any).hideInUI).toBe(true);
}); });
it('should restore assistant hideInUI to value level without hiding the whole AI chat', () => { it('should drop assistant reasoning when dataId prevents attaching to visible content', () => {
const messages: ChatCompletionMessageParam[] = [
{
dataId: 'reasoning-message',
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Reasoning from another assistant item'
},
{
dataId: 'answer-message',
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Visible answer'
}
];
const result = GPTMessages2Chats({ messages });
expect(result).toHaveLength(1);
expect(result[0].dataId).toBe('answer-message');
expect(result[0].value).toEqual([
{
text: {
content: 'Visible answer'
}
}
]);
});
it('should drop assistant reasoning when hideInUI prevents attaching to visible content', () => {
const messages: ChatCompletionMessageParam[] = [ const messages: ChatCompletionMessageParam[] = [
{ {
dataId: 'assistant-data-id', dataId: 'assistant-data-id',
...@@ -1877,12 +2661,6 @@ describe('GPTMessages2Chats', () => { ...@@ -1877,12 +2661,6 @@ describe('GPTMessages2Chats', () => {
expect(result[0].hideInUI).toBeUndefined(); expect(result[0].hideInUI).toBeUndefined();
expect(result[0].value).toEqual([ expect(result[0].value).toEqual([
{ {
reasoning: {
content: 'Hidden reasoning'
},
hideInUI: true
},
{
text: { text: {
content: 'Visible answer' content: 'Visible answer'
} }
......
...@@ -115,6 +115,27 @@ describe('helperChats2GPTMessages', () => { ...@@ -115,6 +115,27 @@ describe('helperChats2GPTMessages', () => {
expect(result[0].content).toBe('Hello, how can I help?'); expect(result[0].content).toBe('Hello, how can I help?');
}); });
it('should convert merged AI reasoning and text value with visible text', () => {
const messages = [
{
obj: ChatRoleEnum.AI,
value: [
{
reasoning: { content: 'Hidden thinking' },
hideReason: true,
text: { content: 'Visible answer' }
}
]
}
] as HelperBotChatItemType[];
const result = helperChats2GPTMessages({ messages });
expect(result).toHaveLength(1);
expect(result[0].role).toBe(ChatCompletionRequestMessageRoleEnum.Assistant);
expect(result[0].content).toBe('Visible answer');
});
it('should concat multiple AI text values', () => { it('should concat multiple AI text values', () => {
const messages = [ const messages = [
{ {
......
...@@ -47,6 +47,22 @@ describe('AIChatItemValueItemSchema', () => { ...@@ -47,6 +47,22 @@ describe('AIChatItemValueItemSchema', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
it('should validate merged text and hidden reasoning content', () => {
const result = AIChatItemValueItemSchema.safeParse({
reasoning: { content: 'Hidden thinking' },
hideReason: true,
text: { content: 'Visible answer' }
});
expect(result.success).toBe(true);
});
it('should reject content value without text or reasoning', () => {
const result = AIChatItemValueItemSchema.safeParse({
hideReason: true
});
expect(result.success).toBe(false);
});
it('should validate planHint', () => { it('should validate planHint', () => {
const result = AIChatItemValueItemSchema.safeParse({ const result = AIChatItemValueItemSchema.safeParse({
planHint: { type: 'generation' } planHint: { type: 'generation' }
......
...@@ -40,7 +40,13 @@ export const computedTemperature = ({ ...@@ -40,7 +40,13 @@ export const computedTemperature = ({
}; };
// LLM utils // LLM utils
// Parse <think></think> tags to think and answer - unstream response const normalizeFirstAnswerAfterReasoning = (answer: string) => answer.trimStart();
/**
* 从非流式模型结果中拆分 <think></think> 思考内容和最终回答。
* </think> 后面的前导空白只用于分隔 reasoning 与 answer,不作为正文保留,
* 避免 reasoning-only 输出被解析成 answerText="\n"。
*/
export const parseReasoningContent = (text: string): [string, string] => { export const parseReasoningContent = (text: string): [string, string] => {
const regex = /<think>([\s\S]*?)<\/think>/; const regex = /<think>([\s\S]*?)<\/think>/;
const match = text.match(regex); const match = text.match(regex);
...@@ -52,7 +58,9 @@ export const parseReasoningContent = (text: string): [string, string] => { ...@@ -52,7 +58,9 @@ export const parseReasoningContent = (text: string): [string, string] => {
const thinkContent = match[1].trim(); const thinkContent = match[1].trim();
// Add answer (remaining text after think tag) // Add answer (remaining text after think tag)
const answerContent = text.slice(match.index! + match[0].length); const answerContent = normalizeFirstAnswerAfterReasoning(
text.slice(match.index! + match[0].length)
);
return [thinkContent, answerContent]; return [thinkContent, answerContent];
}; };
...@@ -75,6 +83,29 @@ export const parseLLMStreamResponse = () => { ...@@ -75,6 +83,29 @@ export const parseLLMStreamResponse = () => {
let buffer_reasoningContent = ''; let buffer_reasoningContent = '';
let buffer_content = ''; let buffer_content = '';
let error: any = undefined; let error: any = undefined;
let shouldNormalizeFirstAnswer = false;
const normalizeContentBoundary = ({
reasoningContent,
content
}: {
reasoningContent: string;
content: string;
}) => {
if (reasoningContent) {
shouldNormalizeFirstAnswer = true;
}
if (!content || !shouldNormalizeFirstAnswer) return content;
const normalizedContent = normalizeFirstAnswerAfterReasoning(content);
if (normalizedContent) {
shouldNormalizeFirstAnswer = false;
}
return normalizedContent;
};
/* /*
parseThinkTag - 只控制是否主动解析 <think></think>,如果接口已经解析了,则不再解析。 parseThinkTag - 只控制是否主动解析 <think></think>,如果接口已经解析了,则不再解析。
...@@ -117,7 +148,7 @@ export const parseLLMStreamResponse = () => { ...@@ -117,7 +148,7 @@ export const parseLLMStreamResponse = () => {
const isStreamEnd = !!buffer_finishReason; const isStreamEnd = !!buffer_finishReason;
// Parse think // Parse think
const { reasoningContent: parsedThinkReasoningContent, content: parsedThinkContent } = const { reasoningContent: parsedThinkReasoningContent, content: rawParsedThinkContent } =
(() => { (() => {
if (reasoningContent || !parseThinkTag) { if (reasoningContent || !parseThinkTag) {
isInThinkTag = false; isInThinkTag = false;
...@@ -234,6 +265,11 @@ export const parseLLMStreamResponse = () => { ...@@ -234,6 +265,11 @@ export const parseLLMStreamResponse = () => {
}; };
})(); })();
const parsedThinkContent = normalizeContentBoundary({
reasoningContent: parsedThinkReasoningContent,
content: rawParsedThinkContent
});
// Parse datset cite // Parse datset cite
if (retainDatasetCite) { if (retainDatasetCite) {
return { return {
......
...@@ -20,15 +20,14 @@ export const formatAIResponse = ({ ...@@ -20,15 +20,14 @@ export const formatAIResponse = ({
}): AIChatItemValueItemType[] => { }): AIChatItemValueItemType[] => {
const result: AIChatItemValueItemType[] = []; const result: AIChatItemValueItemType[] = [];
if (reasoning) {
result.push({
reasoning: {
content: reasoning
}
});
}
result.push({ result.push({
...(reasoning
? {
reasoning: {
content: reasoning
}
}
: {}),
text: { text: {
content: text content: text
} }
......
...@@ -192,6 +192,10 @@ export const createWorkflowAgentLoopEventMapper = ({ ...@@ -192,6 +192,10 @@ export const createWorkflowAgentLoopEventMapper = ({
assistantText?: string; assistantText?: string;
reasoningText?: string; reasoningText?: string;
}) => { }) => {
// 没有可见 assistantText 时,不在 request end 阶段回填 reasoning 到已有 tool。
// reasoning 的归属需要按流式顺序附着到后续 content/tool,避免误挂到上一轮工具。
if (!assistantText) return;
const runtimeToolCalls = toolCalls.filter((call) => { const runtimeToolCalls = toolCalls.filter((call) => {
const functionName = call.function.name; const functionName = call.function.name;
return ( return (
...@@ -201,21 +205,22 @@ export const createWorkflowAgentLoopEventMapper = ({ ...@@ -201,21 +205,22 @@ export const createWorkflowAgentLoopEventMapper = ({
!isInternalTool(functionName, internalToolNames) !isInternalTool(functionName, internalToolNames)
); );
}); });
if (!runtimeToolCalls.length || (!assistantText && !reasoningText)) return; if (!runtimeToolCalls.length) return;
const runtimeToolCallIds = new Set(runtimeToolCalls.map((call) => call.id)); const runtimeToolCallIds = new Set(runtimeToolCalls.map((call) => call.id));
const existingIndexes = assistantResponses const existingIndex = assistantResponses.findIndex((item) =>
.map((item, index) => item.tools?.some((tool) => runtimeToolCallIds.has(tool.id))
item.tools?.some((tool) => runtimeToolCallIds.has(tool.id)) ? index : -1 );
) const insertIndex = existingIndex >= 0 ? existingIndex : assistantResponses.length;
.filter((index) => index >= 0);
const insertIndex = existingIndexes.length
? Math.min(...existingIndexes)
: assistantResponses.length;
const assistantValue: AIChatItemValueItemType = { const assistantValue: AIChatItemValueItemType = {
...(assistantText ? { text: { content: assistantText } } : {}), text: { content: assistantText },
...(reasoningText ? { reasoning: { content: reasoningText } } : {}) ...(reasoningText
? {
reasoning: { content: reasoningText },
...(!showReasoning ? { hideReason: true } : {})
}
: {})
}; };
assistantResponses.splice(insertIndex, 0, assistantValue); assistantResponses.splice(insertIndex, 0, assistantValue);
}; };
......
...@@ -332,26 +332,21 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise ...@@ -332,26 +332,21 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
: result.status === 'aborted' : result.status === 'aborted'
? i18nT('chat:completion_finish_error') ? i18nT('chat:completion_finish_error')
: undefined; : undefined;
const reasoningValue = result.reasoningText
if (result.reasoningText) { ? {
assistantResponses.push({ reasoning: {
reasoning: { content: result.reasoningText
content: result.reasoningText },
}, ...(aiChatReasoning === false ? { hideReason: true } : {})
...(aiChatReasoning === false ? { hideInUI: true } : {})
});
}
if (result.answerText) {
assistantResponses.push({
text: {
content: result.answerText
} }
}); : {};
} else if (errorText) { const finalText = result.answerText || errorText;
if (finalText) {
assistantResponses.push({ assistantResponses.push({
...reasoningValue,
text: { text: {
content: errorText content: finalText
} }
}); });
} }
......
...@@ -80,19 +80,17 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< ...@@ -80,19 +80,17 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
const appendFinalAssistantResponses = () => { const appendFinalAssistantResponses = () => {
const reasoningText = piRuntime?.getReasoningText() || ''; const reasoningText = piRuntime?.getReasoningText() || '';
const answerText = piRuntime?.getAnswerText() || ''; const answerText = piRuntime?.getAnswerText() || '';
const showReasoning = aiChatReasoning !== false;
if (reasoningText) {
assistantResponses.push({
reasoning: {
content: reasoningText
},
...(!showReasoning ? { hideInUI: true } : {})
});
}
if (answerText) { if (answerText) {
assistantResponses.push({ assistantResponses.push({
...(reasoningText
? {
reasoning: {
content: reasoningText
},
...(aiChatReasoning === false ? { hideReason: true } : {})
}
: {}),
text: { text: {
content: answerText content: answerText
} }
......
...@@ -146,7 +146,7 @@ const completions = async ({ ...@@ -146,7 +146,7 @@ const completions = async ({
body: { body: {
model: cqModel.model, model: cqModel.model,
temperature: 0.01, temperature: 0.01,
messages: chats2GPTMessages({ messages, reserveId: false }), messages: chats2GPTMessages({ messages, reserveId: false, reserveReason: false }),
stream: true stream: true
}, },
userKey: externalProvider.openaiAccount userKey: externalProvider.openaiAccount
......
...@@ -209,7 +209,11 @@ const toolChoice = async (props: ActionProps) => { ...@@ -209,7 +209,11 @@ const toolChoice = async (props: ActionProps) => {
] ]
} }
]; ];
const adaptMessages = chats2GPTMessages({ messages, reserveId: false }); const adaptMessages = chats2GPTMessages({
messages,
reserveId: false,
reserveReason: false
});
const filterMessages = await filterGPTMessageByMaxContext({ const filterMessages = await filterGPTMessageByMaxContext({
messages: adaptMessages, messages: adaptMessages,
maxContext: extractModel.maxContext maxContext: extractModel.maxContext
...@@ -317,7 +321,7 @@ const completions = async (props: ActionProps) => { ...@@ -317,7 +321,7 @@ const completions = async (props: ActionProps) => {
body: { body: {
model: extractModel.model, model: extractModel.model,
temperature: 0.01, temperature: 0.01,
messages: chats2GPTMessages({ messages, reserveId: false }), messages: chats2GPTMessages({ messages, reserveId: false, reserveReason: false }),
stream: true stream: true
}, },
userKey: externalProvider.openaiAccount userKey: externalProvider.openaiAccount
......
...@@ -1212,15 +1212,16 @@ export class WorkflowQueue { ...@@ -1212,15 +1212,16 @@ export class WorkflowQueue {
if (assistantResponses) { if (assistantResponses) {
this.chatAssistantResponse = this.chatAssistantResponse.concat(assistantResponses); this.chatAssistantResponse = this.chatAssistantResponse.concat(assistantResponses);
} else { } else {
if (reasoningText) { // reasoning 不能独立落历史;只有存在可见文本时才附着保存。
this.chatAssistantResponse.push({
reasoning: {
content: reasoningText
}
});
}
if (answerText) { if (answerText) {
this.chatAssistantResponse.push({ this.chatAssistantResponse.push({
...(reasoningText
? {
reasoning: {
content: reasoningText
}
}
: {}),
text: { text: {
content: answerText content: answerText
} }
......
...@@ -85,6 +85,14 @@ describe('parseReasoningContent', () => { ...@@ -85,6 +85,14 @@ describe('parseReasoningContent', () => {
expect(parseReasoningContent('<think>reasoning</think>')).toEqual(['reasoning', '']); expect(parseReasoningContent('<think>reasoning</think>')).toEqual(['reasoning', '']);
}); });
it('should remove separator whitespace after think tag', () => {
expect(parseReasoningContent('<think>reasoning</think>\n')).toEqual(['reasoning', '']);
expect(parseReasoningContent('<think>reasoning</think>\n\nanswer')).toEqual([
'reasoning',
'answer'
]);
});
it('should handle multiline think content', () => { it('should handle multiline think content', () => {
expect(parseReasoningContent('<think>line1\nline2</think>answer')).toEqual([ expect(parseReasoningContent('<think>line1\nline2</think>answer')).toEqual([
'line1\nline2', 'line1\nline2',
...@@ -191,6 +199,22 @@ describe('parseLLMStreamResponse', () => { ...@@ -191,6 +199,22 @@ describe('parseLLMStreamResponse', () => {
correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' } correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' }
}, },
{ {
data: [{ content: '<think>这是' }, { content: '思考过程</think>\n' }],
correct: { answer: '', reasoning: '这是思考过程' }
},
{
data: [{ content: '<think>这是' }, { content: '思考过程</think>\n' }, { content: '你好1' }],
correct: { answer: '你好1', reasoning: '这是思考过程' }
},
{
data: [{ content: '<think>这是' }, { content: '思考过程</think>\n\n你好1' }],
correct: { answer: '你好1', reasoning: '这是思考过程' }
},
{
data: [{ reasoning_content: '这是思考过程' }, { content: '\n' }, { content: '你好1' }],
correct: { answer: '你好1', reasoning: '这是思考过程' }
},
{
data: [ data: [
{ content: '<think>这是' }, { content: '<think>这是' },
{ content: '思考' }, { content: '思考' },
......
...@@ -97,6 +97,81 @@ describe('createWorkflowAgentLoopEventMapper', () => { ...@@ -97,6 +97,81 @@ describe('createWorkflowAgentLoopEventMapper', () => {
}); });
}); });
it('hides reasoning stream but persists reasoning with hideReason', () => {
const workflowStreamResponse = vi.fn();
const mapper = createWorkflowAgentLoopEventMapper({
workflowStreamResponse,
getSubAppInfo: (id) => ({
name: id,
avatar: '',
toolDescription: ''
}),
internalToolNames: new Set(),
showReasoning: false
});
mapper.emitEvent({
type: 'reasoning_delta',
text: 'hidden'
});
expect(workflowStreamResponse).not.toHaveBeenCalled();
mapper.emitEvent({
type: 'tool_call',
call: {
id: 'call_search',
type: 'function',
function: {
name: 'search',
arguments: '{}'
}
}
});
mapper.emitEvent({
type: 'llm_request_end',
requestIndex: 1,
modelName: 'GPT-4',
requestId: 'req_search',
finishReason: 'tool_calls',
answerText: 'Need to search.',
reasoningText: 'hidden thinking',
toolCalls: [
{
id: 'call_search',
type: 'function',
function: {
name: 'search',
arguments: '{}'
}
}
]
});
expect(mapper.assistantResponses).toEqual([
{
text: {
content: 'Need to search.'
},
reasoning: {
content: 'hidden thinking'
},
hideReason: true
},
{
id: 'call_search',
tools: [
{
id: 'call_search',
toolName: 'search',
toolAvatar: '',
functionName: 'search',
params: '{}'
}
]
}
]);
});
it('filters internal tool calls and streams runtime tool lifecycle events', () => { it('filters internal tool calls and streams runtime tool lifecycle events', () => {
const workflowStreamResponse = vi.fn(); const workflowStreamResponse = vi.fn();
const mapper = createWorkflowAgentLoopEventMapper({ const mapper = createWorkflowAgentLoopEventMapper({
......
...@@ -228,4 +228,42 @@ describe('dispatchRunAgent user context', () => { ...@@ -228,4 +228,42 @@ describe('dispatchRunAgent user context', () => {
} }
]); ]);
}); });
it('keeps reasoning with hideReason when reasoning display is disabled', async () => {
const { dispatchRunAgent } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent');
const props = createProps();
props.params.aiChatReasoning = false;
runUnifiedAgentLoopMock.mockResolvedValueOnce({
status: 'done',
answerText: 'ok',
reasoningText: 'hidden thinking',
completeMessages: [],
assistantMessages: [],
requestIds: []
});
let resultPromise: Promise<any>;
runWithContext(
{
queryUrlTypeMap: {},
mcpClientMemory: {}
},
() => {
resultPromise = dispatchRunAgent(props);
}
);
const result = await resultPromise!;
expect(result[DispatchNodeResponseKeyEnum.assistantResponses]).toEqual([
{
reasoning: {
content: 'hidden thinking'
},
hideReason: true,
text: {
content: 'ok'
}
}
]);
});
}); });
...@@ -265,4 +265,43 @@ describe('dispatchPiAgent user context', () => { ...@@ -265,4 +265,43 @@ describe('dispatchPiAgent user context', () => {
} }
]); ]);
}); });
it('keeps reasoning with hideReason when reasoning display is disabled', async () => {
const { dispatchPiAgent } =
await import('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent');
const props = createProps();
props.params.aiChatReasoning = false;
createPiAgentWorkflowRuntimeMock.mockReturnValueOnce({
onPayload: vi.fn(),
handleAgentEvent: vi.fn(),
appendChildNodeResponse: vi.fn(),
getReasoningText: vi.fn(() => 'hidden thinking'),
getAnswerText: vi.fn(() => 'pi answer'),
appendPendingAgentError: vi.fn()
});
let resultPromise: Promise<any>;
runWithContext(
{
queryUrlTypeMap: {},
mcpClientMemory: {}
},
() => {
resultPromise = dispatchPiAgent(props);
}
);
const result = await resultPromise!;
expect(result[DispatchNodeResponseKeyEnum.assistantResponses]).toEqual([
{
reasoning: {
content: 'hidden thinking'
},
hideReason: true,
text: {
content: 'pi answer'
}
}
]);
});
}); });
...@@ -456,17 +456,24 @@ const ChatBox = ({ ...@@ -456,17 +456,24 @@ const ChatBox = ({
}; };
} }
if (event === SseResponseEventEnum.answer || event === SseResponseEventEnum.fastAnswer) { if (event === SseResponseEventEnum.answer || event === SseResponseEventEnum.fastAnswer) {
const replaceUpdateValue = (nextValue: AIChatItemValueItemType) => ({
...item,
value: [
...item.value.slice(0, updateIndex),
nextValue,
...item.value.slice(updateIndex + 1)
]
});
if (reasoningText) { if (reasoningText) {
if (updateValue?.reasoning) { if (updateValue?.reasoning) {
updateValue.reasoning.content += reasoningText; updateValue.reasoning.content += reasoningText;
return { return replaceUpdateValue(updateValue);
...item, } else if (updateValue?.text && !updateValue.text.content) {
value: [ updateValue.reasoning = {
...item.value.slice(0, updateIndex), content: reasoningText
updateValue,
...item.value.slice(updateIndex + 1)
]
}; };
return replaceUpdateValue(updateValue);
} else { } else {
const val: AIChatItemValueItemType = { const val: AIChatItemValueItemType = {
id: responseValueId, id: responseValueId,
...@@ -483,14 +490,12 @@ const ChatBox = ({ ...@@ -483,14 +490,12 @@ const ChatBox = ({
if (text) { if (text) {
if (updateValue?.text) { if (updateValue?.text) {
updateValue.text.content += text; updateValue.text.content += text;
return { return replaceUpdateValue(updateValue);
...item, } else if (updateValue?.reasoning) {
value: [ updateValue.text = {
...item.value.slice(0, updateIndex), content: text
updateValue,
...item.value.slice(updateIndex + 1)
]
}; };
return replaceUpdateValue(updateValue);
} else { } else {
const newValue: AIChatItemValueItemType = { const newValue: AIChatItemValueItemType = {
id: responseValueId, id: responseValueId,
......
...@@ -173,6 +173,14 @@ const AIItem = ({ ...@@ -173,6 +173,14 @@ const AIItem = ({
onSubmitCollectionForm: (formData: string) => void; onSubmitCollectionForm: (formData: string) => void;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const firstValue = chat.value[0];
const isWaitingForResponse =
chat.value.length === 1 &&
!(
('text' in firstValue && firstValue.text?.content) ||
('reasoning' in firstValue && firstValue.reasoning && !firstValue.hideReason)
);
return ( return (
<Box <Box
_hover={{ _hover={{
...@@ -192,8 +200,7 @@ const AIItem = ({ ...@@ -192,8 +200,7 @@ const AIItem = ({
color={'myGray.900'} color={'myGray.900'}
bg={'myGray.100'} bg={'myGray.100'}
> >
{chat.value.length === 1 && {isWaitingForResponse ? (
(!('text' in chat.value[0]) || !chat.value[0].text?.content) ? (
<RenderText showAnimation={true} text={t('chat:chat.waiting_for_response')} /> <RenderText showAnimation={true} text={t('chat:chat.waiting_for_response')} />
) : ( ) : (
<> <>
...@@ -203,25 +210,6 @@ const AIItem = ({ ...@@ -203,25 +210,6 @@ const AIItem = ({
<RenderText key={i} showAnimation={false} text={t('chat:plan_check_tip')} /> <RenderText key={i} showAnimation={false} text={t('chat:plan_check_tip')} />
); );
} }
if ('text' in value && value.text) {
return (
<RenderText
key={i}
showAnimation={isChatting && isLastChild}
text={value.text.content}
/>
);
}
if ('reasoning' in value && value.reasoning) {
return (
<RenderResoningContent
key={i}
isChatting={isChatting}
isLastResponseValue={isLastChild}
content={value.reasoning.content}
/>
);
}
if ('collectionForm' in value && value.collectionForm) { if ('collectionForm' in value && value.collectionForm) {
return ( return (
<RenderCollectionForm <RenderCollectionForm
...@@ -232,6 +220,24 @@ const AIItem = ({ ...@@ -232,6 +220,24 @@ const AIItem = ({
/> />
); );
} }
return (
<React.Fragment key={i}>
{'reasoning' in value && value.reasoning && !value.hideReason && (
<RenderResoningContent
isChatting={isChatting}
isLastResponseValue={isLastChild}
content={value.reasoning.content}
/>
)}
{'text' in value && value.text && (
<RenderText
showAnimation={isChatting && isLastChild}
text={value.text.content}
/>
)}
</React.Fragment>
);
})} })}
</> </>
)} )}
......
...@@ -180,17 +180,31 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro ...@@ -180,17 +180,31 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro
} }
if (event === SseResponseEventEnum.answer || event === SseResponseEventEnum.fastAnswer) { if (event === SseResponseEventEnum.answer || event === SseResponseEventEnum.fastAnswer) {
const replaceUpdateValue = (nextValue: AIChatItemValueItemType) => ({
...item,
value: [
...item.value.slice(0, updateIndex),
nextValue,
...item.value.slice(updateIndex + 1)
]
});
if (reasoningText) { if (reasoningText) {
if ('reasoning' in updateValue && updateValue.reasoning) { if ('reasoning' in updateValue && updateValue.reasoning) {
updateValue.reasoning.content += reasoningText; return replaceUpdateValue({
return { ...updateValue,
...item, reasoning: {
value: [ ...updateValue.reasoning,
...item.value.slice(0, updateIndex), content: updateValue.reasoning.content + reasoningText
updateValue, }
...item.value.slice(updateIndex + 1) });
] } else if ('text' in updateValue && updateValue.text && !updateValue.text.content) {
}; return replaceUpdateValue({
...updateValue,
reasoning: {
content: reasoningText
}
});
} else { } else {
const val: AIChatItemValueItemType = { const val: AIChatItemValueItemType = {
reasoning: { reasoning: {
...@@ -205,15 +219,20 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro ...@@ -205,15 +219,20 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro
} }
if (text) { if (text) {
if ('text' in updateValue && updateValue.text) { if ('text' in updateValue && updateValue.text) {
updateValue.text.content += text; return replaceUpdateValue({
return { ...updateValue,
...item, text: {
value: [ ...updateValue.text,
...item.value.slice(0, updateIndex), content: updateValue.text.content + text
updateValue, }
...item.value.slice(updateIndex + 1) });
] } else if ('reasoning' in updateValue && updateValue.reasoning) {
}; return replaceUpdateValue({
...updateValue,
text: {
content: text
}
});
} else { } else {
const newValue: AIChatItemValueItemType = { const newValue: AIChatItemValueItemType = {
text: { text: {
...@@ -258,7 +277,7 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro ...@@ -258,7 +277,7 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro
} }
const chatItemDataId = getNanoid(24); const chatItemDataId = getNanoid(24);
let newChatList: HelperBotChatItemSiteType[] = [ const newChatList: HelperBotChatItemSiteType[] = [
...chatRecords, ...chatRecords,
// 用户消息 // 用户消息
{ {
......
...@@ -43,7 +43,7 @@ const AIResponseBox = ({ ...@@ -43,7 +43,7 @@ const AIResponseBox = ({
const responseBlocks: React.ReactNode[] = []; const responseBlocks: React.ReactNode[] = [];
if ('reasoning' in value && value.reasoning) { if ('reasoning' in value && value.reasoning && !value.hideReason) {
responseBlocks.push( responseBlocks.push(
<RenderReasoningContent <RenderReasoningContent
key="reasoning" key="reasoning"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment