Commit 05254ce0 by Archer Committed by GitHub

perf: adapt messages (#6959)

parent 0f55590a
......@@ -15,3 +15,4 @@ description: 'FastGPT V4.14.21 更新说明'
1. 工作流默认选中模型未回传表单值,导致看到的模型和实际运行模型不一致。
2. 工作流自动保存时,存在边丢失风险。
3. admin 修改系统通知弹窗时候报错。
4. 工作流混用思考/非思考模型,可能出现独立 reason 字段上下文,导致模型调用报错。
......@@ -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.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/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.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......
import { ObjectIdSchema } from '../../../common/type/mongo';
import z from 'zod';
import { ChatRoleEnum } from '../constants';
import { UserChatItemSchema, SystemChatItemSchema, ToolModuleResponseItemSchema } from '../type';
import { UserChatItemSchema, SystemChatItemSchema } from '../type';
import { UserInputInteractiveSchema } from '../../workflow/template/system/interactive/type';
export enum HelperBotTypeEnum {
......@@ -21,18 +21,27 @@ export const HelperBotChatSchema = z.object({
});
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
export const AIChatItemValueItemSchema = z.union([
z.object({
text: z.object({
content: z.string()
})
}),
z.object({
reasoning: z.object({
content: z.string()
})
}),
AIChatContentValueSchema,
z.object({
collectionForm: UserInputInteractiveSchema
}),
......
......@@ -225,6 +225,7 @@ export const AIChatItemValueSchema = z.object({
agentStopGate: AgentLoopStopGateSchema.nullish(),
contextCheckpoint: ContextCheckpointValueSchema.nullish(),
tool: ToolModuleResponseItemSchema.nullish().meta({ deprecated: true }),
hideReason: z.boolean().optional(),
hideInUI: z.boolean().optional()
});
......
......@@ -152,27 +152,25 @@ export const removeAIResponseCite = <T extends AIChatItemValueItemType[] | strin
return removeDatasetCiteText(value, false) as T;
}
return value.map<AIChatItemValueItemType>((item) => {
if (item.text?.content) {
return {
...item,
text: {
...item.text,
content: removeDatasetCiteText(item.text.content, false)
return value.map<AIChatItemValueItemType>((item) => ({
...item,
...(item.text?.content
? {
text: {
...item.text,
content: removeDatasetCiteText(item.text.content, false)
}
}
};
}
if (item.reasoning?.content) {
return {
...item,
reasoning: {
...item.reasoning,
content: removeDatasetCiteText(item.reasoning.content, false)
: {}),
...(item.reasoning?.content
? {
reasoning: {
...item.reasoning,
content: removeDatasetCiteText(item.reasoning.content, false)
}
}
};
}
return item;
}) as T;
: {})
})) as T;
};
export const removeEmptyUserInput = (input?: UserChatItemValueItemType[]) => {
......
......@@ -115,6 +115,27 @@ describe('helperChats2GPTMessages', () => {
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', () => {
const messages = [
{
......
......@@ -47,6 +47,22 @@ describe('AIChatItemValueItemSchema', () => {
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', () => {
const result = AIChatItemValueItemSchema.safeParse({
planHint: { type: 'generation' }
......
......@@ -40,7 +40,13 @@ export const computedTemperature = ({
};
// 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] => {
const regex = /<think>([\s\S]*?)<\/think>/;
const match = text.match(regex);
......@@ -52,7 +58,9 @@ export const parseReasoningContent = (text: string): [string, string] => {
const thinkContent = match[1].trim();
// 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];
};
......@@ -75,6 +83,29 @@ export const parseLLMStreamResponse = () => {
let buffer_reasoningContent = '';
let buffer_content = '';
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>,如果接口已经解析了,则不再解析。
......@@ -117,7 +148,7 @@ export const parseLLMStreamResponse = () => {
const isStreamEnd = !!buffer_finishReason;
// Parse think
const { reasoningContent: parsedThinkReasoningContent, content: parsedThinkContent } =
const { reasoningContent: parsedThinkReasoningContent, content: rawParsedThinkContent } =
(() => {
if (reasoningContent || !parseThinkTag) {
isInThinkTag = false;
......@@ -234,6 +265,11 @@ export const parseLLMStreamResponse = () => {
};
})();
const parsedThinkContent = normalizeContentBoundary({
reasoningContent: parsedThinkReasoningContent,
content: rawParsedThinkContent
});
// Parse datset cite
if (retainDatasetCite) {
return {
......
......@@ -20,15 +20,14 @@ export const formatAIResponse = ({
}): AIChatItemValueItemType[] => {
const result: AIChatItemValueItemType[] = [];
if (reasoning) {
result.push({
reasoning: {
content: reasoning
}
});
}
result.push({
...(reasoning
? {
reasoning: {
content: reasoning
}
}
: {}),
text: {
content: text
}
......
......@@ -192,6 +192,10 @@ export const createWorkflowAgentLoopEventMapper = ({
assistantText?: string;
reasoningText?: string;
}) => {
// 没有可见 assistantText 时,不在 request end 阶段回填 reasoning 到已有 tool。
// reasoning 的归属需要按流式顺序附着到后续 content/tool,避免误挂到上一轮工具。
if (!assistantText) return;
const runtimeToolCalls = toolCalls.filter((call) => {
const functionName = call.function.name;
return (
......@@ -201,21 +205,22 @@ export const createWorkflowAgentLoopEventMapper = ({
!isInternalTool(functionName, internalToolNames)
);
});
if (!runtimeToolCalls.length || (!assistantText && !reasoningText)) return;
if (!runtimeToolCalls.length) return;
const runtimeToolCallIds = new Set(runtimeToolCalls.map((call) => call.id));
const existingIndexes = assistantResponses
.map((item, index) =>
item.tools?.some((tool) => runtimeToolCallIds.has(tool.id)) ? index : -1
)
.filter((index) => index >= 0);
const insertIndex = existingIndexes.length
? Math.min(...existingIndexes)
: assistantResponses.length;
const existingIndex = assistantResponses.findIndex((item) =>
item.tools?.some((tool) => runtimeToolCallIds.has(tool.id))
);
const insertIndex = existingIndex >= 0 ? existingIndex : assistantResponses.length;
const assistantValue: AIChatItemValueItemType = {
...(assistantText ? { text: { content: assistantText } } : {}),
...(reasoningText ? { reasoning: { content: reasoningText } } : {})
text: { content: assistantText },
...(reasoningText
? {
reasoning: { content: reasoningText },
...(!showReasoning ? { hideReason: true } : {})
}
: {})
};
assistantResponses.splice(insertIndex, 0, assistantValue);
};
......
......@@ -332,26 +332,21 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
: result.status === 'aborted'
? i18nT('chat:completion_finish_error')
: undefined;
if (result.reasoningText) {
assistantResponses.push({
reasoning: {
content: result.reasoningText
},
...(aiChatReasoning === false ? { hideInUI: true } : {})
});
}
if (result.answerText) {
assistantResponses.push({
text: {
content: result.answerText
const reasoningValue = result.reasoningText
? {
reasoning: {
content: result.reasoningText
},
...(aiChatReasoning === false ? { hideReason: true } : {})
}
});
} else if (errorText) {
: {};
const finalText = result.answerText || errorText;
if (finalText) {
assistantResponses.push({
...reasoningValue,
text: {
content: errorText
content: finalText
}
});
}
......
......@@ -80,19 +80,17 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
const appendFinalAssistantResponses = () => {
const reasoningText = piRuntime?.getReasoningText() || '';
const answerText = piRuntime?.getAnswerText() || '';
const showReasoning = aiChatReasoning !== false;
if (reasoningText) {
assistantResponses.push({
reasoning: {
content: reasoningText
},
...(!showReasoning ? { hideInUI: true } : {})
});
}
if (answerText) {
assistantResponses.push({
...(reasoningText
? {
reasoning: {
content: reasoningText
},
...(aiChatReasoning === false ? { hideReason: true } : {})
}
: {}),
text: {
content: answerText
}
......
......@@ -146,7 +146,7 @@ const completions = async ({
body: {
model: cqModel.model,
temperature: 0.01,
messages: chats2GPTMessages({ messages, reserveId: false }),
messages: chats2GPTMessages({ messages, reserveId: false, reserveReason: false }),
stream: true
},
userKey: externalProvider.openaiAccount
......
......@@ -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({
messages: adaptMessages,
maxContext: extractModel.maxContext
......@@ -317,7 +321,7 @@ const completions = async (props: ActionProps) => {
body: {
model: extractModel.model,
temperature: 0.01,
messages: chats2GPTMessages({ messages, reserveId: false }),
messages: chats2GPTMessages({ messages, reserveId: false, reserveReason: false }),
stream: true
},
userKey: externalProvider.openaiAccount
......
......@@ -1212,15 +1212,16 @@ export class WorkflowQueue {
if (assistantResponses) {
this.chatAssistantResponse = this.chatAssistantResponse.concat(assistantResponses);
} else {
if (reasoningText) {
this.chatAssistantResponse.push({
reasoning: {
content: reasoningText
}
});
}
// reasoning 不能独立落历史;只有存在可见文本时才附着保存。
if (answerText) {
this.chatAssistantResponse.push({
...(reasoningText
? {
reasoning: {
content: reasoningText
}
}
: {}),
text: {
content: answerText
}
......
......@@ -85,6 +85,14 @@ describe('parseReasoningContent', () => {
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', () => {
expect(parseReasoningContent('<think>line1\nline2</think>answer')).toEqual([
'line1\nline2',
......@@ -191,6 +199,22 @@ describe('parseLLMStreamResponse', () => {
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: [
{ content: '<think>这是' },
{ content: '思考' },
......
......@@ -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', () => {
const workflowStreamResponse = vi.fn();
const mapper = createWorkflowAgentLoopEventMapper({
......
......@@ -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', () => {
}
]);
});
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 = ({
};
}
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 (updateValue?.reasoning) {
updateValue.reasoning.content += reasoningText;
return {
...item,
value: [
...item.value.slice(0, updateIndex),
updateValue,
...item.value.slice(updateIndex + 1)
]
return replaceUpdateValue(updateValue);
} else if (updateValue?.text && !updateValue.text.content) {
updateValue.reasoning = {
content: reasoningText
};
return replaceUpdateValue(updateValue);
} else {
const val: AIChatItemValueItemType = {
id: responseValueId,
......@@ -483,14 +490,12 @@ const ChatBox = ({
if (text) {
if (updateValue?.text) {
updateValue.text.content += text;
return {
...item,
value: [
...item.value.slice(0, updateIndex),
updateValue,
...item.value.slice(updateIndex + 1)
]
return replaceUpdateValue(updateValue);
} else if (updateValue?.reasoning) {
updateValue.text = {
content: text
};
return replaceUpdateValue(updateValue);
} else {
const newValue: AIChatItemValueItemType = {
id: responseValueId,
......
......@@ -173,6 +173,14 @@ const AIItem = ({
onSubmitCollectionForm: (formData: string) => void;
}) => {
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 (
<Box
_hover={{
......@@ -192,8 +200,7 @@ const AIItem = ({
color={'myGray.900'}
bg={'myGray.100'}
>
{chat.value.length === 1 &&
(!('text' in chat.value[0]) || !chat.value[0].text?.content) ? (
{isWaitingForResponse ? (
<RenderText showAnimation={true} text={t('chat:chat.waiting_for_response')} />
) : (
<>
......@@ -203,25 +210,6 @@ const AIItem = ({
<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) {
return (
<RenderCollectionForm
......@@ -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
}
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 ('reasoning' in updateValue && updateValue.reasoning) {
updateValue.reasoning.content += reasoningText;
return {
...item,
value: [
...item.value.slice(0, updateIndex),
updateValue,
...item.value.slice(updateIndex + 1)
]
};
return replaceUpdateValue({
...updateValue,
reasoning: {
...updateValue.reasoning,
content: updateValue.reasoning.content + reasoningText
}
});
} else if ('text' in updateValue && updateValue.text && !updateValue.text.content) {
return replaceUpdateValue({
...updateValue,
reasoning: {
content: reasoningText
}
});
} else {
const val: AIChatItemValueItemType = {
reasoning: {
......@@ -205,15 +219,20 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro
}
if (text) {
if ('text' in updateValue && updateValue.text) {
updateValue.text.content += text;
return {
...item,
value: [
...item.value.slice(0, updateIndex),
updateValue,
...item.value.slice(updateIndex + 1)
]
};
return replaceUpdateValue({
...updateValue,
text: {
...updateValue.text,
content: updateValue.text.content + text
}
});
} else if ('reasoning' in updateValue && updateValue.reasoning) {
return replaceUpdateValue({
...updateValue,
text: {
content: text
}
});
} else {
const newValue: AIChatItemValueItemType = {
text: {
......@@ -258,7 +277,7 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro
}
const chatItemDataId = getNanoid(24);
let newChatList: HelperBotChatItemSiteType[] = [
const newChatList: HelperBotChatItemSiteType[] = [
...chatRecords,
// 用户消息
{
......
......@@ -43,7 +43,7 @@ const AIResponseBox = ({
const responseBlocks: React.ReactNode[] = [];
if ('reasoning' in value && value.reasoning) {
if ('reasoning' in value && value.reasoning && !value.hideReason) {
responseBlocks.push(
<RenderReasoningContent
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