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: '你好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: '你好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 } }); + }); +});