Commit e7fa0c9f by YeYuheng Committed by GitHub

feat: route helper bot generation through pro (#7137)

parent 9d5a8777
import z from 'zod'; import z from 'zod';
import { SelectedDatasetSchema } from '../../../workflow/type/io';
// TopAgent 参数配置 // TopAgent 参数配置
export const topAgentParamsSchema = z.object({ export const topAgentParamsSchema = z.object({
...@@ -11,3 +12,13 @@ export const topAgentParamsSchema = z.object({ ...@@ -11,3 +12,13 @@ export const topAgentParamsSchema = z.object({
enableSandbox: z.boolean().nullish() enableSandbox: z.boolean().nullish()
}); });
export type TopAgentParamsType = z.infer<typeof topAgentParamsSchema>; export type TopAgentParamsType = z.infer<typeof topAgentParamsSchema>;
export const TopAgentFormDataSchema = z.object({
systemPrompt: z.string().optional(),
tools: z.array(z.string()).optional().default([]),
datasets: z.array(SelectedDatasetSchema).optional().default([]),
fileUploadEnabled: z.boolean().optional().default(false),
enableSandboxEnabled: z.boolean().optional().default(false),
executionPlan: z.any().optional()
});
export type TopAgentFormDataType = z.infer<typeof TopAgentFormDataSchema>;
import { PaginationResponseSchema } from '../../../api'; import { z } from 'zod';
import { PaginationSchema } from '../../../api'; import { PaginationResponseSchema, PaginationSchema } from '../../../api';
import { ChatFileTypeEnum } from '../../../../core/chat/constants';
import { import {
HelperBotChatItemSiteSchema, HelperBotChatItemSiteSchema,
HelperBotTypeEnum, HelperBotTypeEnum,
HelperBotTypeEnumSchema HelperBotTypeEnumSchema
} from '../../../../core/chat/helperBot/type'; } from '../../../../core/chat/helperBot/type';
import { topAgentParamsSchema } from '../../../../core/chat/helperBot/topAgent/type'; import { topAgentParamsSchema } from '../../../../core/chat/helperBot/topAgent/type';
import { z } from 'zod';
import { ChatFileTypeEnum } from '../../../../core/chat/constants'; export const HelperBotCompletionsParamsSchema = z.object({
chatId: z.string(),
chatItemId: z.string(),
query: z.string(),
files: z.array(
z.object({
type: z.enum(ChatFileTypeEnum),
key: z.string(),
url: z.string().optional(),
name: z.string()
})
),
metadata: z.object({
type: z.literal(HelperBotTypeEnum.topAgent),
data: topAgentParamsSchema
})
});
export type HelperBotCompletionsParamsType = z.infer<typeof HelperBotCompletionsParamsSchema>;
// 分页获取记录 // 分页获取记录
export const GetHelperBotChatRecordsParamsSchema = PaginationSchema.extend({ export const GetHelperBotChatRecordsParamsSchema = PaginationSchema.extend({
...@@ -45,22 +63,3 @@ export const GetHelperBotFilePreviewParamsSchema = z.object({ ...@@ -45,22 +63,3 @@ export const GetHelperBotFilePreviewParamsSchema = z.object({
}); });
export type GetHelperBotFilePreviewParamsType = z.infer<typeof GetHelperBotFilePreviewParamsSchema>; export type GetHelperBotFilePreviewParamsType = z.infer<typeof GetHelperBotFilePreviewParamsSchema>;
export const GetHelperBotFilePreviewResponseSchema = z.string(); export const GetHelperBotFilePreviewResponseSchema = z.string();
export const HelperBotCompletionsParamsSchema = z.object({
chatId: z.string(),
chatItemId: z.string(),
query: z.string(),
files: z.array(
z.object({
type: z.enum(ChatFileTypeEnum),
key: z.string(),
url: z.string().optional(),
name: z.string()
})
),
metadata: z.object({
type: z.literal(HelperBotTypeEnum.topAgent),
data: topAgentParamsSchema
})
});
export type HelperBotCompletionsParamsType = z.infer<typeof HelperBotCompletionsParamsSchema>;
import z from 'zod'; import z from 'zod';
import type { OpenAPIPath } from '../../../type'; import type { OpenAPIPath } from '../../../type';
import { TagsMap } from '../../../tag';
import { import {
HelperBotCompletionsParamsSchema,
DeleteHelperBotChatParamsSchema, DeleteHelperBotChatParamsSchema,
GetHelperBotChatRecordsParamsSchema, GetHelperBotChatRecordsParamsSchema,
GetHelperBotChatRecordsResponseSchema, GetHelperBotChatRecordsResponseSchema
HelperBotCompletionsParamsSchema
} from './api'; } from './api';
import { TagsMap } from '../../../tag';
export const HelperBotPath: OpenAPIPath = { export const HelperBotPath: OpenAPIPath = {
'/proApi/core/chat/helperBot/completions': {
post: {
summary: '辅助生成统一对话接口',
description: '辅助生成统一对话接口',
tags: [TagsMap.helperBot],
requestBody: {
content: {
'application/json': {
schema: HelperBotCompletionsParamsSchema
}
}
},
responses: {
200: {
description: '成功返回流式处理结果',
content: {
'application/stream+json': {
schema: z.any()
}
}
}
}
}
},
'/core/chat/helperBot/getRecords': { '/core/chat/helperBot/getRecords': {
get: { get: {
summary: '分页获取记录', summary: '分页获取记录',
...@@ -52,29 +76,5 @@ export const HelperBotPath: OpenAPIPath = { ...@@ -52,29 +76,5 @@ export const HelperBotPath: OpenAPIPath = {
} }
} }
} }
},
'/core/chat/helperBot/completions': {
post: {
summary: '辅助助手对话接口',
description: '辅助助手对话接口',
tags: [TagsMap.helperBot],
requestBody: {
content: {
'application/json': {
schema: HelperBotCompletionsParamsSchema
}
}
},
responses: {
200: {
description: '成功返回处理结果',
content: {
'application/stream+json': {
schema: z.any()
}
}
}
}
}
} }
}; };
import { HelperBotTypeEnum } from '@fastgpt/global/core/chat/helperBot/type';
import { dispatchTopAgent } from './topAgent';
export const dispatchMap = {
[HelperBotTypeEnum.topAgent]: dispatchTopAgent
};
import { type HelperBotDispatchParamsType, type HelperBotDispatchResponseType } from '../type';
import { helperChats2GPTMessages } from '@fastgpt/global/core/chat/helperBot/adaptor';
import { getPrompt } from './prompt';
import { createLLMResponse } from '../../../../ai/llm/request';
import { getDefaultHelperBotModel } from '../../../../ai/model';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { textAdaptGptResponse } from '@fastgpt/global/core/workflow/runtime/utils';
import {
generateResourceList,
extractResourcesFromPlan,
buildSystemPrompt,
buildDisplayText
} from './utils';
import { TopAgentAnswerSchema, TopAgentFormDataSchema } from './type';
import { formatAIResponse } from '../utils';
import type { TopAgentParamsType } from '@fastgpt/global/core/chat/helperBot/topAgent/type';
import type { UserInputInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { parseJsonArgs } from '../../../../ai/utils';
import { MongoDataset } from '../../../../dataset/schema';
import { ObjectIdSchema } from '@fastgpt/global/common/type/mongo';
import type { SelectedDatasetType } from '@fastgpt/global/core/workflow/type/io';
import { getLogger, LogCategories } from '../../../../../common/logger';
import { AGENT_SANDBOX_TOOLSET_ID } from '@fastgpt/global/core/ai/sandbox/tools';
export const dispatchTopAgent = async (
props: HelperBotDispatchParamsType<TopAgentParamsType>
): Promise<HelperBotDispatchResponseType> => {
const { query, files, data, histories, workflowResponseWrite, user } = props;
const modelData = getDefaultHelperBotModel();
if (!modelData) {
return Promise.reject('Can not get model data');
}
const usage = {
model: modelData.model,
inputTokens: 0,
outputTokens: 0
};
const { resourceList } = await generateResourceList({
teamId: user.teamId,
tmbId: user.tmbId,
isRoot: user.isRoot,
lang: user.lang
});
const systemPrompt = getPrompt({
resourceList,
metadata: data
});
const historyMessages = helperChats2GPTMessages({
messages: histories
});
const conversationMessages = [
{ role: 'system' as const, content: systemPrompt },
...historyMessages,
{ role: 'user' as const, content: query }
];
const llmResponse = await createLLMResponse({
body: {
messages: conversationMessages,
model: modelData,
stream: true
},
onReasoning: ({ text }) => {
workflowResponseWrite?.({
event: SseResponseEventEnum.answer,
data: textAdaptGptResponse({ reasoning_content: text })
});
}
// onStreaming: ({ text }) => {
// workflowResponseWrite?.({
// event: SseResponseEventEnum.answer,
// data: textAdaptGptResponse({ text })
// });
// }
});
usage.inputTokens = llmResponse.usage.inputTokens;
usage.outputTokens = llmResponse.usage.outputTokens;
const answerText = llmResponse.answerText;
const reasoningText = llmResponse.reasoningText;
// console.log('Top agent response:', answerText);
try {
const parseAnswer = (text: string) => {
return TopAgentAnswerSchema.safeParseAsync(parseJsonArgs(text));
};
let result = await parseAnswer(answerText);
// console.dir({ label: 'Top agent parsed result', result }, {
// depth: null,
// maxArrayLength: null
// });
if (!result.success) {
getLogger(LogCategories.MODULE.AI.HELPERBOT).warn(
'[Top agent] JSON parse failed, try repair',
{ text: answerText }
);
const repairPrompt = `当前查询的用户问题:${query} \n辅助助手上一次的输出:\n${answerText},\nJSON 解析的报错信息:\n${result.error} \n
查看JSON 的报错信息来修正 JSON 格式错误,并仅返回正确的 JSON,确保 JSON 格式正确无误且可以被解析。不要包含任何多余的信息。`;
const repairResponse = await createLLMResponse({
body: {
messages: [
{ role: 'system' as const, content: systemPrompt },
...historyMessages,
{
role: 'user' as const,
content: repairPrompt
}
],
model: modelData,
stream: true
}
});
usage.inputTokens += repairResponse.usage.inputTokens;
usage.outputTokens += repairResponse.usage.outputTokens;
result = await parseAnswer(repairResponse.answerText);
console.dir(
{ label: 'Top agent parsed result', result },
{
depth: null,
maxArrayLength: null
}
);
if (!result.success) {
getLogger(LogCategories.MODULE.AI.HELPERBOT).warn('[Top agent] JSON repair failed', {
text: repairResponse.answerText
});
return {
aiResponse: formatAIResponse({
text: answerText,
reasoning: reasoningText
}),
usage
};
}
}
const responseJson = result.data;
if (responseJson.phase === 'generation') {
getLogger(LogCategories.MODULE.AI.HELPERBOT).debug(
'🔄 TopAgent: Configuration generation phase'
);
const { tools, knowledges } = extractResourcesFromPlan(responseJson.execution_plan);
const filterDatasets = await filterValidDatasets({
teamId: user.teamId,
datasetIds: knowledges
});
const enableSandboxEnabled =
responseJson.resources?.system_features?.sandbox?.enabled ||
tools.includes(AGENT_SANDBOX_TOOLSET_ID);
const formData = TopAgentFormDataSchema.parse({
systemPrompt: buildSystemPrompt(responseJson), // 构建 system prompt
tools, // 从 execution_plan 提取
datasets: filterDatasets,
fileUploadEnabled: responseJson.resources?.system_features?.file_upload?.enabled || false,
enableSandboxEnabled,
executionPlan: responseJson.execution_plan // 保存原始 execution_plan
});
if (formData) {
workflowResponseWrite?.({
event: SseResponseEventEnum.topAgentConfig,
data: formData
});
}
workflowResponseWrite?.({
event: SseResponseEventEnum.plan,
data: {
type: 'generation'
}
});
return {
aiResponse: formatAIResponse({
text: buildDisplayText(responseJson), // 构建显示文本
reasoning: reasoningText,
planHint: {
type: 'generation'
}
}),
usage
};
} else {
getLogger(LogCategories.MODULE.AI.HELPERBOT).debug(
'📝 TopAgent: Information collection phase'
);
const formDeata = responseJson.form;
if (formDeata) {
const inputForm: UserInputInteractive = {
type: 'userInput',
params: {
inputForm: formDeata.map((item) => {
return {
type: item.type as FlowNodeInputTypeEnum,
key: getNanoid(6),
label: item.label,
value: '',
required: false,
valueType:
item.type === FlowNodeInputTypeEnum.numberInput
? WorkflowIOValueTypeEnum.number
: WorkflowIOValueTypeEnum.string,
list:
'options' in item
? item.options?.map((option) => ({ label: option, value: option }))
: undefined
};
}),
description: responseJson.question
}
};
workflowResponseWrite?.({
event: SseResponseEventEnum.collectionForm,
data: inputForm
});
return {
aiResponse: formatAIResponse({
text: responseJson.question,
reasoning: reasoningText,
collectionForm: inputForm
}),
usage
};
}
workflowResponseWrite?.({
event: SseResponseEventEnum.answer,
data: textAdaptGptResponse({ text: responseJson.question })
});
return {
aiResponse: formatAIResponse({
text: responseJson.question,
reasoning: reasoningText
}),
usage
};
}
} catch (e) {
getLogger(LogCategories.MODULE.AI.HELPERBOT).warn(`[Top agent] Failed to parse JSON response`, {
text: answerText
});
return {
aiResponse: formatAIResponse({
text: answerText,
reasoning: reasoningText
}),
usage
};
}
};
const filterValidDatasets = async ({
teamId,
datasetIds
}: {
teamId: string;
datasetIds: string[];
}): Promise<SelectedDatasetType[]> => {
// Check datasetIds is
const result = await MongoDataset.find(
{
teamId,
_id: {
$in: datasetIds.filter((id) => {
const parse = ObjectIdSchema.safeParse(id);
return parse.success;
})
}
},
'_id avatar name vectorModel'
).lean();
return result.map((item) => ({
datasetId: String(item._id),
avatar: item.avatar,
name: item.name,
vectorModel: {
model: item.vectorModel
}
}));
};
import z from 'zod';
import { AICollectionAnswerSchema } from '../type';
import { SelectedDatasetSchema } from '@fastgpt/global/core/workflow/type/io';
// 执行计划步骤中的资源引用类型
export const StepResourceRefSchema = z.object({
id: z.string(),
type: z.enum(['tool', 'knowledge'])
});
// 执行计划步骤类型
export const ExecutionStepSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string(),
expectedTools: z.array(StepResourceRefSchema).optional()
});
export const ExecutionPlanSchema = z.object({
total_steps: z.number(),
steps: z.array(ExecutionStepSchema)
});
export type ExecutionPlanType = z.infer<typeof ExecutionPlanSchema>;
export const TopAgentFormDataSchema = z.object({
systemPrompt: z.string().optional(),
tools: z.array(z.string()).optional().default([]),
datasets: z.array(SelectedDatasetSchema).optional().default([]),
fileUploadEnabled: z.boolean().optional().default(false),
enableSandboxEnabled: z.boolean().optional().default(false),
executionPlan: z.any().optional()
});
export type TopAgentFormDataType = z.infer<typeof TopAgentFormDataSchema>;
// 表单收集
export const TopAgentCollectionAnswerSchema = AICollectionAnswerSchema.extend({
phase: z.literal('collection'),
reasoning: z.string().nullish()
});
export const TopAgentGenerationAnswerSchema = z.object({
phase: z.literal('generation'),
reasoning: z.string().nullish(),
task_analysis: z.object({
goal: z.string(),
role: z.string(),
key_features: z.string()
}),
execution_plan: ExecutionPlanSchema.optional(),
resources: z.object({
system_features: z.object({
file_upload: z
.object({
enabled: z.boolean(),
purpose: z.string().optional()
})
.optional()
.default({ enabled: false }),
sandbox: z
.object({
enabled: z.boolean()
})
.optional()
.default({ enabled: false })
})
})
});
export const TopAgentAnswerSchema = z.discriminatedUnion('phase', [
TopAgentCollectionAnswerSchema,
TopAgentGenerationAnswerSchema
]);
export type TopAgentAnswerType = z.infer<typeof TopAgentAnswerSchema>;
export type TopAgentGenerationAnswerType = z.infer<typeof TopAgentGenerationAnswerSchema>;
import type { localeType } from '@fastgpt/global/common/i18n/type';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import type { ExecutionPlanType, TopAgentGenerationAnswerType } from './type';
import { SubAppIds, systemSubInfo } from '@fastgpt/global/core/workflow/node/agent/constants';
import { MongoDataset } from '../../../../dataset/schema';
import { MongoResourcePermission } from '../../../../../support/permission/schema';
import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant';
import { getGroupsByTmbId } from '../../../../../support/permission/memberGroup/controllers';
import { getOrgIdSetWithParentByTmbId } from '../../../../../support/permission/org/controllers';
import { getUserAvaliableWorkflowTools } from '../../../../app/tool/workflowTool';
import { SystemToolRepo } from '../../../../app/tool/systemTool/systemTool.repo';
import { AGENT_SANDBOX_TOOLSET_ID } from '@fastgpt/global/core/ai/sandbox/tools';
const getAccessibleDatasets = async ({ teamId, tmbId }: { teamId: string; tmbId: string }) => {
const [roleList, myGroupMap, myOrgSet] = await Promise.all([
MongoResourcePermission.find({
resourceType: PerResourceTypeEnum.dataset,
teamId,
resourceId: { $exists: true }
}).lean(),
getGroupsByTmbId({ tmbId, teamId }),
getOrgIdSetWithParentByTmbId({ teamId, tmbId })
]);
const groupIdSet = new Set(myGroupMap.map((item) => String(item._id)));
const datasetIds = roleList
.filter(
(item) =>
String(item.tmbId) === String(tmbId) ||
(item.groupId && groupIdSet.has(String(item.groupId))) ||
(item.orgId && myOrgSet.has(String(item.orgId)))
)
.map((item) => String(item.resourceId));
if (datasetIds.length === 0) return [];
return MongoDataset.find({
_id: { $in: Array.from(new Set(datasetIds)) },
teamId,
deleteTime: null
})
.select('_id name intro avatar vectorModel')
.sort({ updateTime: -1 })
.lean();
};
export const generateResourceList = async ({
teamId,
tmbId,
isRoot,
lang = 'zh-CN'
}: {
teamId: string;
tmbId: string;
isRoot: boolean;
lang?: localeType;
}): Promise<{
resourceList: string;
}> => {
const getPrompt = ({ tool, dataset }: { tool: string; dataset: string }) => {
return `## 可用工具与知识库
### 工具
${tool}
### 知识库
${dataset}
## 可配置前端开关(不是工具,不能 @ 引用)
- **file_upload**: 文件上传开关,允许用户在对话中上传文件
- **sandbox**: 虚拟机开关,允许 Agent 使用虚拟机执行环境
`;
};
const systemToolRepo = SystemToolRepo.getInstance();
const [systemTools, myTools, myDatasets] = await Promise.all([
systemToolRepo
.getSystemToolList({
sources: [
'system'
// teamId
],
lang
})
.then((res) =>
res.map((tool) => {
const toolId = tool.id;
const name = tool.name;
const intro = tool.intro;
const description = tool.toolDescription || intro || '暂无描述';
return `- **${toolId}** [工具]: ${name} - ${description}`;
})
),
getUserAvaliableWorkflowTools({ teamId, tmbId }).then((res) =>
res.map((tool) => {
const toolId = tool._id;
return `- **${toolId}** [工具]: ${tool.name} - ${tool.intro}`;
})
),
getAccessibleDatasets({ teamId, tmbId }).then((res) => {
return res.map((dataset) => {
const id = String(dataset._id);
const name = dataset.name || '未命名知识库';
const intro = dataset.intro || '暂无描述';
return `- **${id}** [知识库]: ${name} - ${intro}`;
});
})
]);
const builtinTools = [SubAppIds.readFiles, AGENT_SANDBOX_TOOLSET_ID].map((id) => {
const info = systemSubInfo[id];
return `- **${id}** [工具]: ${parseI18nString(info.name, lang)} - ${info.toolDescription}`;
});
const allTools = [...systemTools, ...myTools, ...builtinTools];
return {
resourceList: getPrompt({
tool: allTools.length > 0 ? allTools.join('\n') : '暂无已安装的工具',
dataset: myDatasets.length > 0 ? myDatasets.join('\n') : '暂未配置知识库'
})
};
};
/**
* 从 execution_plan 中提取并去重所有使用的资源
*/
export const extractResourcesFromPlan = (executionPlan?: ExecutionPlanType) => {
if (!executionPlan) {
return { tools: [], knowledges: [] };
}
const toolSet = new Set<string>();
const knowledgeSet = new Set<string>();
executionPlan.steps.forEach((step) => {
step.expectedTools?.forEach((resourceRef) => {
if (resourceRef.type === 'tool') {
toolSet.add(resourceRef.id);
} else if (resourceRef.type === 'knowledge') {
knowledgeSet.add(resourceRef.id);
}
});
});
return {
tools: Array.from(toolSet),
knowledges: Array.from(knowledgeSet)
};
};
/**
* 构建包含所有信息的 system prompt 文本
* 使用 {{@toolId@}} 格式引用工具,可被 parseSystemPrompt 解析
*/
export const buildSystemPrompt = (data: TopAgentGenerationAnswerType): string => {
const parts: string[] = [];
// 1. 任务分析
if (data.task_analysis) {
const { goal, role, key_features } = data.task_analysis;
parts.push(`---\n**任务目标**\n${goal}\n`);
parts.push(`**角色定位**\n${role}\n`);
if (key_features) {
parts.push(`**核心特征**\n${key_features}\n`);
}
}
// 2. 执行计划
if (data.execution_plan) {
parts.push(`---\n**参考计划**`);
data.execution_plan.steps.forEach((step, index) => {
let description = step.description;
// 替换 description 中的资源引用:
// - 工具: @工具ID / @工具ID@ / @[工具ID] -> {{@工具ID@}}
// - 知识库: @知识库ID / @知识库ID@ / @[知识库ID] -> {{@dataset_search@}}
if (step.expectedTools && step.expectedTools.length > 0) {
step.expectedTools.forEach((resourceRef) => {
const replaceId =
resourceRef.type === 'knowledge' ? SubAppIds.datasetSearch : resourceRef.id;
const escapedId = resourceRef.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`@(?:\\[${escapedId}\\]|${escapedId}@?)`, 'g');
description = description.replace(regex, `{{@${replaceId}@}}`);
});
}
description = description.replace(
/(?<!\{\{)@(?:\[(file_upload|sandbox)\]|(file_upload|sandbox)@?)(?!\}\})/g,
'$1$2'
);
parts.push(`\n步骤 ${index + 1}. ${step.title} \n${description}`);
// if (step.expectedTools && step.expectedTools.length > 0) {
// const toolList = step.expectedTools
// .map((t) => {
// const ref = `{{@${t.id}@}}`;
// return `${ref}`;
// })
// .join('、');
// parts.push(`预期资源: ${toolList}`);
// }
});
parts.push('');
}
// 3. 系统功能
// if (data.resources?.system_features?.file_upload?.enabled) {
// parts.push(`---\n**系统功能**\n`);
// parts.push(
// `**文件上传**: 已启用\n${data.resources.system_features.file_upload.purpose}`
// );
// }
return parts.join('\n');
};
/**
* 构建用于显示的文本(与 system prompt 格式一致)
*/
export const buildDisplayText = (data: TopAgentGenerationAnswerType): string => {
return buildSystemPrompt(data);
};
import z from 'zod';
import { HelperBotCompletionsParamsSchema } from '../../../../../global/openapi/core/chat/helperBot/api';
import {
AIChatItemValueItemSchema,
HelperBotChatItemSchema
} from '@fastgpt/global/core/chat/helperBot/type';
import { WorkflowResponseFnSchema } from '../../../workflow/dispatch/type';
import { LocaleList } from '@fastgpt/global/common/i18n/type';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
export const HelperBotDispatchParamsSchema = z.object({
query: z.string(),
files: HelperBotCompletionsParamsSchema.shape.files,
data: z.unknown(), // Allow any type, will be constrained by generic type parameter
histories: z.array(HelperBotChatItemSchema),
workflowResponseWrite: WorkflowResponseFnSchema,
user: z.object({
teamId: z.string(),
tmbId: z.string(),
userId: z.string(),
isRoot: z.boolean(),
lang: z.enum(LocaleList)
})
});
type BaseHelperBotDispatchParamsType = z.infer<typeof HelperBotDispatchParamsSchema>;
export type HelperBotDispatchParamsType<T = unknown> = Omit<
BaseHelperBotDispatchParamsType,
'data'
> & {
data: T;
};
export const HelperBotDispatchResponseSchema = z.object({
aiResponse: z.array(AIChatItemValueItemSchema),
usage: z.object({
model: z.string(),
inputTokens: z.number(),
outputTokens: z.number()
})
});
export type HelperBotDispatchResponseType = z.infer<typeof HelperBotDispatchResponseSchema>;
/* AI 表单输出 schema */
const InputSchema = z.object({
type: z.enum([FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.numberInput]),
label: z.string()
});
const SelectSchema = z.object({
type: z.enum([FlowNodeInputTypeEnum.select, FlowNodeInputTypeEnum.multipleSelect]),
label: z.string(),
options: z.array(z.string())
});
export const AICollectionAnswerSchema = z.object({
question: z.string(), // 可能只有一个问题,可能
form: z.array(z.union([InputSchema, SelectSchema])).optional()
});
export type AICollectionAnswerType = z.infer<typeof AICollectionAnswerSchema>;
import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/helperBot/type';
import type { UserInputInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type';
type PlanHintType = {
planHint?: {
type: 'generation';
};
};
export const formatAIResponse = ({
text,
reasoning,
collectionForm,
planHint
}: {
text: string;
reasoning?: string;
collectionForm?: UserInputInteractive;
planHint?: PlanHintType['planHint'];
}): AIChatItemValueItemType[] => {
const result: AIChatItemValueItemType[] = [];
result.push({
...(reasoning
? {
reasoning: {
content: reasoning
}
}
: {}),
text: {
content: text
}
});
if (collectionForm) {
result.push({
collectionForm
});
}
if (planHint) {
result.push({
planHint
});
}
return result;
};
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AGENT_SANDBOX_TOOLSET_ID } from '@fastgpt/global/core/ai/sandbox/tools';
import { SubAppIds } from '@fastgpt/global/core/workflow/node/agent/constants';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
const { createLLMResponseMock } = vi.hoisted(() => ({
createLLMResponseMock: vi.fn()
}));
vi.mock('@fastgpt/service/core/ai/llm/request', () => ({
createLLMResponse: createLLMResponseMock
}));
vi.mock('@fastgpt/service/core/ai/model', () => ({
getDefaultHelperBotModel: vi.fn(() => ({
model: 'helper-model'
}))
}));
vi.mock('@fastgpt/service/core/app/tool/workflowTool', () => ({
getUserAvaliableWorkflowTools: vi.fn(async () => [])
}));
vi.mock('@fastgpt/service/core/app/tool/systemTool/systemTool.repo', () => ({
SystemToolRepo: {
getInstance: vi.fn(() => ({
getSystemToolList: vi.fn(async () => [])
}))
}
}));
vi.mock('@fastgpt/service/core/dataset/schema', () => ({
MongoDataset: {
find: vi.fn(() => ({
select: vi.fn(() => ({
sort: vi.fn(() => ({
lean: vi.fn(async () => [])
}))
})),
lean: vi.fn(async () => [])
}))
}
}));
vi.mock('@fastgpt/service/support/permission/schema', () => ({
MongoResourcePermission: {
find: vi.fn(() => ({
lean: vi.fn(async () => [])
}))
}
}));
vi.mock('@fastgpt/service/support/permission/memberGroup/controllers', () => ({
getGroupsByTmbId: vi.fn(async () => [])
}));
vi.mock('@fastgpt/service/support/permission/org/controllers', () => ({
getOrgIdSetWithParentByTmbId: vi.fn(async () => new Set())
}));
import { dispatchTopAgent } from '@fastgpt/service/core/chat/HelperBot/dispatch/topAgent';
describe('dispatchTopAgent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockGenerationResponse = ({
description,
expectedTools
}: {
description: string;
expectedTools: Array<{ id: string; type: 'tool' | 'knowledge' }>;
}) => {
createLLMResponseMock.mockResolvedValue({
answerText: JSON.stringify({
phase: 'generation',
reasoning: 'generate agent config',
task_analysis: {
goal: 'build helper agent',
role: 'assistant',
key_features: 'use selected resources'
},
execution_plan: {
total_steps: 1,
steps: [
{
id: 'step_1',
title: 'Use resource',
description,
expectedTools
}
]
},
resources: {
system_features: {
file_upload: {
enabled: false
},
sandbox: {
enabled: false
}
}
}
}),
reasoningText: '',
usage: {
inputTokens: 10,
outputTokens: 5
}
});
};
const dispatchAndGetTopAgentConfig = async () => {
const workflowResponseWrite = vi.fn();
await dispatchTopAgent({
query: 'build an agent with selected resources',
files: [],
data: {},
histories: [],
workflowResponseWrite,
user: {
teamId: 'team_1',
tmbId: 'tmb_1',
userId: 'user_1',
isRoot: false,
lang: 'zh-CN'
}
});
const configEvent = workflowResponseWrite.mock.calls.find(
([payload]) => payload.event === SseResponseEventEnum.topAgentConfig
);
expect(configEvent).toBeDefined();
return configEvent![0].data;
};
it('enables sandbox when generated plan selects the agent sandbox toolset', async () => {
createLLMResponseMock.mockResolvedValue({
answerText: JSON.stringify({
phase: 'generation',
reasoning: 'need sandbox',
task_analysis: {
goal: 'run code',
role: 'coding assistant',
key_features: 'execute shell commands'
},
execution_plan: {
total_steps: 1,
steps: [
{
id: 'step_1',
title: 'Execute command',
description: `Use @${AGENT_SANDBOX_TOOLSET_ID} to inspect files`,
expectedTools: [
{
id: AGENT_SANDBOX_TOOLSET_ID,
type: 'tool'
}
]
}
]
},
resources: {
system_features: {
file_upload: {
enabled: false
},
sandbox: {
enabled: false
}
}
}
}),
reasoningText: '',
usage: {
inputTokens: 10,
outputTokens: 5
}
});
const workflowResponseWrite = vi.fn();
await dispatchTopAgent({
query: 'build an agent that can run commands',
files: [],
data: {},
histories: [],
workflowResponseWrite,
user: {
teamId: 'team_1',
tmbId: 'tmb_1',
userId: 'user_1',
isRoot: false,
lang: 'zh-CN'
}
});
expect(workflowResponseWrite).toHaveBeenCalledWith({
event: SseResponseEventEnum.topAgentConfig,
data: expect.objectContaining({
tools: [AGENT_SANDBOX_TOOLSET_ID],
systemPrompt: expect.stringContaining(`{{@${AGENT_SANDBOX_TOOLSET_ID}@}}`),
enableSandboxEnabled: true
})
});
});
it('renders bracketed tool references in generated step descriptions', async () => {
const toolId = 'custom/search_tool';
mockGenerationResponse({
description: `使用 @[${toolId}] 搜索信息`,
expectedTools: [
{
id: toolId,
type: 'tool'
}
]
});
const config = await dispatchAndGetTopAgentConfig();
expect(config).toEqual(
expect.objectContaining({
tools: [toolId],
systemPrompt: expect.stringContaining(`{{@${toolId}@}}`)
})
);
});
it('renders plain tool references in generated step descriptions', async () => {
const toolId = 'custom/search_tool';
mockGenerationResponse({
description: `使用 @${toolId} 搜索信息`,
expectedTools: [
{
id: toolId,
type: 'tool'
}
]
});
const config = await dispatchAndGetTopAgentConfig();
expect(config).toEqual(
expect.objectContaining({
tools: [toolId],
systemPrompt: expect.stringContaining(`{{@${toolId}@}}`)
})
);
});
it('renders knowledge references as dataset search skill labels', async () => {
const datasetId = '507f1f77bcf86cd799439011';
mockGenerationResponse({
description: `使用 @[${datasetId}] 查询知识库`,
expectedTools: [
{
id: datasetId,
type: 'knowledge'
}
]
});
const config = await dispatchAndGetTopAgentConfig();
expect(config).toEqual(
expect.objectContaining({
systemPrompt: expect.stringContaining(`{{@${SubAppIds.datasetSearch}@}}`)
})
);
});
it('does not render system features as skill labels in generated step descriptions', async () => {
mockGenerationResponse({
description: `通过 @file_upload 接收文件,并使用 @${SubAppIds.readFiles} 读取内容,不要使用 @sandbox`,
expectedTools: [
{
id: SubAppIds.readFiles,
type: 'tool'
}
]
});
const config = await dispatchAndGetTopAgentConfig();
expect(config).toEqual(
expect.objectContaining({
tools: [SubAppIds.readFiles],
systemPrompt: expect.stringContaining(`{{@${SubAppIds.readFiles}@}}`)
})
);
expect(config.systemPrompt).toContain('通过 file_upload 接收文件');
expect(config.systemPrompt).toContain('不要使用 sandbox');
expect(config.systemPrompt).not.toContain('{{@file_upload@}}');
expect(config.systemPrompt).not.toContain('{{@sandbox@}}');
});
});
import { describe, expect, it, vi } from 'vitest';
import {
AGENT_SANDBOX_TOOLSET_ID,
SANDBOX_SHELL_TOOL_NAME
} from '@fastgpt/global/core/ai/sandbox/tools';
vi.mock('@fastgpt/service/core/app/tool/workflowTool', () => ({
getUserAvaliableWorkflowTools: vi.fn(async () => [])
}));
vi.mock('@fastgpt/service/core/app/tool/systemTool/systemTool.repo', () => ({
SystemToolRepo: {
getInstance: vi.fn(() => ({
getSystemToolList: vi.fn(async () => [])
}))
}
}));
vi.mock('@fastgpt/service/core/dataset/schema', () => ({
MongoDataset: {
find: vi.fn(() => ({
select: vi.fn(() => ({
sort: vi.fn(() => ({
lean: vi.fn(async () => [])
}))
}))
}))
}
}));
vi.mock('@fastgpt/service/support/permission/schema', () => ({
MongoResourcePermission: {
find: vi.fn(() => ({
lean: vi.fn(async () => [])
}))
}
}));
vi.mock('@fastgpt/service/support/permission/memberGroup/controllers', () => ({
getGroupsByTmbId: vi.fn(async () => [])
}));
vi.mock('@fastgpt/service/support/permission/org/controllers', () => ({
getOrgIdSetWithParentByTmbId: vi.fn(async () => new Set())
}));
import { generateResourceList } from '@fastgpt/service/core/chat/HelperBot/dispatch/topAgent/utils';
describe('topAgent utils', () => {
it('lists sandbox as an agent sandbox capability group instead of the shell tool', async () => {
const { resourceList } = await generateResourceList({
teamId: 'team_1',
tmbId: 'tmb_1',
isRoot: false,
lang: 'zh-CN'
});
expect(resourceList).toContain(`**${AGENT_SANDBOX_TOOLSET_ID}**`);
expect(resourceList).not.toContain(`**${SANDBOX_SHELL_TOOL_NAME}**`);
});
});
Subproject commit 336c7cc6988acea74d38f123b44680cff5064c27 Subproject commit 860cc780847d747e7fdd24433e95887cdb341812
...@@ -11,7 +11,7 @@ import type { ...@@ -11,7 +11,7 @@ import type {
UserInputInteractive, UserInputInteractive,
WorkflowInteractiveResponseType WorkflowInteractiveResponseType
} from '@fastgpt/global/core/workflow/template/system/interactive/type'; } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import type { TopAgentFormDataType } from '@fastgpt/service/core/chat/HelperBot/dispatch/topAgent/type'; import type { TopAgentFormDataType } from '@fastgpt/global/core/chat/helperBot/topAgent/type';
import type { AgentPlanStatusType, AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; import type { AgentPlanStatusType, AgentPlanType } from '@fastgpt/global/core/ai/agent/type';
export type generatingMessageProps = { export type generatingMessageProps = {
......
...@@ -91,12 +91,12 @@ const RenderText = React.memo(function RenderText({ ...@@ -91,12 +91,12 @@ const RenderText = React.memo(function RenderText({
return <Markdown source={source} showAnimation={showAnimation} />; return <Markdown source={source} showAnimation={showAnimation} />;
}); });
const RenderCollectionForm = React.memo(function RenderCollectionForm({ const RenderCollectionForm = React.memo(function RenderCollectionForm({
isLastValue, canSubmit,
collectionForm, collectionForm,
onSubmit, onSubmit,
showDescription = true showDescription = true
}: { }: {
isLastValue: boolean; canSubmit: boolean;
collectionForm: UserInputInteractive; collectionForm: UserInputInteractive;
onSubmit: (formData: string) => void; onSubmit: (formData: string) => void;
showDescription?: boolean; showDescription?: boolean;
...@@ -141,7 +141,7 @@ const RenderCollectionForm = React.memo(function RenderCollectionForm({ ...@@ -141,7 +141,7 @@ const RenderCollectionForm = React.memo(function RenderCollectionForm({
})} })}
</Flex> </Flex>
{!submitted && isLastValue && ( {!submitted && canSubmit && (
<Flex justifyContent={'flex-end'} mt={4}> <Flex justifyContent={'flex-end'} mt={4}>
<Button <Button
size={'sm'} size={'sm'}
...@@ -180,6 +180,9 @@ const AIItem = ({ ...@@ -180,6 +180,9 @@ const AIItem = ({
('text' in firstValue && firstValue.text?.content) || ('text' in firstValue && firstValue.text?.content) ||
('reasoning' in firstValue && firstValue.reasoning && !firstValue.hideReason) ('reasoning' in firstValue && firstValue.reasoning && !firstValue.hideReason)
); );
const lastCollectionFormIndex = chat.value.findLastIndex(
(value) => 'collectionForm' in value && value.collectionForm
);
return ( return (
<Box <Box
...@@ -214,7 +217,7 @@ const AIItem = ({ ...@@ -214,7 +217,7 @@ const AIItem = ({
return ( return (
<RenderCollectionForm <RenderCollectionForm
key={i} key={i}
isLastValue={isLastChild && i === chat.value.length - 1} canSubmit={isLastChild && i === lastCollectionFormIndex}
collectionForm={value.collectionForm} collectionForm={value.collectionForm}
onSubmit={onSubmitCollectionForm} onSubmit={onSubmitCollectionForm}
/> />
......
...@@ -2,9 +2,11 @@ import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; ...@@ -2,9 +2,11 @@ import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
import React, { type ReactNode } from 'react'; import React, { type ReactNode } from 'react';
import { createContext } from 'use-context-selector'; import { createContext } from 'use-context-selector';
import { HelperBotTypeEnum } from '@fastgpt/global/core/chat/helperBot/type'; import { HelperBotTypeEnum } from '@fastgpt/global/core/chat/helperBot/type';
import type { TopAgentParamsType } from '@fastgpt/global/core/chat/helperBot/topAgent/type'; import type {
TopAgentFormDataType,
TopAgentParamsType
} from '@fastgpt/global/core/chat/helperBot/topAgent/type';
import { type AppFileSelectConfigType } from '@fastgpt/global/core/app/type/config.schema'; import { type AppFileSelectConfigType } from '@fastgpt/global/core/app/type/config.schema';
import type { TopAgentFormDataType } from '@fastgpt/service/core/chat/HelperBot/dispatch/topAgent/type';
export type HelperBotRefType = { export type HelperBotRefType = {
restartChat: () => void; restartChat: () => void;
......
...@@ -293,7 +293,7 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro ...@@ -293,7 +293,7 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro
chatController.current = abortSignal; chatController.current = abortSignal;
const response = await streamFetch({ const response = await streamFetch({
url: '/api/core/chat/helperBot/completions', url: '/api/proApi/core/chat/helperBot/completions',
data: { data: {
chatId, chatId,
chatItemId: chatItemDataId, chatItemId: chatItemDataId,
......
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import {
HelperBotCompletionsParamsSchema,
type HelperBotCompletionsParamsType
} from '@fastgpt/global/openapi/core/chat/helperBot/api';
import { authCert } from '@fastgpt/service/support/permission/auth/common';
import { MongoHelperBotChatItem } from '@fastgpt/service/core/chat/HelperBot/chatItemSchema';
import { getWorkflowResponseWrite } from '@fastgpt/service/core/workflow/dispatch/utils';
import { dispatchMap } from '@fastgpt/service/core/chat/HelperBot/dispatch/index';
import { pushChatRecords } from '@fastgpt/service/core/chat/HelperBot/utils';
import { getLocale } from '@fastgpt/service/common/middle/i18n';
import { authFrequencyLimit } from '@fastgpt/service/common/system/frequencyLimit/utils';
import { addSeconds } from 'date-fns';
import { sseErrRes } from '@fastgpt/service/common/response';
import { getLogger, LogCategories } from '@fastgpt/service/common/logger';
export type completionsBody = HelperBotCompletionsParamsType;
async function handler(req: ApiRequestProps<completionsBody>, res: ApiResponseType<any>) {
const logger = getLogger(LogCategories.MODULE.AI.HELPERBOT);
const setSSEHeaders = () => {
const headers: Record<string, string> = {
Connection: 'keep-alive',
'Content-Type': 'text/event-stream;charset=utf-8',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no',
'Cache-Control': 'no-cache, no-transform'
};
Object.entries(headers).forEach(([key, value]) => {
res.setHeader(key, value);
});
};
// keep consistent with SSE APIs, otherwise stream consumer may treat response as non-SSE
setSSEHeaders();
const parseResult = HelperBotCompletionsParamsSchema.safeParse(req.body);
if (!parseResult.success) {
sseErrRes(res, parseResult.error);
return res.end();
}
const { chatId, chatItemId, query, files, metadata } = parseResult.data;
try {
const { teamId, tmbId, userId, isRoot } = await authCert({ req, authToken: true });
// Limit
await authFrequencyLimit({
eventId: `${tmbId}-helperBot-completions`,
maxAmount: 10,
expiredTime: addSeconds(new Date(), 60)
}).catch(() => {
return Promise.reject('Frequency limit exceeded');
});
const histories = await MongoHelperBotChatItem.find({
userId,
chatId
})
.sort({ _id: -1 })
.limit(40)
.lean();
histories.reverse();
const workflowResponseWrite = getWorkflowResponseWrite({
res,
detail: true,
streamResponse: true,
id: chatId,
showNodeStatus: true
});
// 执行不同逻辑
const fn = dispatchMap[metadata.type];
if (!fn) {
return Promise.reject('Invalid helper bot type');
}
const result = await fn({
query,
files,
data: metadata.data,
histories,
workflowResponseWrite,
user: {
teamId,
tmbId,
userId,
isRoot,
lang: getLocale(req)
}
});
// Save chat
await pushChatRecords({
type: metadata.type,
userId,
chatId,
chatItemId,
query,
files,
aiResponse: result.aiResponse
});
// Push usage
// pushHelperBotUsage({
// teamId,
// tmbId,
// model: result.usage.model,
// inputTokens: result.usage.inputTokens,
// outputTokens: result.usage.outputTokens
// });
} catch (error) {
logger.error('HelperBot completions failed', {
error,
chatId,
chatItemId,
metadata
});
sseErrRes(res, error);
}
res.end();
}
export default NextAPI(handler);
export const config = {
api: {
bodyParser: {
sizeLimit: '20mb'
},
responseLimit: '20mb'
}
};
...@@ -25,7 +25,7 @@ import type { ...@@ -25,7 +25,7 @@ import type {
ToolModuleResponseItemType, ToolModuleResponseItemType,
SkillModuleResponseItemType SkillModuleResponseItemType
} from '@fastgpt/global/core/chat/type'; } from '@fastgpt/global/core/chat/type';
import type { TopAgentFormDataType } from '@fastgpt/service/core/chat/HelperBot/dispatch/topAgent/type'; import type { TopAgentFormDataType } from '@fastgpt/global/core/chat/helperBot/topAgent/type';
import type { UserInputInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type'; import type { UserInputInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import type { AgentPlanStatusType, AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; import type { AgentPlanStatusType, AgentPlanType } from '@fastgpt/global/core/ai/agent/type';
import type { StreamNoNeedToBeResumeType } from '@fastgpt/global/openapi/core/ai/api'; import type { StreamNoNeedToBeResumeType } from '@fastgpt/global/openapi/core/ai/api';
...@@ -204,7 +204,7 @@ function handleEventSourceData(params: HandleEventSourceDataParams) { ...@@ -204,7 +204,7 @@ function handleEventSourceData(params: HandleEventSourceDataParams) {
} }
case SseResponseEventEnum.collectionForm: { case SseResponseEventEnum.collectionForm: {
onmessage({ event, collectionForm: obj }); enqueue({ responseValueId, event, collectionForm: obj });
break; break;
} }
......
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