From bf9185c018bdbf7e761726d0443502c2f78e80a4 Mon Sep 17 00:00:00 2001
From: xxyyh <2289112474@qq.com>
Date: Wed, 4 Mar 2026 18:13:12 +0800
Subject: [PATCH 1/4] fix: 1.image read 2.JSON parsing error
---
.../core/workflow/dispatch/ai/agent/index.ts | 37 +++-
.../workflow/dispatch/ai/agent/master/call.ts | 3 +
.../dispatch/ai/agent/sub/plan/index.ts | 175 +++++++++++++++---
packages/service/package.json | 1 +
packages/web/i18n/en/chat.json | 1 +
packages/web/i18n/zh-CN/chat.json | 1 +
packages/web/i18n/zh-Hant/chat.json | 1 +
pnpm-lock.yaml | 9 +
.../app/detail/Edit/ChatAgent/utils.ts | 7 +
9 files changed, 203 insertions(+), 32 deletions(-)
diff --git a/packages/service/core/workflow/dispatch/ai/agent/index.ts b/packages/service/core/workflow/dispatch/ai/agent/index.ts
index b93bbeae1a8d..23e14d138cbc 100644
--- a/packages/service/core/workflow/dispatch/ai/agent/index.ts
+++ b/packages/service/core/workflow/dispatch/ai/agent/index.ts
@@ -18,6 +18,7 @@ import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import {
chats2GPTMessages,
chatValue2RuntimePrompt,
+ runtimePrompt2ChatsValue,
GPTMessages2Chats
} from '@fastgpt/global/core/chat/adapt';
import { getPlanCallResponseText } from '@fastgpt/global/core/chat/utils';
@@ -37,6 +38,7 @@ import { getContinuePlanQuery, parseUserSystemPrompt } from './sub/plan/prompt';
import type { PlanAgentParamsType } from './sub/plan/constants';
import type { AppFormEditFormType } from '@fastgpt/global/core/app/formEdit/type';
import { getLogger, LogCategories } from '../../../../../common/logger';
+import { getLLMModel } from '../../../../ai/model';
export type DispatchAgentModuleProps = ModuleDispatchProps<{
[NodeInputKeyEnum.history]?: ChatItemType[];
@@ -45,6 +47,7 @@ export type DispatchAgentModuleProps = ModuleDispatchProps<{
[NodeInputKeyEnum.fileUrlList]?: string[];
[NodeInputKeyEnum.aiModel]: string;
[NodeInputKeyEnum.aiSystemPrompt]: string;
+ [NodeInputKeyEnum.aiChatVision]?: boolean;
[NodeInputKeyEnum.selectedTools]?: SkillToolType[];
@@ -87,6 +90,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
userChatInput, // 本次任务的输入
history = 6,
fileUrlList: fileLinks,
+ aiChatVision = true,
agent_selectedTools: selectedTools = [],
// Dataset search configuration
agent_datasetParams: datasetParams,
@@ -94,6 +98,8 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
useAgentSandbox = false
}
} = props;
+ const modelData = getLLMModel(model);
+ const normalizedVision = !!(aiChatVision && modelData?.vision);
const chatHistories = getHistories(history, histories);
const aiHistoryValues = chatHistories
.filter((item) => item.obj === ChatRoleEnum.AI)
@@ -130,16 +136,25 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
});
// 交互模式进来的话,这个值才是交互输入的值
- const queryInput = chatValue2RuntimePrompt(query).text;
+ const { text: queryInput, files: queryFiles } = chatValue2RuntimePrompt(query);
const formatUserChatInput = fileInputPrompt
? `${fileInputPrompt}\n\n${userChatInput}`
: userChatInput;
+ const currentUserMessage = chats2GPTMessages({
+ messages: [
+ {
+ obj: ChatRoleEnum.Human,
+ value: runtimePrompt2ChatsValue({
+ text: formatUserChatInput,
+ files: queryFiles
+ })
+ }
+ ],
+ reserveId: false
+ })[0];
let {
- masterMessages = historiesMessages.concat({
- role: 'user',
- content: formatUserChatInput
- }),
+ masterMessages: restoredMasterMessages,
planHistoryMessages,
agentPlan,
planBuffer
@@ -162,6 +177,16 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
planBuffer: undefined
};
})();
+ let masterMessages: ChatCompletionMessageParam[];
+ if (!restoredMasterMessages) {
+ masterMessages = historiesMessages.concat(currentUserMessage ? [currentUserMessage] : []);
+ } else if (planHistoryMessages?.length) {
+ masterMessages = restoredMasterMessages ?? historiesMessages;
+ } else {
+ masterMessages = currentUserMessage
+ ? restoredMasterMessages.concat(currentUserMessage)
+ : restoredMasterMessages;
+ }
// Get sub apps
const { completionTools: agentCompletionTools, subAppsMap: agentSubAppsMap } = await getSubapps(
@@ -383,6 +408,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
systemPrompt: formatedSystemPrompt,
masterMessages: [],
planMessages: [],
+ useVision: normalizedVision,
getSubAppInfo,
getSubApp,
completionTools: agentCompletionTools,
@@ -455,6 +481,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
...props,
masterMessages,
planMessages: planHistoryMessages || [],
+ useVision: normalizedVision,
systemPrompt: formatedSystemPrompt,
getSubAppInfo,
getSubApp,
diff --git a/packages/service/core/workflow/dispatch/ai/agent/master/call.ts b/packages/service/core/workflow/dispatch/ai/agent/master/call.ts
index e849692384c3..8347338522a0 100644
--- a/packages/service/core/workflow/dispatch/ai/agent/master/call.ts
+++ b/packages/service/core/workflow/dispatch/ai/agent/master/call.ts
@@ -54,6 +54,7 @@ export const masterCall = async ({
systemPrompt,
masterMessages,
planMessages,
+ useVision,
getSubAppInfo,
getSubApp,
completionTools,
@@ -64,6 +65,7 @@ export const masterCall = async ({
}: DispatchAgentModuleProps & {
masterMessages: ChatCompletionMessageParam[];
planMessages: ChatCompletionMessageParam[];
+ useVision: boolean;
systemPrompt?: string;
getSubAppInfo: GetSubAppInfoFnType;
@@ -215,6 +217,7 @@ export const masterCall = async ({
messages: requestMessages,
model: getLLMModel(model),
stream: true,
+ useVision,
tools: isStepCall
? completionTools.filter((item) => item.function.name !== SubAppIds.plan)
: completionTools
diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts
index d2bc51d7f3cb..cc30c1fc7ea9 100644
--- a/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts
+++ b/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts
@@ -16,7 +16,8 @@ import { parseJsonArgs } from '../../../../../../ai/utils';
import { AIAskAnswerSchema, AIAskTool } from './ask/constants';
import { AgentPlanSchema, type AgentPlanType } from '@fastgpt/global/core/ai/agent/type';
import type { GetSubAppInfoFnType } from '../../type';
-import { getNanoid } from '@fastgpt/global/common/string/tools';
+import { getNanoid, sliceJsonStr } from '@fastgpt/global/common/string/tools';
+import { jsonrepair } from 'jsonrepair';
import {
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
@@ -28,6 +29,8 @@ import type { PlanAgentParamsType } from './constants';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import { getLogger, LogCategories } from '../../../../../../../common/logger';
+const agentLogger = getLogger(LogCategories.MODULE.AI.AGENT);
+
type PlanAgentConfig = {
systemPrompt?: string;
model: string;
@@ -81,24 +84,43 @@ const parsePlan = async ({
return;
}
- const result = parseJsonArgs(text);
- if (!result) {
- return;
+ const parsePlanData = async (value: unknown) => {
+ if (!value || typeof value !== 'object') {
+ return;
+ }
+
+ const params = await AgentPlanSchema.safeParseAsync({
+ ...value,
+ task,
+ description,
+ background
+ });
+
+ if (!params.success) {
+ return;
+ }
+
+ return params.data;
+ };
+
+ const directResult = parseJsonArgs(text);
+ const directPlan = await parsePlanData(directResult);
+ if (directPlan) {
+ agentLogger.debug('[Plan Agent] JSON direct parsing successful');
+ return directPlan;
}
- const params = await AgentPlanSchema.safeParseAsync({
- ...result,
- task,
- description,
- background,
- planId
- });
- if (!params.success) {
- getLogger(LogCategories.MODULE.AI.AGENT).warn(`[Plan Agent] Not plan`, { text });
+ try {
+ const repairedText = jsonrepair(sliceJsonStr(text));
+ const repairedResult = parseJsonArgs(repairedText);
+ agentLogger.debug('[Plan Agent] JSON jsonrepair parsing successful');
+ return parsePlanData(repairedResult);
+ } catch (error) {
+ agentLogger.warn('[Plan Agent] local jsonrepair failed', {
+ error: error instanceof Error ? error.message : String(error)
+ });
return;
}
-
- return params.data;
};
const parseAskInteractive = async (
toolCalls: ChatCompletionMessageToolCall[]
@@ -142,7 +164,7 @@ const parseAskInteractive = async (
}
};
} else {
- getLogger(LogCategories.MODULE.AI.AGENT).warn(`[Plan Agent] Ask tool params is not valid`, {
+ agentLogger.warn(`[Plan Agent] Ask tool params is not valid`, {
tooCall
});
return;
@@ -204,7 +226,7 @@ export const dispatchPlanAgent = async ({
content: props.queryInput
});
} else {
- getLogger(LogCategories.MODULE.AI.AGENT).error('Plan interactive mode error', {
+ agentLogger.error('Plan interactive mode error', {
planMessages: props.planMessages
});
return Promise.reject('Plan interactive mode error');
@@ -252,22 +274,121 @@ export const dispatchPlanAgent = async ({
return Promise.reject(responseEmptyTip);
}
+ const llmRequestIds: string[] = [requestId];
/*
正常输出情况:
1. text: 正常生成plan
2. toolCall: 调用ask工具
3. text + confirm: 成功生成工具 + 确认操作
*/
- // 获取生成的 plan
- const plan = await parsePlan({
- text: answerText,
- planId,
- task,
- description,
- background
- });
// 获取交互结果
- const askInteractive = await parseAskInteractive(toolCalls);
+ let askInteractive = await parseAskInteractive(toolCalls);
+ let plan: AgentPlanType | undefined;
+
+ if (!askInteractive) {
+ plan = await parsePlan({
+ text: answerText,
+ planId,
+ task,
+ description,
+ background
+ });
+ }
+
+ if (!askInteractive && !plan) {
+ agentLogger.warn('[Plan Agent] parse failed, try regenerate plan once', {
+ requestId,
+ mode: props.mode,
+ answerText: answerText.slice(0, 2000)
+ });
+
+ const regeneratePrompt = [
+ '上一轮 plan 输出不是合法 JSON,无法解析。',
+ '',
+ '请基于原始任务重新生成完整 plan,严格按 JSON 输出。',
+ '',
+ '要求:',
+ '- 仅返回 JSON',
+ '- 包含 task 和 steps 字段',
+ '- 每个 step 必须包含 id/title/description',
+ '',
+ 'JSON 格式示例(只参考格式,不要照抄内容):',
+ '{',
+ ' "task": "深入了解 Rust 编程语言(系统编程方向)",',
+ ' "steps": [',
+ ' {',
+ ' "id": "step1",',
+ ' "title": "了解 Rust 的核心特性",',
+ ' "description": "使用 @webSearch 搜索 Rust 的所有权、借用检查与并发安全机制"',
+ ' },',
+ ' {',
+ ' "id": "step2",',
+ ' "title": "调研 Rust 在系统编程的应用",',
+ ' "description": "使用 @webSearch 搜索 Rust 在操作系统、网络编程、嵌入式中的典型项目"',
+ ' }',
+ ' ]',
+ '}'
+ ].join('\n');
+
+ const regenerateResponse = await createLLMResponse({
+ isAborted: checkIsStopping,
+ body: {
+ model: modelData.model,
+ messages: [
+ ...requestMessages,
+ {
+ role: 'assistant',
+ ...(answerText && { content: answerText }),
+ ...(toolCalls.length > 0 && { tool_calls: toolCalls })
+ },
+ {
+ role: 'user',
+ content: regeneratePrompt
+ }
+ ],
+ stream: true,
+ tools: props.mode === 'continue' ? undefined : [AIAskTool],
+ tool_choice: 'auto',
+ toolCallMode: modelData.toolChoice ? 'toolChoice' : 'prompt',
+ parallel_tool_calls: false
+ }
+ });
+ if (regenerateResponse.responseEmptyTip) {
+ return Promise.reject(regenerateResponse.responseEmptyTip);
+ }
+
+ usage.inputTokens += regenerateResponse.usage.inputTokens;
+ usage.outputTokens += regenerateResponse.usage.outputTokens;
+ llmRequestIds.push(regenerateResponse.requestId);
+ completeMessages = regenerateResponse.completeMessages;
+
+ askInteractive = await parseAskInteractive(regenerateResponse.toolCalls || []);
+ if (!askInteractive) {
+ plan = await parsePlan({
+ text: regenerateResponse.answerText,
+ planId,
+ task,
+ description,
+ background
+ });
+ }
+
+ if (!askInteractive && !plan) {
+ agentLogger.warn('[Plan Agent] plan regenerate failed', {
+ requestId,
+ regenerateRequestId: regenerateResponse.requestId,
+ mode: props.mode,
+ answerText: regenerateResponse.answerText.slice(0, 2000)
+ });
+ askInteractive = {
+ type: 'agentPlanAskQuery',
+ params: {
+ content: i18nT('chat:agent_plan_parse_retry_tip')
+ }
+ };
+ completeMessages = [];
+ }
+ }
const { totalPoints, modelName } = formatModelChars2Points({
model: modelData.model,
@@ -288,7 +409,7 @@ export const dispatchPlanAgent = async ({
totalPoints,
model: modelName,
runningTime: +((Date.now() - startTime) / 1000).toFixed(2),
- llmRequestIds: [requestId]
+ llmRequestIds
};
return {
diff --git a/packages/service/package.json b/packages/service/package.json
index f57fb4e9ec3f..e97030fd58c7 100644
--- a/packages/service/package.json
+++ b/packages/service/package.json
@@ -36,6 +36,7 @@
"ioredis": "^5.6.0",
"joplin-turndown-plugin-gfm": "^1.0.12",
"json5": "catalog:",
+ "jsonrepair": "^3.0.0",
"jsonpath-plus": "^10.3.0",
"jsonwebtoken": "^9.0.2",
"lodash": "catalog:",
diff --git a/packages/web/i18n/en/chat.json b/packages/web/i18n/en/chat.json
index 8e64be395995..bf7526e88a5c 100644
--- a/packages/web/i18n/en/chat.json
+++ b/packages/web/i18n/en/chat.json
@@ -5,6 +5,7 @@
"Next": "Next",
"Previous": "Previous",
"agent_plan_continue": "Continue planning",
+ "agent_plan_parse_retry_tip": "The plan format was invalid. Add one more requirement and I will regenerate the plan.",
"ai_reasoning": "Thinking process",
"back_to_text": "Text input",
"balance_not_enough_pause": "Workflow paused due to insufficient AI points",
diff --git a/packages/web/i18n/zh-CN/chat.json b/packages/web/i18n/zh-CN/chat.json
index e2cd33d770b8..53e554b71477 100644
--- a/packages/web/i18n/zh-CN/chat.json
+++ b/packages/web/i18n/zh-CN/chat.json
@@ -5,6 +5,7 @@
"Next": "下一个",
"Previous": "上一个",
"agent_plan_continue": "继续规划",
+ "agent_plan_parse_retry_tip": "规划结果格式异常,请补充一句需求后我重新生成计划。",
"ai_reasoning": "思考过程",
"back_to_text": "返回输入",
"balance_not_enough_pause": "由于 AI 积分不足,暂停运行工作流",
diff --git a/packages/web/i18n/zh-Hant/chat.json b/packages/web/i18n/zh-Hant/chat.json
index 7569bd37f3a7..99d44d3802e0 100644
--- a/packages/web/i18n/zh-Hant/chat.json
+++ b/packages/web/i18n/zh-Hant/chat.json
@@ -5,6 +5,7 @@
"Next": "下一個",
"Previous": "上一個",
"agent_plan_continue": "繼續規劃",
+ "agent_plan_parse_retry_tip": "規劃結果格式異常,請補充一句需求後我重新生成計畫。",
"ai_reasoning": "思考過程",
"back_to_text": "返回輸入",
"balance_not_enough_pause": "由於 AI 積分不足,暫停運行工作流",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0d912751bc02..41391cec37be 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -324,6 +324,9 @@ importers:
jsonpath-plus:
specifier: ^10.3.0
version: 10.3.0
+ jsonrepair:
+ specifier: ^3.0.0
+ version: 3.13.2
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
@@ -8031,6 +8034,10 @@ packages:
resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
engines: {node: '>=0.10.0'}
+ jsonrepair@3.13.2:
+ resolution: {integrity: sha512-Leuly0nbM4R+S5SVJk3VHfw1oxnlEK9KygdZvfUtEtTawNDyzB4qa1xWTmFt1aeoA7sXZkVTRuIixJ8bAvqVUg==}
+ hasBin: true
+
jsonwebtoken@9.0.2:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
engines: {node: '>=12', npm: '>=6'}
@@ -19907,6 +19914,8 @@ snapshots:
jsonpointer@5.0.1: {}
+ jsonrepair@3.13.2: {}
+
jsonwebtoken@9.0.2:
dependencies:
jws: 3.2.2
diff --git a/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/utils.ts b/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/utils.ts
index d39ebd74a8a5..b0034e53d0cc 100644
--- a/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/utils.ts
+++ b/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/utils.ts
@@ -164,6 +164,13 @@ export function agentForm2AppWorkflow(
...Input_Template_File_Link,
value: [[workflowStartNodeId, NodeOutputKeyEnum.userFiles]]
},
+ {
+ key: NodeInputKeyEnum.aiChatVision,
+ renderTypeList: [FlowNodeInputTypeEnum.hidden],
+ label: '',
+ valueType: WorkflowIOValueTypeEnum.boolean,
+ value: true
+ },
{
key: NodeInputKeyEnum.history,
renderTypeList: [FlowNodeInputTypeEnum.numberInput, FlowNodeInputTypeEnum.reference],
From e320ea3b4aef4699df44ea3696ff0f9bcf764cac Mon Sep 17 00:00:00 2001
From: xxyyh <2289112474@qq.com>
Date: Sat, 7 Mar 2026 15:20:48 +0800
Subject: [PATCH 2/4] dataset cite and pause
---
.../core/workflow/dispatch/ai/agent/master/call.ts | 2 +-
packages/service/core/workflow/dispatch/index.ts | 7 ++++++-
.../core/chat/components/AIResponseBox.tsx | 14 +++++++++++---
3 files changed, 18 insertions(+), 5 deletions(-)
diff --git a/packages/service/core/workflow/dispatch/ai/agent/master/call.ts b/packages/service/core/workflow/dispatch/ai/agent/master/call.ts
index 8347338522a0..b66d6b062852 100644
--- a/packages/service/core/workflow/dispatch/ai/agent/master/call.ts
+++ b/packages/service/core/workflow/dispatch/ai/agent/master/call.ts
@@ -607,7 +607,7 @@ export const masterCall = async ({
response,
assistantMessages: [], // TODO
usages,
- stop
+ stop: stop || checkIsStopping()
};
},
onToolCompress: ({ call, response, usage }) => {
diff --git a/packages/service/core/workflow/dispatch/index.ts b/packages/service/core/workflow/dispatch/index.ts
index f2cde88dfa25..49f7ab7a6a93 100644
--- a/packages/service/core/workflow/dispatch/index.ts
+++ b/packages/service/core/workflow/dispatch/index.ts
@@ -211,10 +211,15 @@ export async function dispatchWorkFlow({
const checkStoppingTimer =
apiVersion === 'v2'
? setInterval(async () => {
- stopping = await shouldWorkflowStop({
+ if (stopping) return;
+
+ const shouldStop = await shouldWorkflowStop({
appId: runningAppInfo.id,
chatId
});
+ if (shouldStop) {
+ stopping = true;
+ }
}, 100)
: undefined;
diff --git a/projects/app/src/components/core/chat/components/AIResponseBox.tsx b/projects/app/src/components/core/chat/components/AIResponseBox.tsx
index 399aabb05674..224bae0f90af 100644
--- a/projects/app/src/components/core/chat/components/AIResponseBox.tsx
+++ b/projects/app/src/components/core/chat/components/AIResponseBox.tsx
@@ -60,11 +60,13 @@ const accordionButtonStyle = {
const RenderResoningContent = React.memo(function RenderResoningContent({
content,
isChatting,
- isLastResponseValue
+ isLastResponseValue,
+ isDisabled
}: {
content: string;
isChatting: boolean;
isLastResponseValue: boolean;
+ isDisabled?: boolean;
}) {
const { t } = useTranslation();
const showAnimation = isChatting && isLastResponseValue;
@@ -90,7 +92,7 @@ const RenderResoningContent = React.memo(function RenderResoningContent({
borderColor={'myGray.300'}
color={'myGray.500'}
>
-
+
@@ -100,12 +102,14 @@ const RenderText = React.memo(function RenderText({
showAnimation,
text,
chatItemDataId,
- onOpenCiteModal
+ onOpenCiteModal,
+ isDisabled
}: {
showAnimation: boolean;
text: string;
chatItemDataId: string;
onOpenCiteModal?: (e?: OnOpenCiteModalProps) => void;
+ isDisabled?: boolean;
}) {
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
@@ -131,6 +135,7 @@ const RenderText = React.memo(function RenderText({
showAnimation={showAnimation}
chatAuthData={chatAuthData}
onOpenCiteModal={onOpenCiteModal}
+ isDisabled={isDisabled}
/>
);
});
@@ -424,6 +429,7 @@ const AIResponseBox = ({
}) => {
const showRunningStatus = useContextSelector(ChatItemContext, (v) => v.showRunningStatus);
const tools = value.tool ? [value.tool] : value.tools;
+ const disableStreamingInteraction = isChatting && isLastChild;
if ('text' in value && value.text) {
return (
@@ -432,6 +438,7 @@ const AIResponseBox = ({
showAnimation={isChatting && isLastResponseValue}
text={value.text.content}
onOpenCiteModal={onOpenCiteModal}
+ isDisabled={disableStreamingInteraction}
/>
);
}
@@ -441,6 +448,7 @@ const AIResponseBox = ({
isChatting={isChatting}
isLastResponseValue={isLastResponseValue}
content={value.reasoning.content}
+ isDisabled={disableStreamingInteraction}
/>
);
}
From 37332e7f43575e54f31c711c3b8fef1d9c9282d4 Mon Sep 17 00:00:00 2001
From: archer <545436317@qq.com>
Date: Thu, 19 Mar 2026 22:23:22 +0800
Subject: [PATCH 3/4] perf: plancall second parse
---
packages/service/core/ai/llm/request.ts | 3 +-
packages/service/core/ai/utils.ts | 3 +-
.../core/workflow/dispatch/ai/agent/index.ts | 7 +-
.../workflow/dispatch/ai/agent/master/call.ts | 7 +-
.../dispatch/ai/agent/sub/plan/index.ts | 199 +++++++-----------
.../dispatch/ai/agent/sub/plan/prompt.ts | 26 +++
6 files changed, 115 insertions(+), 130 deletions(-)
diff --git a/packages/service/core/ai/llm/request.ts b/packages/service/core/ai/llm/request.ts
index 61c995196466..04dd1aa4ed70 100644
--- a/packages/service/core/ai/llm/request.ts
+++ b/packages/service/core/ai/llm/request.ts
@@ -85,11 +85,12 @@ export const createLLMResponse = async (
const { throwError = true, body, custonHeaders, userKey, maxContinuations = 1 } = args;
const { messages, useVision, requestOrigin, tools, toolCallMode } = body;
+ const model = getLLMModel(body.model);
// Messages process
const requestMessages = await loadRequestMessages({
messages,
- useVision,
+ useVision: useVision && model.vision,
origin: requestOrigin
});
// Message process
diff --git a/packages/service/core/ai/utils.ts b/packages/service/core/ai/utils.ts
index 08fcf9383f12..2024585921e5 100644
--- a/packages/service/core/ai/utils.ts
+++ b/packages/service/core/ai/utils.ts
@@ -4,6 +4,7 @@ import { getLLMDefaultUsage } from '@fastgpt/global/core/ai/constants';
import { removeDatasetCiteText } from '@fastgpt/global/core/ai/llm/utils';
import json5 from 'json5';
import { sliceJsonStr } from '@fastgpt/global/common/string/tools';
+import { jsonrepair } from 'jsonrepair';
/*
Count response max token
@@ -332,7 +333,7 @@ export const parseLLMStreamResponse = () => {
export const parseJsonArgs = >(str: string) => {
try {
- return json5.parse(sliceJsonStr(str)) as T;
+ return json5.parse(jsonrepair(sliceJsonStr(str))) as T;
} catch {
return;
}
diff --git a/packages/service/core/workflow/dispatch/ai/agent/index.ts b/packages/service/core/workflow/dispatch/ai/agent/index.ts
index 23e14d138cbc..bdc7e686f670 100644
--- a/packages/service/core/workflow/dispatch/ai/agent/index.ts
+++ b/packages/service/core/workflow/dispatch/ai/agent/index.ts
@@ -38,16 +38,15 @@ import { getContinuePlanQuery, parseUserSystemPrompt } from './sub/plan/prompt';
import type { PlanAgentParamsType } from './sub/plan/constants';
import type { AppFormEditFormType } from '@fastgpt/global/core/app/formEdit/type';
import { getLogger, LogCategories } from '../../../../../common/logger';
-import { getLLMModel } from '../../../../ai/model';
export type DispatchAgentModuleProps = ModuleDispatchProps<{
[NodeInputKeyEnum.history]?: ChatItemType[];
[NodeInputKeyEnum.userChatInput]: string;
+ [NodeInputKeyEnum.aiChatVision]?: boolean;
[NodeInputKeyEnum.fileUrlList]?: string[];
[NodeInputKeyEnum.aiModel]: string;
[NodeInputKeyEnum.aiSystemPrompt]: string;
- [NodeInputKeyEnum.aiChatVision]?: boolean;
[NodeInputKeyEnum.selectedTools]?: SkillToolType[];
@@ -98,8 +97,6 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
useAgentSandbox = false
}
} = props;
- const modelData = getLLMModel(model);
- const normalizedVision = !!(aiChatVision && modelData?.vision);
const chatHistories = getHistories(history, histories);
const aiHistoryValues = chatHistories
.filter((item) => item.obj === ChatRoleEnum.AI)
@@ -408,7 +405,6 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
systemPrompt: formatedSystemPrompt,
masterMessages: [],
planMessages: [],
- useVision: normalizedVision,
getSubAppInfo,
getSubApp,
completionTools: agentCompletionTools,
@@ -481,7 +477,6 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
...props,
masterMessages,
planMessages: planHistoryMessages || [],
- useVision: normalizedVision,
systemPrompt: formatedSystemPrompt,
getSubAppInfo,
getSubApp,
diff --git a/packages/service/core/workflow/dispatch/ai/agent/master/call.ts b/packages/service/core/workflow/dispatch/ai/agent/master/call.ts
index b66d6b062852..c5fbcb7f81cc 100644
--- a/packages/service/core/workflow/dispatch/ai/agent/master/call.ts
+++ b/packages/service/core/workflow/dispatch/ai/agent/master/call.ts
@@ -54,7 +54,6 @@ export const masterCall = async ({
systemPrompt,
masterMessages,
planMessages,
- useVision,
getSubAppInfo,
getSubApp,
completionTools,
@@ -65,7 +64,6 @@ export const masterCall = async ({
}: DispatchAgentModuleProps & {
masterMessages: ChatCompletionMessageParam[];
planMessages: ChatCompletionMessageParam[];
- useVision: boolean;
systemPrompt?: string;
getSubAppInfo: GetSubAppInfoFnType;
@@ -94,7 +92,8 @@ export const masterCall = async ({
// Dataset search configuration
agent_datasetParams: datasetParams,
// Sandbox (Computer Use)
- useAgentSandbox = false
+ useAgentSandbox = false,
+ aiChatVision
}
} = props;
@@ -217,7 +216,7 @@ export const masterCall = async ({
messages: requestMessages,
model: getLLMModel(model),
stream: true,
- useVision,
+ useVision: aiChatVision,
tools: isStepCall
? completionTools.filter((item) => item.function.name !== SubAppIds.plan)
: completionTools
diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts
index cc30c1fc7ea9..3b0c4e1ee1ea 100644
--- a/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts
+++ b/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts
@@ -4,7 +4,12 @@ import type {
ChatCompletionTool
} from '@fastgpt/global/core/ai/type';
import { createLLMResponse } from '../../../../../../ai/llm/request';
-import { getInitialPlanPrompt, getContinuePlanPrompt, getInitialPlanQuery } from './prompt';
+import {
+ getInitialPlanPrompt,
+ getContinuePlanPrompt,
+ getInitialPlanQuery,
+ reTryPlanPrompt
+} from './prompt';
import { getLLMModel } from '../../../../../../ai/model';
import { formatModelChars2Points } from '../../../../../../../support/wallet/usage/utils';
import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type';
@@ -84,43 +89,26 @@ const parsePlan = async ({
return;
}
- const parsePlanData = async (value: unknown) => {
- if (!value || typeof value !== 'object') {
- return;
- }
-
- const params = await AgentPlanSchema.safeParseAsync({
- ...value,
- task,
- description,
- background
- });
+ const result = parseJsonArgs<{ steps: AgentPlanType['steps'] }>(text);
- if (!params.success) {
- return;
- }
-
- return params.data;
- };
-
- const directResult = parseJsonArgs(text);
- const directPlan = await parsePlanData(directResult);
- if (directPlan) {
- agentLogger.debug('[Plan Agent] JSON direct parsing successful');
- return directPlan;
+ if (!result) {
+ return result;
}
- try {
- const repairedText = jsonrepair(sliceJsonStr(text));
- const repairedResult = parseJsonArgs(repairedText);
- agentLogger.debug('[Plan Agent] JSON jsonrepair parsing successful');
- return parsePlanData(repairedResult);
- } catch (error) {
- agentLogger.warn('[Plan Agent] local jsonrepair failed', {
- error: error instanceof Error ? error.message : String(error)
- });
+ const params = await AgentPlanSchema.safeParseAsync({
+ ...result,
+ planId,
+ task,
+ description,
+ background
+ });
+
+ if (!params.success) {
+ agentLogger.warn(`[Plan Agent] Not plan`, { text });
return;
}
+
+ return params.data;
};
const parseAskInteractive = async (
toolCalls: ChatCompletionMessageToolCall[]
@@ -250,6 +238,14 @@ export const dispatchPlanAgent = async ({
// console.dir({ requestMessages }, { depth: null });
// console.log('userInput:', userInput, 'mode:', mode, 'interactive?.type:', interactive?.type);
+ const requestParams = {
+ model: modelData.model,
+ stream: true,
+ tools: props.mode === 'continue' ? undefined : [AIAskTool],
+ tool_choice: 'auto' as const,
+ toolCallMode: modelData.toolChoice ? ('toolChoice' as const) : ('prompt' as const),
+ parallel_tool_calls: false
+ };
let {
answerText,
toolCalls = [],
@@ -260,13 +256,8 @@ export const dispatchPlanAgent = async ({
} = await createLLMResponse({
isAborted: checkIsStopping,
body: {
- model: modelData.model,
messages: requestMessages,
- stream: true,
- tools: props.mode === 'continue' ? undefined : [AIAskTool],
- tool_choice: 'auto',
- toolCallMode: modelData.toolChoice ? 'toolChoice' : 'prompt',
- parallel_tool_calls: false
+ ...requestParams
}
});
@@ -281,114 +272,86 @@ export const dispatchPlanAgent = async ({
2. toolCall: 调用ask工具
3. text + confirm: 成功生成工具 + 确认操作
*/
- // 获取交互结果
- let askInteractive = await parseAskInteractive(toolCalls);
- let plan: AgentPlanType | undefined;
-
- if (!askInteractive) {
- plan = await parsePlan({
- text: answerText,
- planId,
- task,
- description,
- background
- });
- }
+ // 1. 首次获取交互结果
+ const { askInteractive, plan } = await (async () => {
+ // 1. 首次获取交互结果
+ let [askInteractive, plan] = await Promise.all([
+ parseAskInteractive(toolCalls),
+ parsePlan({
+ text: answerText,
+ planId,
+ task,
+ description,
+ background
+ })
+ ]);
+ if (plan || askInteractive) {
+ return {
+ askInteractive,
+ plan
+ };
+ }
- if (!askInteractive && !plan) {
+ // 2. 二次尝试生成 plan
agentLogger.warn('[Plan Agent] parse failed, try regenerate plan once', {
requestId,
mode: props.mode,
answerText: answerText.slice(0, 2000)
});
- const regeneratePrompt = [
- '上一轮 plan 输出不是合法 JSON,无法解析。',
- '',
- '请基于原始任务重新生成完整 plan,严格按 JSON 输出。',
- '',
- '要求:',
- '- 仅返回 JSON',
- '- 包含 task 和 steps 字段',
- '- 每个 step 必须包含 id/title/description',
- '',
- 'JSON 格式示例(只参考格式,不要照抄内容):',
- '{',
- ' "task": "深入了解 Rust 编程语言(系统编程方向)",',
- ' "steps": [',
- ' {',
- ' "id": "step1",',
- ' "title": "了解 Rust 的核心特性",',
- ' "description": "使用 @webSearch 搜索 Rust 的所有权、借用检查与并发安全机制"',
- ' },',
- ' {',
- ' "id": "step2",',
- ' "title": "调研 Rust 在系统编程的应用",',
- ' "description": "使用 @webSearch 搜索 Rust 在操作系统、网络编程、嵌入式中的典型项目"',
- ' }',
- ' ]',
- '}'
- ].join('\n');
-
const regenerateResponse = await createLLMResponse({
isAborted: checkIsStopping,
body: {
- model: modelData.model,
messages: [
- ...requestMessages,
- {
- role: 'assistant',
- ...(answerText && { content: answerText }),
- ...(toolCalls.length > 0 && { tool_calls: toolCalls })
- },
+ ...completeMessages,
{
role: 'user',
- content: regeneratePrompt
+ content: reTryPlanPrompt
}
],
- stream: true,
- tools: props.mode === 'continue' ? undefined : [AIAskTool],
- tool_choice: 'auto',
- toolCallMode: modelData.toolChoice ? 'toolChoice' : 'prompt',
- parallel_tool_calls: false
+ ...requestParams
}
});
- if (regenerateResponse.responseEmptyTip) {
- return Promise.reject(regenerateResponse.responseEmptyTip);
- }
-
usage.inputTokens += regenerateResponse.usage.inputTokens;
usage.outputTokens += regenerateResponse.usage.outputTokens;
llmRequestIds.push(regenerateResponse.requestId);
completeMessages = regenerateResponse.completeMessages;
- askInteractive = await parseAskInteractive(regenerateResponse.toolCalls || []);
- if (!askInteractive) {
- plan = await parsePlan({
+ [askInteractive, plan] = await Promise.all([
+ parseAskInteractive(regenerateResponse.toolCalls || []),
+ parsePlan({
text: regenerateResponse.answerText,
planId,
task,
description,
background
- });
- }
-
- if (!askInteractive && !plan) {
- agentLogger.warn('[Plan Agent] plan regenerate failed', {
- requestId,
- regenerateRequestId: regenerateResponse.requestId,
- mode: props.mode,
- answerText: regenerateResponse.answerText.slice(0, 2000)
- });
- askInteractive = {
- type: 'agentPlanAskQuery',
- params: {
- content: i18nT('chat:agent_plan_parse_retry_tip')
- }
+ })
+ ]);
+ if (plan || askInteractive) {
+ return {
+ askInteractive,
+ plan
};
- completeMessages = [];
}
- }
+
+ // 真的失败了
+ agentLogger.warn('[Plan Agent] plan regenerate failed', {
+ requestId,
+ regenerateRequestId: regenerateResponse.requestId,
+ mode: props.mode,
+ answerText: regenerateResponse.answerText.slice(0, 2000)
+ });
+ askInteractive = {
+ type: 'agentPlanAskQuery',
+ params: {
+ content: i18nT('chat:agent_plan_parse_retry_tip')
+ }
+ };
+
+ return {
+ askInteractive
+ };
+ })();
const { totalPoints, modelName } = formatModelChars2Points({
model: modelData.model,
diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/plan/prompt.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/plan/prompt.ts
index 3e53ac983ff4..d540eebb806a 100644
--- a/packages/service/core/workflow/dispatch/ai/agent/sub/plan/prompt.ts
+++ b/packages/service/core/workflow/dispatch/ai/agent/sub/plan/prompt.ts
@@ -1125,3 +1125,29 @@ ${response}
## 下一步任务
请基于已执行步骤及结果,根据系统提示词来判断是否需要继续规划、生成总结报告步骤、还是任务已完成,或者遇到问题直接返回`;
};
+
+export const reTryPlanPrompt = `上一轮 plan 输出不是合法 JSON,无法解析。
+
+请基于原始任务重新生成完整 plan,严格按 JSON 输出。
+
+要求:
+- 仅返回 JSON
+- 包含 task 和 steps 字段
+- 每个 step 必须包含 id/title/description
+
+JSON 格式示例(只参考格式,不要照抄内容):
+{
+ "task": "深入了解 Rust 编程语言(系统编程方向)",
+ "steps": [
+ {
+ "id": "step1",
+ "title": "了解 Rust 的核心特性",
+ "description": "使用 @webSearch 搜索 Rust 的所有权、借用检查与并发安全机制"
+ },
+ {
+ "id": "step2",
+ "title": "调研 Rust 在系统编程的应用",
+ "description": "使用 @webSearch 搜索 Rust 在操作系统、网络编程、嵌入式中的典型项目"
+ }
+ ]
+}`;
From ef3346028a3034f13ebd60ee1496280328f80262 Mon Sep 17 00:00:00 2001
From: archer <545436317@qq.com>
Date: Thu, 19 Mar 2026 22:31:39 +0800
Subject: [PATCH 4/4] add test
---
.../dispatch/ai/agent/sub/plan/index.ts | 3 +-
.../core/ai/parseStreamResponse.test.ts | 450 -------------
test/cases/service/core/ai/utils.test.ts | 603 ++++++++++++++++++
3 files changed, 604 insertions(+), 452 deletions(-)
delete mode 100644 test/cases/service/core/ai/parseStreamResponse.test.ts
create mode 100644 test/cases/service/core/ai/utils.test.ts
diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts
index 3b0c4e1ee1ea..4d91429d3699 100644
--- a/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts
+++ b/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts
@@ -21,8 +21,7 @@ import { parseJsonArgs } from '../../../../../../ai/utils';
import { AIAskAnswerSchema, AIAskTool } from './ask/constants';
import { AgentPlanSchema, type AgentPlanType } from '@fastgpt/global/core/ai/agent/type';
import type { GetSubAppInfoFnType } from '../../type';
-import { getNanoid, sliceJsonStr } from '@fastgpt/global/common/string/tools';
-import { jsonrepair } from 'jsonrepair';
+import { getNanoid } from '@fastgpt/global/common/string/tools';
import {
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
diff --git a/test/cases/service/core/ai/parseStreamResponse.test.ts b/test/cases/service/core/ai/parseStreamResponse.test.ts
deleted file mode 100644
index 407450d03dae..000000000000
--- a/test/cases/service/core/ai/parseStreamResponse.test.ts
+++ /dev/null
@@ -1,450 +0,0 @@
-import type { CompletionFinishReason } from '@fastgpt/global/core/ai/type';
-import { parseLLMStreamResponse } from '@fastgpt/service/core/ai/utils';
-import { describe, expect, it } from 'vitest';
-
-describe('Parse reasoning stream content test', async () => {
- const partList = [
- {
- data: [{ content: '你好1' }, { content: '你好2' }, { content: '你好3' }],
- correct: { answer: '你好1你好2你好3', reasoning: '' }
- },
- {
- data: [
- { reasoning_content: '这是' },
- { reasoning_content: '思考' },
- { reasoning_content: '过程' },
- { content: '你好1' },
- { content: '你好2' },
- { content: '你好3' }
- ],
- correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' }
- },
- {
- data: [
- { content: '' },
- { content: '这是' },
- { content: '思考' },
- { content: '过程' },
- { content: '' },
- { content: '你好1' },
- { content: '你好2' },
- { content: '你好3' }
- ],
- correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' }
- },
- {
- data: [
- { content: '' },
- { content: '这是' },
- { content: '思考' },
- { content: '过程' },
- { content: '' },
- { content: '你好1' },
- { content: '你好2' },
- { content: '你好3' }
- ],
- correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' }
- },
- {
- data: [
- { content: '这是' },
- { content: '思考' },
- { content: '过程' },
- { content: '' },
- { content: '你好1' },
- { content: '你好2' },
- { content: '你好3' }
- ],
- correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' }
- },
- {
- data: [
- { content: '这是' },
- { content: '思考' },
- { content: '过程' },
- { content: 'think>' },
- { content: '你好1' },
- { content: '你好2' },
- { content: '你好3' }
- ],
- correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' }
- },
- {
- data: [
- { content: '这是' },
- { content: '思考' },
- { content: '过程' },
- { content: '你好1' },
- { content: '你好2' },
- { content: '你好3' }
- ],
- correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' }
- },
- {
- data: [
- { content: '这是' },
- { content: '思考' },
- { content: '过程你好1' },
- { content: '你好2' },
- { content: '你好3' }
- ],
- correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' }
- },
- {
- data: [
- { content: '这是' },
- { content: '思考' },
- { content: '过程你好1' },
- { content: '你好2' },
- { content: '你好3' }
- ],
- correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程这是' },
- { content: '思考' },
- { content: '过程 {
- it(`Reasoning test:${index}`, () => {
- const { parsePart } = parseLLMStreamResponse();
-
- let answer = '';
- let reasoning = '';
- part.data.forEach((item) => {
- const formatPart = {
- choices: [
- {
- delta: {
- role: 'assistant',
- content: item.content,
- reasoning_content: item.reasoning_content
- }
- }
- ]
- };
- const { reasoningContent, content } = parsePart({
- part: formatPart,
- parseThinkTag: true,
- retainDatasetCite: false
- });
- answer += content;
- reasoning += reasoningContent;
- });
- expect(answer).toBe(part.correct.answer);
- expect(reasoning).toBe(part.correct.reasoning);
- });
- });
-});
-
-describe('Parse dataset cite content test', async () => {
- const partList = [
- {
- // 完整的
- data: [
- { content: '知识库' },
- { content: '问答系统' },
- { content: '[67e517e74767063e882d6861](CITE)' }
- ],
- correct: {
- content: '知识库问答系统[67e517e74767063e882d6861](CITE)',
- responseContent: '知识库问答系统'
- }
- },
- {
- // 只要 objectId
- data: [
- { content: '知识库' },
- { content: '问答系统' },
- { content: '[67e517e747' },
- { content: '67063e882d' },
- { content: '6861]' }
- ],
- correct: {
- content: '知识库问答系统[67e517e74767063e882d6861]',
- responseContent: '知识库问答系统'
- }
- },
- {
- // 满足替换条件的
- data: [
- { content: '知识库' },
- { content: '问答系统' },
- { content: '[67e517e747' },
- { content: '67063e882d' },
- { content: '6861](' }
- ],
- correct: {
- content: '知识库问答系统[67e517e74767063e882d6861](',
- responseContent: '知识库问答系统'
- }
- },
- {
- // 满足替换条件的
- data: [
- { content: '知识库' },
- { content: '问答系统' },
- { content: '[67e517e747' },
- { content: '67063e882d' },
- { content: '6861](C' }
- ],
- correct: {
- content: '知识库问答系统[67e517e74767063e882d6861](C',
- responseContent: '知识库问答系统'
- }
- },
- {
- // 满足替换条件的
- data: [
- { content: '知识库' },
- { content: '问答系统' },
- { content: '[67e517e747' },
- { content: '67063e882d' },
- { content: '6861](CI' }
- ],
- correct: {
- content: '知识库问答系统[67e517e74767063e882d6861](CI',
- responseContent: '知识库问答系统'
- }
- },
- {
- // 满足替换条件的
- data: [
- { content: '知识库' },
- { content: '问答系统' },
- { content: '[67e517e747' },
- { content: '67063e882d' },
- { content: '6861](CIT' }
- ],
- correct: {
- content: '知识库问答系统[67e517e74767063e882d6861](CIT',
- responseContent: '知识库问答系统'
- }
- },
- {
- // 满足替换条件的
- data: [
- { content: '知识库' },
- { content: '问答系统' },
- { content: '[67e517e747' },
- { content: '67063e882d' },
- { content: '6861](CITE' }
- ],
- correct: {
- content: '知识库问答系统[67e517e74767063e882d6861](CITE',
- responseContent: '知识库问答系统'
- }
- },
- {
- // 缺失结尾
- data: [
- { content: '知识库问答系统' },
- { content: '[67e517e747' },
- { content: '67063e882d' },
- { content: '6861](CITE' }
- ],
- correct: {
- content: '知识库问答系统[67e517e74767063e882d6861](CITE',
- responseContent: '知识库问答系统'
- }
- },
- {
- // ObjectId 不正确
- data: [
- { content: '知识库问答系统' },
- { content: '[67e517e747' },
- { content: '67882d' },
- { content: '6861](CITE)' }
- ],
- correct: {
- content: '知识库问答系统[67e517e74767882d6861](CITE)',
- responseContent: '知识库问答系统[67e517e74767882d6861](CITE)'
- }
- },
- {
- // 其他链接
- data: [{ content: '知识库' }, { content: '问答系统' }, { content: '[](https://fastgpt.cn)' }],
- correct: {
- content: '知识库问答系统[](https://fastgpt.cn)',
- responseContent: '知识库问答系统[](https://fastgpt.cn)'
- }
- },
- {
- // 不完整的其他链接
- data: [{ content: '知识库' }, { content: '问答系统' }, { content: '[](https://fastgp' }],
- correct: {
- content: '知识库问答系统[](https://fastgp',
- responseContent: '知识库问答系统[](https://fastgp'
- }
- },
- {
- // 开头
- data: [{ content: '[知识库' }, { content: '问答系统' }, { content: '[](https://fastgp' }],
- correct: {
- content: '[知识库问答系统[](https://fastgp',
- responseContent: '[知识库问答系统[](https://fastgp'
- }
- },
- {
- // 结尾
- data: [{ content: '知识库' }, { content: '问答系统' }, { content: '[' }],
- correct: {
- content: '知识库问答系统[',
- responseContent: '知识库问答系统['
- }
- },
- {
- // 中间
- data: [
- { content: '知识库' },
- { content: '问答系统' },
- { content: '[' },
- { content: '问答系统]' }
- ],
- correct: {
- content: '知识库问答系统[问答系统]',
- responseContent: '知识库问答系统[问答系统]'
- }
- },
- {
- // 双链接
- data: [
- { content: '知识库' },
- { content: '问答系统' },
- { content: '[](https://fastgpt.cn)' },
- { content: '[67e517e747' },
- { content: '67063e882d' },
- { content: '6861](CITE)' }
- ],
- correct: {
- content: '知识库问答系统[](https://fastgpt.cn)[67e517e74767063e882d6861](CITE)',
- responseContent: '知识库问答系统[](https://fastgpt.cn)'
- }
- },
- {
- // 双链接缺失部分
- data: [
- { content: '知识库' },
- { content: '问答系统' },
- { content: '[](https://fastgpt.cn)' },
- { content: '[67e517e747' },
- { content: '67063e882d' },
- { content: '6861](CIT' }
- ],
- correct: {
- content: '知识库问答系统[](https://fastgpt.cn)[67e517e74767063e882d6861](CIT',
- responseContent: '知识库问答系统[](https://fastgpt.cn)'
- }
- },
- {
- // 双Cite
- data: [
- { content: '知识库' },
- { content: '问答系统' },
- { content: '[67e517e747' },
- { content: '67063e882d' },
- { content: '6861](CITE)' },
- { content: '[67e517e747' },
- { content: '67063e882d' },
- { content: '6861](CITE)' }
- ],
- correct: {
- content: '知识库问答系统[67e517e74767063e882d6861](CITE)[67e517e74767063e882d6861](CITE)',
- responseContent: '知识库问答系统'
- }
- },
- {
- // 双Cite-第一个假Cite
- data: [
- { content: '知识库' },
- { content: '问答系统' },
- { content: '[67e517e747' },
- { content: '6861](CITE)' },
- { content: '[67e517e747' },
- { content: '67063e882d' },
- { content: '6861](CITE)' }
- ],
- correct: {
- content: '知识库问答系统[67e517e7476861](CITE)[67e517e74767063e882d6861](CITE)',
- responseContent: '知识库问答系统[67e517e7476861](CITE)'
- }
- },
- {
- // [id](CITE)
- data: [
- { content: '知识库' },
- { content: '问答系统' },
- { content: '[i' },
- { content: 'd](CITE)' },
- { content: '[67e517e747' },
- { content: '67063e882d' },
- { content: '6861](CITE)' }
- ],
- correct: {
- content: '知识库问答系统[id](CITE)[67e517e74767063e882d6861](CITE)',
- responseContent: '知识库问答系统'
- }
- },
- {
- // [id](CITE)
- data: [
- { content: '知识库' },
- { content: '问答系统' },
- { content: '[i' },
- { content: 'd](CITE)' }
- ],
- correct: {
- content: '知识库问答系统[id](CITE)',
- responseContent: '知识库问答系统'
- }
- }
- ];
-
- partList.forEach((part, index) => {
- it(`Dataset cite test: ${index}`, () => {
- const { parsePart } = parseLLMStreamResponse();
-
- let answer = '';
- let responseContent = '';
- const list = [...part.data, { content: '' }];
- list.forEach((item, index) => {
- const formatPart = {
- choices: [
- {
- delta: {
- role: 'assistant',
- content: item.content,
- reasoning_content: ''
- },
- finish_reason: (index === list.length - 2 ? 'stop' : null) as CompletionFinishReason
- }
- ]
- };
- const { content, responseContent: newResponseContent } = parsePart({
- part: formatPart,
- parseThinkTag: false,
- retainDatasetCite: false
- });
- answer += content;
- responseContent += newResponseContent;
- });
-
- expect(answer).toEqual(part.correct.content);
- expect(responseContent).toEqual(part.correct.responseContent);
- });
- });
-});
diff --git a/test/cases/service/core/ai/utils.test.ts b/test/cases/service/core/ai/utils.test.ts
new file mode 100644
index 000000000000..4f29265637b7
--- /dev/null
+++ b/test/cases/service/core/ai/utils.test.ts
@@ -0,0 +1,603 @@
+import { describe, expect, it } from 'vitest';
+import {
+ parseJsonArgs,
+ parseLLMStreamResponse,
+ computedMaxToken,
+ computedTemperature,
+ parseReasoningContent
+} from '@fastgpt/service/core/ai/utils';
+import type { CompletionFinishReason } from '@fastgpt/global/core/ai/type';
+import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema';
+
+const mockModel = (maxResponse: number, maxTemperature?: number) =>
+ ({ maxResponse, maxTemperature }) as LLMModelItemType;
+
+describe('computedMaxToken', () => {
+ it('should return undefined when maxToken is undefined', () => {
+ expect(computedMaxToken({ maxToken: undefined, model: mockModel(4096) })).toBeUndefined();
+ });
+
+ it('should cap maxToken to model.maxResponse', () => {
+ expect(computedMaxToken({ maxToken: 8000, model: mockModel(4096) })).toBe(4096);
+ });
+
+ it('should return maxToken when within model.maxResponse', () => {
+ expect(computedMaxToken({ maxToken: 1000, model: mockModel(4096) })).toBe(1000);
+ });
+
+ it('should enforce minimum of 1 by default', () => {
+ expect(computedMaxToken({ maxToken: 0, model: mockModel(4096) })).toBe(1);
+ });
+
+ it('should enforce custom min value', () => {
+ expect(computedMaxToken({ maxToken: 5, model: mockModel(4096), min: 10 })).toBe(10);
+ });
+
+ it('should use maxToken when it exceeds min', () => {
+ expect(computedMaxToken({ maxToken: 100, model: mockModel(4096), min: 10 })).toBe(100);
+ });
+});
+
+describe('computedTemperature', () => {
+ it('should return undefined when model has no maxTemperature', () => {
+ expect(computedTemperature({ model: mockModel(4096), temperature: 5 })).toBeUndefined();
+ });
+
+ it('should scale temperature proportionally', () => {
+ // maxTemperature=2, temperature=5 => 2*(5/10)=1.0
+ expect(computedTemperature({ model: mockModel(4096, 2), temperature: 5 })).toBe(1.0);
+ });
+
+ it('should return maxTemperature when temperature=10', () => {
+ expect(computedTemperature({ model: mockModel(4096, 2), temperature: 10 })).toBe(2.0);
+ });
+
+ it('should enforce minimum of 0.01', () => {
+ expect(computedTemperature({ model: mockModel(4096, 2), temperature: 0 })).toBe(0.01);
+ });
+
+ it('should round to 2 decimal places', () => {
+ // maxTemperature=1, temperature=3 => 1*(3/10)=0.30
+ expect(computedTemperature({ model: mockModel(4096, 1), temperature: 3 })).toBe(0.3);
+ });
+});
+
+describe('parseReasoningContent', () => {
+ it('should return empty reasoning and full text when no think tag', () => {
+ expect(parseReasoningContent('hello world')).toEqual(['', 'hello world']);
+ });
+
+ it('should extract think content and remaining answer', () => {
+ expect(parseReasoningContent('reasoninganswer')).toEqual([
+ 'reasoning',
+ 'answer'
+ ]);
+ });
+
+ it('should trim whitespace from think content', () => {
+ expect(parseReasoningContent(' reasoning answer')).toEqual([
+ 'reasoning',
+ 'answer'
+ ]);
+ });
+
+ it('should return empty answer when nothing after think tag', () => {
+ expect(parseReasoningContent('reasoning')).toEqual(['reasoning', '']);
+ });
+
+ it('should handle multiline think content', () => {
+ expect(parseReasoningContent('line1\nline2answer')).toEqual([
+ 'line1\nline2',
+ 'answer'
+ ]);
+ });
+
+ it('should only match first think tag', () => {
+ expect(parseReasoningContent('firstmidsecondend')).toEqual([
+ 'first',
+ 'midsecondend'
+ ]);
+ });
+});
+
+describe('parseLLMStreamResponse', () => {
+ describe('Parse reasoning stream content test', async () => {
+ const partList = [
+ {
+ data: [{ content: '你好1' }, { content: '你好2' }, { content: '你好3' }],
+ correct: { answer: '你好1你好2你好3', reasoning: '' }
+ },
+ {
+ data: [
+ { reasoning_content: '这是' },
+ { reasoning_content: '思考' },
+ { reasoning_content: '过程' },
+ { content: '你好1' },
+ { content: '你好2' },
+ { content: '你好3' }
+ ],
+ correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' }
+ },
+ {
+ data: [
+ { content: '' },
+ { content: '这是' },
+ { content: '思考' },
+ { content: '过程' },
+ { content: '' },
+ { content: '你好1' },
+ { content: '你好2' },
+ { content: '你好3' }
+ ],
+ correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' }
+ },
+ {
+ data: [
+ { content: '' },
+ { content: '这是' },
+ { content: '思考' },
+ { content: '过程' },
+ { content: '' },
+ { content: '你好1' },
+ { content: '你好2' },
+ { content: '你好3' }
+ ],
+ correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' }
+ },
+ {
+ data: [
+ { content: '这是' },
+ { content: '思考' },
+ { content: '过程' },
+ { content: '' },
+ { content: '你好1' },
+ { content: '你好2' },
+ { content: '你好3' }
+ ],
+ correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' }
+ },
+ {
+ data: [
+ { content: '这是' },
+ { content: '思考' },
+ { content: '过程' },
+ { content: 'think>' },
+ { content: '你好1' },
+ { content: '你好2' },
+ { content: '你好3' }
+ ],
+ correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' }
+ },
+ {
+ data: [
+ { content: '这是' },
+ { content: '思考' },
+ { content: '过程' },
+ { content: '你好1' },
+ { content: '你好2' },
+ { content: '你好3' }
+ ],
+ correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' }
+ },
+ {
+ data: [
+ { content: '这是' },
+ { content: '思考' },
+ { content: '过程你好1' },
+ { content: '你好2' },
+ { content: '你好3' }
+ ],
+ correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程' }
+ },
+ {
+ data: [
+ { content: '这是' },
+ { content: '思考' },
+ { content: '过程你好1' },
+ { content: '你好2' },
+ { content: '你好3' }
+ ],
+ correct: { answer: '你好1你好2你好3', reasoning: '这是思考过程这是' },
+ { content: '思考' },
+ { content: '过程 {
+ it(`Reasoning test:${index}`, () => {
+ const { parsePart } = parseLLMStreamResponse();
+
+ let answer = '';
+ let reasoning = '';
+ part.data.forEach((item) => {
+ const formatPart = {
+ choices: [
+ {
+ delta: {
+ role: 'assistant',
+ content: item.content,
+ reasoning_content: item.reasoning_content
+ }
+ }
+ ]
+ };
+ const { reasoningContent, content } = parsePart({
+ part: formatPart,
+ parseThinkTag: true,
+ retainDatasetCite: false
+ });
+ answer += content;
+ reasoning += reasoningContent;
+ });
+ expect(answer).toBe(part.correct.answer);
+ expect(reasoning).toBe(part.correct.reasoning);
+ });
+ });
+ });
+
+ describe('Parse dataset cite content test', async () => {
+ const partList = [
+ {
+ // 完整的
+ data: [
+ { content: '知识库' },
+ { content: '问答系统' },
+ { content: '[67e517e74767063e882d6861](CITE)' }
+ ],
+ correct: {
+ content: '知识库问答系统[67e517e74767063e882d6861](CITE)',
+ responseContent: '知识库问答系统'
+ }
+ },
+ {
+ // 只要 objectId
+ data: [
+ { content: '知识库' },
+ { content: '问答系统' },
+ { content: '[67e517e747' },
+ { content: '67063e882d' },
+ { content: '6861]' }
+ ],
+ correct: {
+ content: '知识库问答系统[67e517e74767063e882d6861]',
+ responseContent: '知识库问答系统'
+ }
+ },
+ {
+ // 满足替换条件的
+ data: [
+ { content: '知识库' },
+ { content: '问答系统' },
+ { content: '[67e517e747' },
+ { content: '67063e882d' },
+ { content: '6861](' }
+ ],
+ correct: {
+ content: '知识库问答系统[67e517e74767063e882d6861](',
+ responseContent: '知识库问答系统'
+ }
+ },
+ {
+ // 满足替换条件的
+ data: [
+ { content: '知识库' },
+ { content: '问答系统' },
+ { content: '[67e517e747' },
+ { content: '67063e882d' },
+ { content: '6861](C' }
+ ],
+ correct: {
+ content: '知识库问答系统[67e517e74767063e882d6861](C',
+ responseContent: '知识库问答系统'
+ }
+ },
+ {
+ // 满足替换条件的
+ data: [
+ { content: '知识库' },
+ { content: '问答系统' },
+ { content: '[67e517e747' },
+ { content: '67063e882d' },
+ { content: '6861](CI' }
+ ],
+ correct: {
+ content: '知识库问答系统[67e517e74767063e882d6861](CI',
+ responseContent: '知识库问答系统'
+ }
+ },
+ {
+ // 满足替换条件的
+ data: [
+ { content: '知识库' },
+ { content: '问答系统' },
+ { content: '[67e517e747' },
+ { content: '67063e882d' },
+ { content: '6861](CIT' }
+ ],
+ correct: {
+ content: '知识库问答系统[67e517e74767063e882d6861](CIT',
+ responseContent: '知识库问答系统'
+ }
+ },
+ {
+ // 满足替换条件的
+ data: [
+ { content: '知识库' },
+ { content: '问答系统' },
+ { content: '[67e517e747' },
+ { content: '67063e882d' },
+ { content: '6861](CITE' }
+ ],
+ correct: {
+ content: '知识库问答系统[67e517e74767063e882d6861](CITE',
+ responseContent: '知识库问答系统'
+ }
+ },
+ {
+ // 缺失结尾
+ data: [
+ { content: '知识库问答系统' },
+ { content: '[67e517e747' },
+ { content: '67063e882d' },
+ { content: '6861](CITE' }
+ ],
+ correct: {
+ content: '知识库问答系统[67e517e74767063e882d6861](CITE',
+ responseContent: '知识库问答系统'
+ }
+ },
+ {
+ // ObjectId 不正确
+ data: [
+ { content: '知识库问答系统' },
+ { content: '[67e517e747' },
+ { content: '67882d' },
+ { content: '6861](CITE)' }
+ ],
+ correct: {
+ content: '知识库问答系统[67e517e74767882d6861](CITE)',
+ responseContent: '知识库问答系统[67e517e74767882d6861](CITE)'
+ }
+ },
+ {
+ // 其他链接
+ data: [
+ { content: '知识库' },
+ { content: '问答系统' },
+ { content: '[](https://fastgpt.cn)' }
+ ],
+ correct: {
+ content: '知识库问答系统[](https://fastgpt.cn)',
+ responseContent: '知识库问答系统[](https://fastgpt.cn)'
+ }
+ },
+ {
+ // 不完整的其他链接
+ data: [{ content: '知识库' }, { content: '问答系统' }, { content: '[](https://fastgp' }],
+ correct: {
+ content: '知识库问答系统[](https://fastgp',
+ responseContent: '知识库问答系统[](https://fastgp'
+ }
+ },
+ {
+ // 开头
+ data: [{ content: '[知识库' }, { content: '问答系统' }, { content: '[](https://fastgp' }],
+ correct: {
+ content: '[知识库问答系统[](https://fastgp',
+ responseContent: '[知识库问答系统[](https://fastgp'
+ }
+ },
+ {
+ // 结尾
+ data: [{ content: '知识库' }, { content: '问答系统' }, { content: '[' }],
+ correct: {
+ content: '知识库问答系统[',
+ responseContent: '知识库问答系统['
+ }
+ },
+ {
+ // 中间
+ data: [
+ { content: '知识库' },
+ { content: '问答系统' },
+ { content: '[' },
+ { content: '问答系统]' }
+ ],
+ correct: {
+ content: '知识库问答系统[问答系统]',
+ responseContent: '知识库问答系统[问答系统]'
+ }
+ },
+ {
+ // 双链接
+ data: [
+ { content: '知识库' },
+ { content: '问答系统' },
+ { content: '[](https://fastgpt.cn)' },
+ { content: '[67e517e747' },
+ { content: '67063e882d' },
+ { content: '6861](CITE)' }
+ ],
+ correct: {
+ content: '知识库问答系统[](https://fastgpt.cn)[67e517e74767063e882d6861](CITE)',
+ responseContent: '知识库问答系统[](https://fastgpt.cn)'
+ }
+ },
+ {
+ // 双链接缺失部分
+ data: [
+ { content: '知识库' },
+ { content: '问答系统' },
+ { content: '[](https://fastgpt.cn)' },
+ { content: '[67e517e747' },
+ { content: '67063e882d' },
+ { content: '6861](CIT' }
+ ],
+ correct: {
+ content: '知识库问答系统[](https://fastgpt.cn)[67e517e74767063e882d6861](CIT',
+ responseContent: '知识库问答系统[](https://fastgpt.cn)'
+ }
+ },
+ {
+ // 双Cite
+ data: [
+ { content: '知识库' },
+ { content: '问答系统' },
+ { content: '[67e517e747' },
+ { content: '67063e882d' },
+ { content: '6861](CITE)' },
+ { content: '[67e517e747' },
+ { content: '67063e882d' },
+ { content: '6861](CITE)' }
+ ],
+ correct: {
+ content: '知识库问答系统[67e517e74767063e882d6861](CITE)[67e517e74767063e882d6861](CITE)',
+ responseContent: '知识库问答系统'
+ }
+ },
+ {
+ // 双Cite-第一个假Cite
+ data: [
+ { content: '知识库' },
+ { content: '问答系统' },
+ { content: '[67e517e747' },
+ { content: '6861](CITE)' },
+ { content: '[67e517e747' },
+ { content: '67063e882d' },
+ { content: '6861](CITE)' }
+ ],
+ correct: {
+ content: '知识库问答系统[67e517e7476861](CITE)[67e517e74767063e882d6861](CITE)',
+ responseContent: '知识库问答系统[67e517e7476861](CITE)'
+ }
+ },
+ {
+ // [id](CITE)
+ data: [
+ { content: '知识库' },
+ { content: '问答系统' },
+ { content: '[i' },
+ { content: 'd](CITE)' },
+ { content: '[67e517e747' },
+ { content: '67063e882d' },
+ { content: '6861](CITE)' }
+ ],
+ correct: {
+ content: '知识库问答系统[id](CITE)[67e517e74767063e882d6861](CITE)',
+ responseContent: '知识库问答系统'
+ }
+ },
+ {
+ // [id](CITE)
+ data: [
+ { content: '知识库' },
+ { content: '问答系统' },
+ { content: '[i' },
+ { content: 'd](CITE)' }
+ ],
+ correct: {
+ content: '知识库问答系统[id](CITE)',
+ responseContent: '知识库问答系统'
+ }
+ }
+ ];
+
+ partList.forEach((part, index) => {
+ it(`Dataset cite test: ${index}`, () => {
+ const { parsePart } = parseLLMStreamResponse();
+
+ let answer = '';
+ let responseContent = '';
+ const list = [...part.data, { content: '' }];
+ list.forEach((item, index) => {
+ const formatPart = {
+ choices: [
+ {
+ delta: {
+ role: 'assistant',
+ content: item.content,
+ reasoning_content: ''
+ },
+ finish_reason: (index === list.length - 2 ? 'stop' : null) as CompletionFinishReason
+ }
+ ]
+ };
+ const { content, responseContent: newResponseContent } = parsePart({
+ part: formatPart,
+ parseThinkTag: false,
+ retainDatasetCite: false
+ });
+ answer += content;
+ responseContent += newResponseContent;
+ });
+
+ expect(answer).toEqual(part.correct.content);
+ expect(responseContent).toEqual(part.correct.responseContent);
+ });
+ });
+ });
+});
+
+describe('parseJsonArgs', () => {
+ it('should parse valid JSON string', () => {
+ const result = parseJsonArgs<{ a: number }>('{"a": 1}');
+ expect(result).toEqual({ a: 1 });
+ });
+
+ it('should parse JSON5 (unquoted keys)', () => {
+ const result = parseJsonArgs<{ a: number }>('{a: 1}');
+ expect(result).toEqual({ a: 1 });
+ });
+
+ it('should parse JSON with trailing commas', () => {
+ const result = parseJsonArgs<{ a: number; b: string }>('{a: 1, b: "hello",}');
+ expect(result).toEqual({ a: 1, b: 'hello' });
+ });
+
+ it('should repair and parse broken JSON (missing closing brace)', () => {
+ const result = parseJsonArgs<{ a: number }>('{a: 1');
+ expect(result).toEqual({ a: 1 });
+ });
+
+ it('should extract JSON from surrounding text', () => {
+ const result = parseJsonArgs<{ key: string }>('prefix {"key": "value"} suffix');
+ expect(result).toEqual({ key: 'value' });
+ });
+
+ it('should parse array JSON', () => {
+ const result = parseJsonArgs('[1, 2, 3]');
+ expect(result).toEqual([1, 2, 3]);
+ });
+
+ it('should return undefined for completely invalid input', () => {
+ // jsonrepair returns the string as-is, json5 parses it as a string — not an object
+ // Only truly unparseable input (e.g. unmatched braces with garbage) returns undefined
+ const result = parseJsonArgs('{{{invalid');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for empty string', () => {
+ const result = parseJsonArgs('');
+ expect(result).toBeUndefined();
+ });
+
+ it('should parse nested objects', () => {
+ const result = parseJsonArgs<{ a: { b: number } }>('{"a": {"b": 2}}');
+ expect(result).toEqual({ a: { b: 2 } });
+ });
+});