Commit da3d14bc by Archer Committed by GitHub

Fix chat (#7131)

* fix: interactive

* fix: test
parent a6fc27f6
# 判断器回连交互节点第二次不渲染问题分析
## 背景
用户提供的工作流 `/Users/yjl/Downloads/子循环 (1).json` 通过判断器分支回连,再次进入同一个 `formInput` 节点。第一次表单可以渲染,提交后回连再次触发表单时,第二个表单不渲染。
## 根因
前端恢复流去重逻辑曾用 `entryNodeIds` 判断两个 interactive 是否为同一轮交互:
- 普通流程中,同一个 `entryNodeIds` 通常可以近似表示同一个暂停点。
- 判断器回连、循环、递归路径中,同一个节点会多次触发,`entryNodeIds` 只能说明“哪个节点”,不能说明“第几次触发”。
因此,当第一轮表单已经 `submitted=true` 后,第二轮同节点表单由于 `entryNodeIds` 相同,可能被误判为上一轮的 stale replay,并被跳过追加,导致前端没有进入待交互渲染态。
该问题与 2026-05-15 的 `fix: skip stale resume interactive after submitted form` 类修复有关。该修复用于防止恢复流 replay 的空表单覆盖已提交表单值,但同节点回连场景暴露了 `entryNodeIds` 作为交互身份不够精确的问题。
## 修复方案
新增 `interactiveId` 作为每次交互暂停的唯一触发轮次 ID:
1. 服务端每次生成 workflow interactive 时写入新的 `interactiveId`
2. 前端判断同一交互时,若任一侧存在 `interactiveId`,优先按 `interactiveId` 判断。
3. 只有双方都没有 `interactiveId` 的旧数据,才回退到原来的 `usageId/entryNodeIds` 兼容逻辑。
这样同一个 `formInput` 节点多次触发时会有不同的 `interactiveId`,第二轮表单可以正常 append 和渲染;恢复流中同一轮 stale replay 仍会被拦截。
import { i18nT } from '../../i18n/utils';
import type { ErrType } from '../errorCode';
/* sandbox: 510000 */
const startCode = 510000;
export enum SandboxErrEnum {
agentSandboxPermissionDenied = 'agentSandboxPermissionDenied'
}
const sandboxErr = [
{
statusText: SandboxErrEnum.agentSandboxPermissionDenied,
message: i18nT('common:code_error.sandbox_error.agent_sandbox_permission_denied')
}
];
export default sandboxErr.reduce((acc, cur, index) => {
return {
...acc,
[cur.statusText]: {
code: startCode + index,
statusText: cur.statusText,
message: cur.message,
data: null,
httpStatus: 403
}
};
}, {} as ErrType<`${SandboxErrEnum}`>);
......@@ -10,6 +10,7 @@ import commonErr from './code/common';
import s3Err from './code/s3';
import SystemErrEnum from './code/system';
import agentSkillErr from './code/skill';
import sandboxErr from './code/sandbox';
import { i18nT } from '../i18n/utils';
export const ERROR_CODE: { [key: number]: string } = {
......@@ -122,5 +123,6 @@ export const ERROR_RESPONSE: Record<
...commonErr,
...s3Err,
...SystemErrEnum,
...agentSkillErr
...agentSkillErr,
...sandboxErr
};
......@@ -8,6 +8,7 @@ import { ChatCompletionMessageParamSchema } from '../../../../ai/llm/type';
export const InteractiveBasicTypeSchema = z.object({
entryNodeIds: z.array(z.string()),
interactiveId: z.string().optional(),
nodeResponseId: z.string().optional(),
memoryEdges: z.array(RuntimeEdgeItemTypeSchema),
nodeOutputs: z.array(NodeOutputItemSchema),
......@@ -20,6 +21,7 @@ export type InteractiveBasicType = z.infer<typeof InteractiveBasicTypeSchema>;
const InteractiveNodeTypeSchema = z.object({
entryNodeIds: z.array(z.string()).optional(),
interactiveId: z.string().optional(),
nodeResponseId: z.string().optional(),
memoryEdges: z.array(RuntimeEdgeItemTypeSchema).optional(),
nodeOutputs: z.array(NodeOutputItemSchema).optional()
......
import { SandboxErrEnum } from '@fastgpt/global/common/error/code/sandbox';
import { UserError } from '@fastgpt/global/common/error/utils';
/**
* 生成 Agent 虚拟机权限错误。
*
* 该错误表示团队套餐或管理员配置不允许当前应用使用虚拟机,属于业务权限拒绝,
* 需要和 provider 内部文件权限、命令权限等通用 PERMISSION_DENIED 区分。
*/
export const createAgentSandboxPermissionDeniedError = () =>
new UserError(SandboxErrEnum.agentSandboxPermissionDenied);
......@@ -35,6 +35,7 @@ import { getLogger, LogCategories } from '../../../../common/logger';
import { serviceEnv } from '../../../../env';
import type { SandboxStatusItemType } from '@fastgpt/global/core/chat/type';
import { checkTeamSandboxPermission } from '../../../../support/permission/teamLimit';
import { createAgentSandboxPermissionDeniedError } from '../../sandbox/error';
const addLog = getLogger(LogCategories.MODULE.AI.AGENT);
......@@ -70,7 +71,7 @@ export async function createEditDebugSandbox(
try {
await checkTeamSandboxPermission(teamId);
} catch {
throw new Error('当前应用未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。');
throw createAgentSandboxPermissionDeniedError();
}
const providerConfig = getSandboxProviderConfig();
......
......@@ -10,6 +10,7 @@ import { getSandboxRuntimeProfile } from '../../../../../../ai/sandbox/runtime/p
import { getSandboxClient, type SandboxClient } from '../../../../../../ai/sandbox/service/runtime';
import { pickOutboundAxios } from '../../../../../../../common/api/axios';
import { checkTeamSandboxPermission } from '../../../../../../../support/permission/teamLimit';
import { createAgentSandboxPermissionDeniedError } from '../../../../../../ai/sandbox/error';
type UseSandboxParams = {
appId: string;
......@@ -96,7 +97,7 @@ export async function useSandbox({
try {
await checkTeamSandboxPermission(teamId);
} catch {
throw new Error('当前应用未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。');
throw createAgentSandboxPermissionDeniedError();
}
}
......
......@@ -18,6 +18,7 @@ import { dispatchReadFileTool, ReadFileToolParamsSchema } from '../tools/file';
import { initToolCallEdges, initToolNodes } from '../utils';
import type { ToolInfo } from './useToolCatalog';
import { checkTeamSandboxPermission } from '../../../../../../support/permission/teamLimit';
import { createAgentSandboxPermissionDeniedError } from '../../../../../ai/sandbox/error';
type WorkflowProps = Omit<
DispatchToolModuleProps,
......@@ -130,7 +131,7 @@ export const useToolRunner = ({
try {
await checkTeamSandboxPermission(workflowProps.runningUserInfo.teamId);
} catch {
throw new Error('当前应用未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。');
throw createAgentSandboxPermissionDeniedError();
}
const { input, response, durationSeconds } = await runSandboxTools({
......
......@@ -12,6 +12,7 @@ import { postTextCensor } from '../../../../chat/postTextCensor';
import { useToolNodeList } from './hooks/useToolNodeList';
import { useToolMessages } from './hooks/useToolMessages';
import { checkTeamSandboxPermission } from '../../../../../support/permission/teamLimit';
import { createAgentSandboxPermissionDeniedError } from '../../../../ai/sandbox/error';
type Response = DispatchNodeResultType<{
[NodeOutputKeyEnum.answerText]: string;
......@@ -48,7 +49,7 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise<
try {
await checkTeamSandboxPermission(runningUserInfo.teamId);
} catch {
throw new Error('当前应用未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。');
throw createAgentSandboxPermissionDeniedError();
}
}
......
......@@ -1445,6 +1445,7 @@ export class WorkflowQueue {
const interactiveResult: WorkflowInteractiveResponseType = {
...interactiveResponse,
interactiveId: getNanoid(),
...(nodeResponseId ? { nodeResponseId } : {}),
skipNodeQueue: Array.from(this.skipNodeQueue.values()).map((item) => ({
id: item.node.nodeId,
......
......@@ -150,6 +150,9 @@ import {
migrateArchivedSandboxInstanceRecord,
updateSandboxInstanceRecordBySandboxId
} from '@fastgpt/service/core/ai/sandbox/instance/repository';
import { checkTeamSandboxPermission } from '@fastgpt/service/support/permission/teamLimit';
import { SandboxErrEnum } from '@fastgpt/global/common/error/code/sandbox';
import { getErrText } from '@fastgpt/global/common/error/utils';
type MockReadFileResult = {
path: string;
......@@ -376,6 +379,7 @@ temp_data.csv
describe('createEditDebugSandbox', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkTeamSandboxPermission).mockResolvedValue(undefined);
vi.mocked(buildSandboxAdapter).mockReturnValue({
getInfo: vi.fn(async () => ({
status: { state: 'Running' }
......@@ -383,6 +387,25 @@ describe('createEditDebugSandbox', () => {
} as any);
});
it('throws structured sandbox error when team has no sandbox permission', async () => {
vi.mocked(checkTeamSandboxPermission).mockRejectedValueOnce(new Error('no permission'));
const promise = createEditDebugSandbox({
skillId: 'skill-1',
teamId: 'team-1',
tmbId: 'tmb-1'
});
await expect(promise).rejects.toMatchObject({
message: SandboxErrEnum.agentSandboxPermissionDenied
});
await expect(promise.catch((error) => getErrText(error))).resolves.toBe(
'common:code_error.sandbox_error.agent_sandbox_permission_denied'
);
expect(MongoAgentSkills.findOne).not.toHaveBeenCalled();
});
it('uploads zip packages and decompresses inside the sandbox so Chinese skill directory names are preserved', async () => {
const packageBuffer = Buffer.from('zip');
const skillId = 'skill-1';
......
......@@ -596,7 +596,7 @@ describe('dispatchRunAgent user context', () => {
);
const result = await promise;
expect(result.error?.system_error_text).toBe(
'当前应用未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。'
'common:code_error.sandbox_error.agent_sandbox_permission_denied'
);
expect(getSandboxClientMock).not.toHaveBeenCalled();
});
......
......@@ -3,6 +3,8 @@ import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { useToolRunner } from '@fastgpt/service/core/workflow/dispatch/ai/toolcall/hooks/useToolRunner';
import { summarizeRuntimeNodeResponses } from '@fastgpt/service/core/workflow/dispatch/utils';
import { SandboxErrEnum } from '@fastgpt/global/common/error/code/sandbox';
import { getErrText } from '@fastgpt/global/common/error/utils';
const { dispatchReadFileToolMock, runSandboxToolsMock, runWorkflowMock } = vi.hoisted(() => ({
dispatchReadFileToolMock: vi.fn(),
......@@ -541,8 +543,11 @@ describe('useToolRunner', () => {
});
const promise = runTool({ call: createCall({ name: 'shell' }) });
await expect(promise).rejects.toThrow(
'当前应用未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。'
await expect(promise).rejects.toMatchObject({
message: SandboxErrEnum.agentSandboxPermissionDenied
});
await expect(promise.catch((error) => getErrText(error))).resolves.toBe(
'common:code_error.sandbox_error.agent_sandbox_permission_denied'
);
});
});
......@@ -5,6 +5,8 @@ import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { dispatchRunTools } from '@fastgpt/service/core/workflow/dispatch/ai/toolcall';
import { checkTeamSandboxPermission } from '@fastgpt/service/support/permission/teamLimit';
import { createRuntimeNodeResponseSummary } from '@fastgpt/service/core/workflow/dispatch/utils';
import { SandboxErrEnum } from '@fastgpt/global/common/error/code/sandbox';
import { getErrText } from '@fastgpt/global/common/error/utils';
const { getLLMModelMock, runToolCallMock, useToolMessagesMock, useToolNodeListMock } = vi.hoisted(
() => ({
......@@ -194,8 +196,11 @@ describe('dispatchRunTools file context', () => {
})
);
await expect(promise).rejects.toThrow(
'当前应用未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。'
await expect(promise).rejects.toMatchObject({
message: SandboxErrEnum.agentSandboxPermissionDenied
});
await expect(promise.catch((error) => getErrText(error))).resolves.toBe(
'common:code_error.sandbox_error.agent_sandbox_permission_denied'
);
expect(useToolMessagesMock).not.toHaveBeenCalled();
expect(runToolCallMock).not.toHaveBeenCalled();
......
......@@ -187,6 +187,7 @@
"code_error.skill_error.requirements_too_long": "Requirements must be less than 8000 characters",
"code_error.skill_error.skill_name_too_long": "Skill name must be 50 characters or fewer",
"code_error.skill_error.un_auth_skill": "Unauthorized to Operate This Skill",
"code_error.sandbox_error.agent_sandbox_permission_denied": "The current app is not authorized to use the sandbox/VM. Please contact an administrator to configure it.",
"code_error.system_error.community_version_num_limit": "Exceeded Open Source Version Limit, Please Upgrade to Commercial Version: https://fastgpt.io",
"code_error.system_error.license_app_amount_limit": "Exceed the maximum number of applications in the system",
"code_error.system_error.license_dataset_amount_limit": "Exceed the maximum number of knowledge bases in the system",
......
......@@ -187,6 +187,7 @@
"code_error.skill_error.requirements_too_long": "需求描述不能超过 8000 字符",
"code_error.skill_error.skill_name_too_long": "技能名称不能超过 50 字符",
"code_error.skill_error.un_auth_skill": "无权操作该技能",
"code_error.sandbox_error.agent_sandbox_permission_denied": "当前应用无权使用虚拟机,请联系管理员配置。",
"code_error.system_error.community_version_num_limit": "超出社区版数量限制,请升级商业版: https://fastgpt.in",
"code_error.system_error.license_app_amount_limit": "超出系统最大应用数量",
"code_error.system_error.license_dataset_amount_limit": "超出系统最大知识库数量",
......
......@@ -185,6 +185,7 @@
"code_error.skill_error.requirements_too_long": "需求描述不能超過 8000 字元",
"code_error.skill_error.skill_name_too_long": "技能名稱不能超過 50 字元",
"code_error.skill_error.un_auth_skill": "無權操作該技能",
"code_error.sandbox_error.agent_sandbox_permission_denied": "當前應用無權使用虛擬機,請聯絡管理員配置。",
"code_error.system_error.community_version_num_limit": "超出開源版數量限制,請升級商業版:https://fastgpt.io",
"code_error.system_error.license_app_amount_limit": "超出系統最大應用數量",
"code_error.system_error.license_dataset_amount_limit": "超出系統最大知識庫數量",
......
......@@ -192,10 +192,7 @@ export const shouldAppendResumeInteractive = ({
if (!lastExistingInteractive) return true;
const existingFinalInteractive = extractDeepestInteractive(lastExistingInteractive);
const isSameInteractive =
existingFinalInteractive.type === incomingFinalInteractive.type &&
(existingFinalInteractive.usageId === incomingFinalInteractive.usageId ||
isSameArray(existingFinalInteractive.entryNodeIds, incomingFinalInteractive.entryNodeIds));
const isSameInteractive = areSameInteractive(existingFinalInteractive, incomingFinalInteractive);
if (!isSameInteractive) return true;
......@@ -212,10 +209,16 @@ const areSameInteractive = (
const finalA = extractDeepestInteractive(a);
const finalB = extractDeepestInteractive(b);
if (finalA.type !== finalB.type) return false;
// 新数据使用每次暂停生成的 interactiveId 区分同一节点的不同触发轮次。
if (finalA.interactiveId || finalB.interactiveId) {
return !!finalA.interactiveId && finalA.interactiveId === finalB.interactiveId;
}
return (
finalA.type === finalB.type &&
// 同一轮交互:usageId 相同,或 entryNodeIds 数组完全一致(dataId 变化时仍视为同一表单)
(finalA.usageId === finalB.usageId || isSameArray(finalA.entryNodeIds, finalB.entryNodeIds))
// 兼容旧数据:没有 interactiveId 时,仍按历史规则识别同一轮交互。
finalA.usageId === finalB.usageId || isSameArray(finalA.entryNodeIds, finalB.entryNodeIds)
);
};
......
......@@ -8,6 +8,7 @@ import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { serviceEnv } from '@fastgpt/service/env';
import { timingSafeEqual } from 'crypto';
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { createAgentSandboxPermissionDeniedError } from '@fastgpt/service/core/ai/sandbox/error';
/**
* 统一沙盒 API 会话访问控制鉴权。
......@@ -73,7 +74,7 @@ export async function authSandboxSession({
try {
await checkTeamSandboxPermission(result.teamId);
} catch {
throw new Error('当前应用未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。');
throw createAgentSandboxPermissionDeniedError();
}
return result;
......
......@@ -168,6 +168,7 @@ describe('shouldAppendResumeInteractive', () => {
const baseInteractive = {
type: 'userInput',
entryNodeIds: ['form-node-id'],
interactiveId: 'interactive-id',
memoryEdges: [],
nodeOutputs: [],
usageId: 'usage-id',
......@@ -222,6 +223,40 @@ describe('shouldAppendResumeInteractive', () => {
).toBe(false);
});
it('appends a repeated form node when it is a different interactive trigger', () => {
const submittedInteractive = {
type: 'userInput',
entryNodeIds: ['form-node-id'],
interactiveId: 'first-interactive-id',
memoryEdges: [],
nodeOutputs: [],
usageId: 'usage-id',
params: {
description: '',
inputForm: [],
submitted: true
}
} as const;
expect(
shouldAppendResumeInteractive({
existingValues: [
{
interactive: submittedInteractive
}
],
incomingInteractive: {
...submittedInteractive,
interactiveId: 'second-interactive-id',
params: {
...submittedInteractive.params,
submitted: false
}
}
})
).toBe(true);
});
it('appends a new interactive when there is no submitted matching interactive', () => {
expect(
shouldAppendResumeInteractive({
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment