Commit 661cda83 by light5980 Committed by GitHub

Deleted show (#7043)

* feat(app): add knowledge base deleted show

# Conflicts:
#	projects/app/test/service/core/app/rewriteAppWorkflowToDetail.test.ts

* feat: workflow konwledge base hover

* perf: rewrite app detail

* udpate doc

* fix: review

* fix: test

---------

Co-authored-by: archer <545436317@qq.com>
parent ab0b7456
......@@ -280,7 +280,7 @@ ChatItem
设计文档:
- `.agent/design/bug/stream-resume-form-input-file-list.md`
- `.agents/design/bug/stream-resume-form-input-file-list.md`
- 新增本次问题的设计说明、根因、方案、测试和 TODO。
### 数据合并与恢复工具
......
......@@ -10,7 +10,7 @@ FastGPT 是一个 AI Agent 构建平台,通过 Flow 提供开箱即用的数据
## 设计文档
你可以参考 [项目设计文档](./.agent/design/) 来了解 FastGPT 已有的设计方案。
你可以参考 [项目设计文档](./.agents/design/) 来了解 FastGPT 已有的设计方案。
## 架构
......@@ -34,7 +34,7 @@ FastGPT 是一个 AI Agent 构建平台,通过 Flow 提供开箱即用的数据
## 开发命令
常用开发命令见 [FastGPT 开发命令](./.agent/code/commands.md)。
常用开发命令见 [FastGPT 开发命令](./.agents/code/commands.md)。
## 测试
......@@ -87,7 +87,7 @@ FastGPT 是一个 AI Agent 构建平台,通过 Flow 提供开箱即用的数据
## 代码规范
[FastGPT 代码规范](./.agent/code/syntax.md)
[FastGPT 代码规范](./.agents/code/syntax.md)
### API 入参校验
......@@ -105,7 +105,7 @@ const { body, query } = parseApiInput({
```
- 这个 helper 只用于 API 边界的请求入参校验。内部业务数据、数据库记录、模型返回、工具调用参数等 schema 校验仍使用普通 `Schema.parse(...)`,因为这些错误应按内部 bug 上报。
- 相关设计见 [Zod 请求入参错误降噪设计](./.agent/design/api/zod-request-parse-error-handling.md)。
- 相关设计见 [Zod 请求入参错误降噪设计](./.agents/design/api/zod-request-parse-error-handling.md)。
### 函数注释
......@@ -165,8 +165,8 @@ function agent_loop(用户需求){
1. 输出语言:中文
2. 输出文档位置:
2.1. 设计文档: [.agent/design](.agent/design),todo 跟在设计文档后面。
2.2. 问题分析文档: [.agent/issue](.agent/issue)
2.1. 设计文档: [.agents/design](.agents/design),todo 跟在设计文档后面。
2.2. 问题分析文档: [.agents/issue](.agents/issue)
3. 相同需求文档,尽量写在一起(内容超过 300 行,可以分批写入),或者创建要给目录一起管理,不要随意平铺一堆不同版本的相同问题的文档。
4. 文件输出,使用正确的编码格式,例如UTF-8。
5. 除非用户指明,否则不要编写总结报告。
......@@ -48,6 +48,7 @@ fastgpt-plugin:
3. 插件运行入口支持从对象存储拉取,并缓存到本地文件目录。
4. 输入引导配置增加校验,避免错误配置了自定义词库地址。
5. 工作流数组引用类型增强校验,避免刚好与二维数据冲突。
6. 知识库被删除后,应用编排时优雅提示。
## 🐛 修复
......
......@@ -275,7 +275,7 @@
"content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-28T16:21:09+08:00",
"content/self-host/upgrading/4-15/41503.mdx": "2026-05-28T16:21:09+08:00",
"content/self-host/upgrading/4-15/41504.en.mdx": "2026-06-01T17:19:55+08:00",
"content/self-host/upgrading/4-15/41504.mdx": "2026-06-04T16:10:15+08:00",
"content/self-host/upgrading/4-15/41504.mdx": "2026-06-05T13:29:50+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......
......@@ -20,7 +20,8 @@ export const SelectedDatasetSchema = z.object({
model: z.string().meta({
description: '知识库使用的向量模型'
})
})
}),
isDeleted: BoolSchema.optional()
});
export type SelectedDatasetType = z.infer<typeof SelectedDatasetSchema>;
......
......@@ -60,9 +60,10 @@ export const getHandleId = (
return `${nodeId}-${type}-${key}`;
};
export const checkInputIsReference = (input: FlowNodeInputItemType) => {
if (input.renderTypeList?.[input?.selectedTypeIndex || 0] === FlowNodeInputTypeEnum.reference)
export const nodeInputIsReference = (input: FlowNodeInputItemType) => {
if (input.renderTypeList?.[input?.selectedTypeIndex || 0] === FlowNodeInputTypeEnum.reference) {
return true;
}
return false;
};
......@@ -410,7 +411,9 @@ export const formatEditorVariablePickerIcon = (
// Check the value is a valid reference value format: [variableId, outputId]
export const isValidReferenceValueFormat = (
value: any,
nodesMap?: Record<string, Pick<StoreNodeItemType, 'nodeId'>> | Map<string, Pick<StoreNodeItemType, 'nodeId'>>
nodesMap?:
| Record<string, Pick<StoreNodeItemType, 'nodeId'>>
| Map<string, Pick<StoreNodeItemType, 'nodeId'>>
): value is ReferenceItemValueType => {
if (!(Array.isArray(value) && value.length === 2 && typeof value[0] === 'string')) {
return false;
......
import { describe, expect, it, vi, beforeEach } from 'vitest';
import {
getHandleId,
checkInputIsReference,
nodeInputIsReference,
getGuideModule,
splitGuideModule,
getAppChatConfig,
......@@ -65,14 +65,14 @@ describe('getHandleId', () => {
});
});
describe('checkInputIsReference', () => {
describe('nodeInputIsReference', () => {
it('should return true when renderTypeList first item is reference', () => {
const input: FlowNodeInputItemType = {
key: 'test',
label: 'Test',
renderTypeList: [FlowNodeInputTypeEnum.reference]
};
expect(checkInputIsReference(input)).toBe(true);
expect(nodeInputIsReference(input)).toBe(true);
});
it('should return true when selectedTypeIndex points to reference', () => {
......@@ -82,7 +82,7 @@ describe('checkInputIsReference', () => {
renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 1
};
expect(checkInputIsReference(input)).toBe(true);
expect(nodeInputIsReference(input)).toBe(true);
});
it('should return false when renderTypeList first item is not reference', () => {
......@@ -91,7 +91,7 @@ describe('checkInputIsReference', () => {
label: 'Test',
renderTypeList: [FlowNodeInputTypeEnum.input]
};
expect(checkInputIsReference(input)).toBe(false);
expect(nodeInputIsReference(input)).toBe(false);
});
it('should return false when selectedTypeIndex is 0 and first item is not reference', () => {
......@@ -101,7 +101,7 @@ describe('checkInputIsReference', () => {
renderTypeList: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 0
};
expect(checkInputIsReference(input)).toBe(false);
expect(nodeInputIsReference(input)).toBe(false);
});
it('should return false when renderTypeList is undefined', () => {
......@@ -109,7 +109,7 @@ describe('checkInputIsReference', () => {
key: 'test',
label: 'Test'
} as FlowNodeInputItemType;
expect(checkInputIsReference(input)).toBe(false);
expect(nodeInputIsReference(input)).toBe(false);
});
it('should use index 0 when selectedTypeIndex is undefined', () => {
......@@ -118,7 +118,7 @@ describe('checkInputIsReference', () => {
label: 'Test',
renderTypeList: [FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.input]
};
expect(checkInputIsReference(input)).toBe(true);
expect(nodeInputIsReference(input)).toBe(true);
});
});
......
......@@ -33,19 +33,57 @@ import { mongoSessionRun } from '../../common/mongo/sessionRun';
import { getLogger, LogCategories } from '../../common/logger';
import { deleteSandboxesByAppId, deleteSandboxesByChatIds } from '../ai/sandbox/service/resource';
import { MongoSystemTool } from '../plugin/tool/systemToolSchema';
import {
SelectedAgentSkillItemTypeSchema,
type AppFormEditFormType
} from '@fastgpt/global/core/app/formEdit/type';
import z from 'zod';
import { nodeInputIsReference } from '@fastgpt/global/core/workflow/utils';
const logger = getLogger(LogCategories.MODULE.APP.FOLDER);
/**
* 在更新应用前,对工作流节点数据进行格式化和安全处理。
* 主要职责:
* 1. 知识库:统一数据结构为 { datasetId: string }[]。
* 2. Skill: 统一数据结构为 { skillId: string }[]。
* 2. 敏感信息(如 Header Secret、密码类型输入、系统工具手动配置的密钥)进行加密存储。
*/
export const beforeUpdateAppFormat = ({ nodes }: { nodes?: StoreNodeItemType[] }) => {
if (!nodes) return;
const StoredSelectedDatasetSchema = z.object({
datasetId: z.string()
});
/**
* 格式化数据集选择值,保存阶段只保留 datasetId,移除编辑态快照字段。
* 引用模式由调用处判断并跳过,避免把 [nodeId, key] 误压缩成空数组。
* 兼容历史单选格式 { datasetId },避免旧应用再次保存时丢失知识库配置。
*/
const formatDatasetSelectValue = (value: unknown) => {
const datasets = z
.union([StoredSelectedDatasetSchema, z.array(StoredSelectedDatasetSchema)])
.parse(value);
const datasetList = Array.isArray(datasets) ? datasets : [datasets];
return datasetList.map(({ datasetId }) => ({ datasetId }));
};
nodes.forEach((node) => {
const isDatasetNode =
node.flowNodeType === FlowNodeTypeEnum.datasetSearchNode ||
node.flowNodeType === FlowNodeTypeEnum.agent;
// Format header secret
node.inputs.forEach((input) => {
if (nodeInputIsReference(input)) return;
// 敏感信息
if (input.key === NodeInputKeyEnum.headerSecret && typeof input.value === 'object') {
input.value = storeSecretValue(input.value);
}
if (input.renderTypeList.includes(FlowNodeInputTypeEnum.password)) {
if (input.renderTypeList?.includes(FlowNodeInputTypeEnum.password)) {
input.value = encryptSecretValue(input.value);
}
if (input.key === NodeInputKeyEnum.systemInputConfig && typeof input.value === 'object') {
......@@ -59,35 +97,31 @@ export const beforeUpdateAppFormat = ({ nodes }: { nodes?: StoreNodeItemType[] }
}
});
}
});
// Format dataset search
if (node.flowNodeType === FlowNodeTypeEnum.datasetSearchNode) {
node.inputs.forEach((input) => {
// 知识库
if (isDatasetNode) {
// Agent
if (input.key === NodeInputKeyEnum.datasetSelectList) {
const val = input.value as undefined | { datasetId: string }[] | { datasetId: string };
if (!val) {
input.value = [];
} else if (Array.isArray(val)) {
// Not rewrite reference value
if (val.length === 2 && val.every((item) => typeof item === 'string')) {
return;
}
input.value = val
.map((dataset: { datasetId: string }) => ({
datasetId: dataset.datasetId
}))
.filter((item) => !!item.datasetId);
} else if (typeof val === 'object' && val !== null) {
input.value = [
{
datasetId: val.datasetId
}
];
input.value = formatDatasetSelectValue(input.value);
}
// workflow
if (input.key === NodeInputKeyEnum.datasetParams) {
const datasetParams = input.value as AppFormEditFormType['dataset'] | undefined;
if (datasetParams?.datasets) {
input.value = {
...datasetParams,
datasets: formatDatasetSelectValue(datasetParams.datasets)
};
}
}
});
}
}
// Skills
if (input.key === NodeInputKeyEnum.skills) {
const skills = z.array(SelectedAgentSkillItemTypeSchema).parse(input.value);
input.value = skills.map(({ isDeleted, description, avatar, ...skill }) => skill);
}
});
});
};
......
import { MongoDataset } from '../dataset/schema';
import { getEmbeddingModel } from '../ai/model';
import { DatasetTypeEnum, DatasetTypeMap } from '@fastgpt/global/core/dataset/constants';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { nodeInputIsReference } from '@fastgpt/global/core/workflow/utils';
import { getChildAppPreviewNode } from './tool/controller';
import { authAppByTmbId } from '../../support/permission/app/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { splitCombineToolId } from '@fastgpt/global/core/app/tool/utils';
import type { localeType } from '@fastgpt/global/common/i18n/type';
import type { SkillToolType } from '@fastgpt/global/core/ai/skill/type';
import type { SelectedAgentSkillItemType } from '@fastgpt/global/core/app/formEdit/type';
import { SkillToolSchema } from '@fastgpt/global/core/ai/skill/type';
import {
SelectedAgentSkillItemTypeSchema,
type AppFormEditFormType,
type SelectedAgentSkillItemType
} from '@fastgpt/global/core/app/formEdit/type';
import { authSkillByTmbId } from '../../support/permission/skill/auth';
import type { SelectedDatasetType } from '@fastgpt/global/core/workflow/type/io';
import z from 'zod';
export async function listAppDatasetDataByTeamIdAndDatasetIds({
teamId,
datasetIdList
}: {
teamId?: string;
datasetIdList: string[];
}) {
const myDatasets = await MongoDataset.find({
_id: { $in: datasetIdList },
...(teamId && { teamId })
}).lean();
return myDatasets.map((item) => ({
datasetId: String(item._id),
avatar: item.avatar,
name: item.name,
vectorModel: getEmbeddingModel(item.vectorModel)
}));
}
/**
* 重写应用工作流节点,填充详细的元数据信息(如工具详情、技能详情、知识库详情)。
*/
export async function rewriteAppWorkflowToDetail({
nodes,
teamId,
......@@ -46,7 +37,9 @@ export async function rewriteAppWorkflowToDetail({
ownerTmbId: string;
lang?: localeType;
}) {
const datasetIdSet = new Set<string>();
type SelectedDatasetSnapshot = Pick<SelectedDatasetType, 'datasetId'> &
Partial<SelectedDatasetType>;
const defaultDeletedDatasetAvatar = DatasetTypeMap[DatasetTypeEnum.dataset].avatar;
const loadToolNode = async ({ id, versionId }: { id: string; versionId?: string }) => {
const { authAppId } = splitCombineToolId(id);
......@@ -63,7 +56,8 @@ export async function rewriteAppWorkflowToDetail({
authAppByTmbId({
tmbId: ownerTmbId,
appId: authAppId,
per: ReadPermissionVal
per: ReadPermissionVal,
isRoot
})
]
: [])
......@@ -80,7 +74,6 @@ export async function rewriteAppWorkflowToDetail({
};
}
};
const loadAgentSkill = async (
selectedSkill: SelectedAgentSkillItemType
): Promise<SelectedAgentSkillItemType> => {
......@@ -106,8 +99,43 @@ export async function rewriteAppWorkflowToDetail({
};
}
};
const formatSelectedDatasetValue = async (
value?: SelectedDatasetSnapshot[] | SelectedDatasetSnapshot
): Promise<SelectedDatasetType[] | undefined> => {
const loadDatasetInfo = async (
snapshot: SelectedDatasetSnapshot
): Promise<SelectedDatasetType> => {
const datasetId = String(snapshot.datasetId);
const dataset = await MongoDataset.findOne({
_id: datasetId,
...(!isRoot && teamId && { teamId })
}).lean();
if (dataset && !dataset.deleteTime) {
return {
datasetId: String(dataset._id),
avatar: dataset.avatar,
name: dataset.name,
vectorModel: getEmbeddingModel(dataset.vectorModel),
isDeleted: false
};
}
// 保存前会压缩成 { datasetId },软删除或物理删除后没有快照时需要补齐合法占位。
return {
datasetId,
avatar: defaultDeletedDatasetAvatar,
name: snapshot.name || '',
vectorModel: snapshot.vectorModel || getEmbeddingModel(),
isDeleted: true
};
};
if (!value) return;
const datasets = Array.isArray(value) ? value : [value];
return Promise.all(datasets.map(loadDatasetInfo));
};
/* Add node(App Type) versionlabel and latest sign ==== */
await Promise.all(
nodes.map(async (node) => {
// Tool node
......@@ -153,6 +181,7 @@ export async function rewriteAppWorkflowToDetail({
return {
...item,
value: input?.value,
renderTypeList: input?.renderTypeList ?? item.renderTypeList,
selectedTypeIndex:
selectedTypeIndex >= 0 &&
(selectedTypeIndex > 0 || input?.selectedTypeIndex !== undefined)
......@@ -176,147 +205,99 @@ export async function rewriteAppWorkflowToDetail({
}
// Agent, parse subapp
if (node.flowNodeType === FlowNodeTypeEnum.agent) {
const tools = (node.inputs.find((item) => item.key === NodeInputKeyEnum.selectedTools)
?.value || []) as SkillToolType[];
const nodes = await Promise.all(
tools.map(async (tool) => {
const result = await loadToolNode({ id: tool.id });
if (result.success) {
const data = result.data!;
// Merge saved config back into inputs
const mergedInputs = data.inputs.map((input) => ({
...input,
value:
tool.config && tool.config[input.key] !== undefined
? tool.config[input.key] // Use saved config value
: input.value // Keep default value
}));
// Tool load
const toolInput = node.inputs.find((item) => item.key === NodeInputKeyEnum.selectedTools);
if (toolInput && !nodeInputIsReference(toolInput)) {
const toolsParse = z.array(SkillToolSchema).safeParse(toolInput?.value || []);
const tools = toolsParse.success ? toolsParse.data : [];
const nodes = await Promise.all(
tools.map(async (tool) => {
const result = await loadToolNode({ id: tool.id });
if (result.success) {
const data = result.data!;
// Merge saved config back into inputs
const mergedInputs = data.inputs.map((input) => ({
...input,
value:
tool.config && tool.config[input.key] !== undefined
? tool.config[input.key] // Use saved config value
: input.value // Keep default value
}));
return {
...data,
inputs: mergedInputs
};
} else {
return {
id: tool.id,
templateType: 'personalTool' as const,
flowNodeType: FlowNodeTypeEnum.tool,
name: 'Invalid',
avatar: '',
intro: '',
showStatus: false,
weight: 0,
isTool: true,
version: 'v1',
inputs: [],
outputs: [],
configStatus: 'invalid' as const,
pluginData: {
error: result.error
}
};
}
})
);
node.inputs.forEach((input) => {
if (input.key === NodeInputKeyEnum.selectedTools) {
input.value = nodes;
}
});
return {
...data,
inputs: mergedInputs
};
} else {
return {
id: tool.id,
templateType: 'personalTool' as const,
flowNodeType: FlowNodeTypeEnum.tool,
name: 'Invalid',
avatar: '',
intro: '',
showStatus: false,
weight: 0,
isTool: true,
version: 'v1',
inputs: [],
outputs: [],
configStatus: 'invalid' as const,
pluginData: {
error: result.error
}
};
}
})
);
toolInput.value = nodes;
}
// Skill load
const skillsInput = node.inputs.find((item) => item.key === NodeInputKeyEnum.skills);
const skills = (
Array.isArray(skillsInput?.value) ? skillsInput!.value : []
) as SelectedAgentSkillItemType[];
if (skillsInput && skills.length > 0) {
skillsInput.value = await Promise.all(skills.map(loadAgentSkill));
if (skillsInput && !nodeInputIsReference(skillsInput)) {
const skillParse = z
.array(SelectedAgentSkillItemTypeSchema)
.safeParse(skillsInput.value || []);
const skills = skillParse.success ? skillParse.data : [];
if (skills.length > 0) {
skillsInput.value = await Promise.all(skills.map(loadAgentSkill));
}
}
}
})
);
// Get all dataset ids from nodes
nodes.forEach((node) => {
if (node.flowNodeType !== FlowNodeTypeEnum.datasetSearchNode) return;
const input = node.inputs.find((item) => item.key === NodeInputKeyEnum.datasetSelectList);
if (!input) return;
const rawValue = input.value as undefined | { datasetId: string }[] | { datasetId: string };
if (!rawValue) return;
const datasetIds = Array.isArray(rawValue)
? rawValue.map((v) => v?.datasetId).filter((id) => !!id && typeof id === 'string')
: rawValue?.datasetId
? [String(rawValue.datasetId)]
: [];
datasetIds.forEach((id) => datasetIdSet.add(id));
});
if (datasetIdSet.size === 0) return;
// Load dataset list
const datasetList = await listAppDatasetDataByTeamIdAndDatasetIds({
teamId: isRoot ? undefined : teamId,
datasetIdList: Array.from(datasetIdSet)
});
const datasetMap = new Map(datasetList.map((ds) => [String(ds.datasetId), ds]));
// Rewrite dataset ids, add dataset info to nodes
if (datasetList.length > 0) {
nodes.forEach((node) => {
if (node.flowNodeType !== FlowNodeTypeEnum.datasetSearchNode) return;
node.inputs.forEach((item) => {
if (item.key !== NodeInputKeyEnum.datasetSelectList) return;
const val = item.value as undefined | { datasetId: string }[] | { datasetId: string };
// Dataset load
if (
node.flowNodeType === FlowNodeTypeEnum.datasetSearchNode ||
node.flowNodeType === FlowNodeTypeEnum.agent
) {
await Promise.all(
node.inputs.map(async (input) => {
if (nodeInputIsReference(input)) return;
// Agent
if (input.key === NodeInputKeyEnum.datasetSelectList) {
const datasets = await formatSelectedDatasetValue(input.value);
if (datasets) {
input.value = datasets;
}
}
// workflow
if (input.key === NodeInputKeyEnum.datasetParams) {
const datasetParams = input.value as AppFormEditFormType['dataset'] | undefined;
if (datasetParams?.datasets) {
const datasets = await formatSelectedDatasetValue(datasetParams.datasets);
if (!datasets) return;
if (Array.isArray(val)) {
item.value = val
.map((v) => {
const data = datasetMap.get(String(v.datasetId));
if (!data)
return {
datasetId: v.datasetId,
avatar: '',
name: 'Dataset not found',
vectorModel: ''
input.value = {
...datasetParams,
datasets
};
return {
datasetId: data.datasetId,
avatar: data.avatar,
name: data.name,
vectorModel: data.vectorModel
};
})
.filter(Boolean);
} else if (typeof val === 'object' && val !== null) {
const data = datasetMap.get(String(val.datasetId));
if (!data) {
item.value = [
{
datasetId: val.datasetId,
avatar: '',
name: 'Dataset not found',
vectorModel: ''
}
];
} else {
item.value = [
{
datasetId: data.datasetId,
avatar: data.avatar,
name: data.name,
vectorModel: data.vectorModel
}
];
}
}
});
});
}
}
})
);
}
})
);
return nodes;
}
......@@ -1162,6 +1162,7 @@
"test_model_tip": "This model is a test model and does not support high concurrency use.",
"textarea_variable_picker_tip": "Enter \"/\" to select a variable",
"to_dataset": "To dataset",
"dataset_deleted": "Dataset has been deleted",
"tool_invalid": "Tool has expired",
"tool_invalid_click_delete_tip": "Click delete",
"total_num": "Total: {{num}}",
......
......@@ -1163,6 +1163,7 @@
"test_model_tip": "该模型为测试模型,不支持高并发使用。",
"textarea_variable_picker_tip": "输入\"/\"可选择变量",
"to_dataset": "前往知识库",
"dataset_deleted": "知识库已被删除",
"tool_invalid": "工具已失效",
"tool_invalid_click_delete_tip": "点击删除",
"total_num": "总数: {{num}}",
......
......@@ -1152,6 +1152,7 @@
"test_model_tip": "此模型為測試模型,不支援高並發使用。",
"textarea_variable_picker_tip": "輸入「/」以選擇變數",
"to_dataset": "前往知識庫",
"dataset_deleted": "知識庫已被刪除",
"tool_invalid": "工具已失效",
"tool_invalid_click_delete_tip": "點擊刪除",
"total_num": "總數: {{num}}",
......
import React from 'react';
import { Box, Flex, type FlexProps } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyIconButton, { MyDeleteIconButton } from '@fastgpt/web/components/common/Icon/button';
import type { SelectedDatasetType } from '@fastgpt/global/core/workflow/type/io';
type DatasetCardProps = {
dataset: SelectedDatasetType;
onDelete?: (datasetId: string) => void;
flexProps?: FlexProps;
};
const formCardShadow = '0 4px 8px -2px rgba(16,24,40,.1),0 2px 4px -2px rgba(16,24,40,.06)';
const cardProps: FlexProps = {
w: '100%',
minW: 0,
maxW: '100%',
p: 2,
bg: 'white',
boxShadow: formCardShadow,
borderRadius: 'md',
border: 'base'
};
/**
* 单个已选知识库卡片,仅消费后端补齐的 isDeleted 状态来展示正常态或删除态。
*/
const DatasetCard = React.memo(function DatasetCard({
dataset,
onDelete,
flexProps
}: DatasetCardProps) {
const { t } = useTranslation();
const router = useRouter();
const isDeleted = !!dataset.isDeleted;
const hasPreviewButton = !isDeleted;
const hasDeleteButton = !!onDelete;
const hasController = hasPreviewButton || hasDeleteButton;
return (
<Flex
overflow={'hidden'}
alignItems={'center'}
userSelect={'none'}
{...cardProps}
{...flexProps}
border={flexProps?.border || cardProps.border}
borderColor={isDeleted ? 'red.600' : flexProps?.borderColor}
_hover={{
...flexProps?._hover,
borderColor: isDeleted ? 'red.600' : 'primary.300',
'& .dataset-card-controller': {
opacity: 1,
pointerEvents: 'auto'
}
}}
>
<Avatar src={dataset.avatar} w={'1.5rem'} borderRadius={'sm'} />
<Box
ml={2}
flex={'1 1 auto'}
w={0}
minW={0}
className={'textEllipsis'}
fontSize={'sm'}
color={isDeleted ? 'red.600' : 'myGray.900'}
>
{isDeleted ? t('common:dataset_deleted') : dataset.name}
</Box>
{hasController && (
<Box
className="dataset-card-controller"
ml={2}
flexShrink={0}
display={'flex'}
alignItems={'center'}
opacity={[1, 0]}
pointerEvents={['auto', 'none']}
>
{hasPreviewButton && (
<MyIconButton
icon={'common/viewLight'}
onClick={(e) => {
e.stopPropagation();
router.push({
pathname: '/dataset/detail',
query: {
datasetId: dataset.datasetId
}
});
}}
/>
)}
{hasDeleteButton && (
<MyDeleteIconButton
onClick={(e) => {
e.stopPropagation();
onDelete?.(dataset.datasetId);
}}
/>
)}
</Box>
)}
</Flex>
);
});
export default DatasetCard;
......@@ -9,9 +9,9 @@ import {
Checkbox,
VStack,
HStack,
IconButton,
Spacer,
useDisclosure
useDisclosure,
IconButton
} from '@chakra-ui/react';
import { ChevronRightIcon, CloseIcon } from '@chakra-ui/icons';
import Avatar from '@fastgpt/web/components/common/Avatar';
......@@ -45,6 +45,12 @@ export const DatasetSelectModal = ({
// Current selected datasets, initialized with defaultSelectedDatasets
const [selectedDatasets, setSelectedDatasets] =
useState<SelectedDatasetType[]>(defaultSelectedDatasets);
// 已删除知识库只在弹窗确认时被写回移除;关闭弹窗不影响外部配置。
const availableSelectedDatasets = useMemo(
() => selectedDatasets.filter((dataset) => !dataset.isDeleted),
[selectedDatasets]
);
const hasDeletedSelectedDatasets = availableSelectedDatasets.length !== selectedDatasets.length;
const { toast } = useToast();
const { userInfo } = useUserStore();
......@@ -61,14 +67,14 @@ export const DatasetSelectModal = ({
} = useDatasetSelect();
// The vector model of the first selected dataset
const activeVectorModel = selectedDatasets[0]?.vectorModel?.model;
const activeVectorModel = availableSelectedDatasets[0]?.vectorModel?.model;
// Check if a dataset is selected
const isDatasetSelected = useCallback(
(datasetId: string) => {
return selectedDatasets.some((dataset) => dataset.datasetId === datasetId);
return availableSelectedDatasets.some((dataset) => dataset.datasetId === datasetId);
},
[selectedDatasets]
[availableSelectedDatasets]
);
// Check if a dataset is disabled (vector model mismatch)
......@@ -98,11 +104,13 @@ export const DatasetSelectModal = ({
return false;
}
const selectedDatasetIds = new Set(selectedDatasets.map((dataset) => dataset.datasetId));
const selectedDatasetIds = new Set(
availableSelectedDatasets.map((dataset) => dataset.datasetId)
);
return compatibleDatasetsByModel.every((item: DatasetListItemType) =>
selectedDatasetIds.has(item._id)
);
}, [compatibleDatasetsByModel, selectedDatasets]);
}, [availableSelectedDatasets, compatibleDatasetsByModel]);
const onSelect = (item: DatasetListItemType, checked: boolean) => {
if (checked) {
......@@ -118,7 +126,8 @@ export const DatasetSelectModal = ({
datasetId: item._id,
avatar: item.avatar,
name: item.name,
vectorModel: item.vectorModel
vectorModel: item.vectorModel,
isDeleted: false
}
]);
} else {
......@@ -343,7 +352,8 @@ export const DatasetSelectModal = ({
datasetId: item._id,
avatar: item.avatar,
name: item.name,
vectorModel: item.vectorModel
vectorModel: item.vectorModel,
isDeleted: false
})
);
setSelectedDatasets((prev) => [...prev, ...newSelections]);
......@@ -373,7 +383,7 @@ export const DatasetSelectModal = ({
<>
{/* Selected count display */}
<Box mb={3} px={4} fontSize="sm" color="myGray.600">
{t('app:Selected')}: {selectedDatasets.length} {t('app:dataset')}
{t('app:Selected')}: {availableSelectedDatasets.length} {t('app:dataset')}
</Box>
{/* Selected dataset list */}
<VStack
......@@ -385,10 +395,10 @@ export const DatasetSelectModal = ({
h={0}
minH={0}
>
{selectedDatasets.length === 0 && !isFetching && (
{availableSelectedDatasets.length === 0 && !isFetching && (
<EmptyTip text={t('app:No_selected_dataset')} />
)}
{selectedDatasets.map((item) => (
{availableSelectedDatasets.map((item) => (
<Flex
key={item.datasetId}
px={2}
......@@ -441,7 +451,7 @@ export const DatasetSelectModal = ({
</Button>
)}
<Spacer />
{isRootEmpty ? (
{isRootEmpty && !hasDeletedSelectedDatasets ? (
<Button
px={3.5}
maxH={8}
......@@ -455,19 +465,21 @@ export const DatasetSelectModal = ({
</Button>
) : (
<HStack spacing={3} align="center">
<Flex
px={3}
py={1.5}
borderRadius={'sm'}
bg={'primary.50'}
alignItems={'center'}
fontSize={'11px'}
color={'primary.600'}
gap={1}
>
<MyIcon name={'common/info'} w={3.5} />
{t('app:dataset.Select_dataset_model_tip')}
</Flex>
{!isRootEmpty && (
<Flex
px={3}
py={1.5}
borderRadius={'sm'}
bg={'primary.50'}
alignItems={'center'}
fontSize={'11px'}
color={'primary.600'}
gap={1}
>
<MyIcon name={'common/info'} w={3.5} />
{t('app:dataset.Select_dataset_model_tip')}
</Flex>
)}
<Button
px={3.5}
maxH={8}
......@@ -475,7 +487,7 @@ export const DatasetSelectModal = ({
onClick={() => {
// Close modal and return selected datasets
onClose();
onChange(selectedDatasets);
onChange(availableSelectedDatasets);
}}
>
{t('common:Confirm')}
......
......@@ -10,7 +10,6 @@ import {
Switch
} from '@chakra-ui/react';
import type { AppFormEditFormType } from '@fastgpt/global/core/app/formEdit/type';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import dynamic from 'next/dynamic';
......@@ -25,7 +24,7 @@ import { getWebLLMModel } from '@/web/common/system/utils';
import ToolSelect from '../FormComponent/ToolSelector/ToolSelect';
import { cardStyles } from '../../constants';
import { SmallAddIcon } from '@chakra-ui/icons';
import MyIconButton, { MyDeleteIconButton } from '@fastgpt/web/components/common/Icon/button';
import MyIconButton from '@fastgpt/web/components/common/Icon/button';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useSkillManager } from './hooks/useSkillManager';
import { AGENT_SANDBOX_TOOLSET_ID, SANDBOX_ICON } from '@fastgpt/global/core/ai/sandbox/tools';
......@@ -36,6 +35,7 @@ import { useUserStore } from '@/web/support/user/useUserStore';
import MyTag from '@fastgpt/web/components/common/Tag/index';
import { useAgentSkillSelect } from './hooks/useAgentSkillSelect';
import { RechargeModal } from '@/components/support/wallet/NotSufficientModal';
import DatasetCard from '@/components/core/app/DatasetCard';
const DatasetSelectModal = dynamic(() => import('@/components/core/app/DatasetSelectModal'));
const DatasetParamsModal = dynamic(() => import('@/components/core/app/DatasetParamsModal'));
......@@ -58,7 +58,6 @@ const EditForm = ({
appForm: AppFormEditFormType;
setAppForm: React.Dispatch<React.SetStateAction<AppFormEditFormType>>;
}) => {
const router = useRouter();
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const { teamPlanStatus } = useUserStore();
......@@ -466,63 +465,21 @@ const EditForm = ({
</Box>
)}
<Grid gridTemplateColumns={'repeat(2, minmax(0, 1fr))'} gridGap={[2, 4]}>
{selectDatasets.map((item) => (
<Flex
key={item.datasetId}
overflow={'hidden'}
alignItems={'center'}
p={2}
bg={'white'}
boxShadow={'0 4px 8px -2px rgba(16,24,40,.1),0 2px 4px -2px rgba(16,24,40,.06)'}
borderRadius={'md'}
border={'base'}
_hover={{
'& .controler': {
display: 'flex'
}
}}
>
<Avatar src={item.avatar} w={'1.5rem'} borderRadius={'sm'} />
<Box
ml={2}
flex={'1 0 0'}
w={0}
className={'textEllipsis'}
fontSize={'sm'}
color={'myGray.900'}
>
{item.name}
</Box>
{/* Icon */}
<Box className="controler" display={['flex', 'none']} alignItems={'center'}>
<MyIconButton
icon={'common/viewLight'}
onClick={() =>
router.push({
pathname: '/dataset/detail',
query: {
datasetId: item.datasetId
}
})
{selectDatasets.map((dataset) => (
<DatasetCard
key={dataset.datasetId}
dataset={dataset}
onDelete={(datasetId) => {
setAppForm((state) => ({
...state,
dataset: {
...state.dataset,
datasets:
state.dataset.datasets?.filter((pre) => pre.datasetId !== datasetId) || []
}
/>
<MyDeleteIconButton
onClick={() => {
setAppForm((state) => ({
...state,
dataset: {
...state.dataset,
datasets:
state.dataset.datasets?.filter(
(pre) => pre.datasetId !== item.datasetId
) || []
}
}));
}}
/>
</Box>
</Flex>
}));
}}
/>
))}
</Grid>
</Box>
......@@ -650,7 +607,8 @@ const EditForm = ({
datasetId: item.datasetId,
name: item.name,
avatar: item.avatar,
vectorModel: item.vectorModel
vectorModel: item.vectorModel,
isDeleted: item.isDeleted
}))}
onClose={onCloseKbSelect}
onChange={(e) => {
......
......@@ -10,11 +10,9 @@ import {
Switch
} from '@chakra-ui/react';
import type { AppFormEditFormType } from '@fastgpt/global/core/app/formEdit/type';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import dynamic from 'next/dynamic';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyIcon from '@fastgpt/web/components/common/Icon';
import VariableEdit from '@/components/core/app/VariableEdit';
import PromptEditor from '@fastgpt/web/components/common/Textarea/PromptEditor';
......@@ -32,12 +30,12 @@ import { getWebLLMModel } from '@/web/common/system/utils';
import ToolSelect from '../FormComponent/ToolSelector/ToolSelect';
import OptimizerPopover from '@/components/common/PromptEditor/OptimizerPopover';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import MyIconButton, { MyDeleteIconButton } from '@fastgpt/web/components/common/Icon/button';
import { SmallAddIcon } from '@chakra-ui/icons';
import { SANDBOX_ICON } from '@fastgpt/global/core/ai/sandbox/tools';
import SandboxTipTag from '../../components/SandboxTipTag';
import SandboxNotSupportTip from '../../components/SandboxNotSupportTip';
import { useUserStore } from '@/web/support/user/useUserStore';
import DatasetCard from '@/components/core/app/DatasetCard';
const DatasetSelectModal = dynamic(() => import('@/components/core/app/DatasetSelectModal'));
const DatasetParamsModal = dynamic(() => import('@/components/core/app/DatasetParamsModal'));
......@@ -69,7 +67,6 @@ const EditForm = ({
appForm: AppFormEditFormType;
setAppForm: React.Dispatch<React.SetStateAction<AppFormEditFormType>>;
}) => {
const router = useRouter();
const { t } = useTranslation();
const { defaultModels, feConfigs } = useSystemStore();
const showSandbox = feConfigs.show_agent_sandbox;
......@@ -360,63 +357,21 @@ const EditForm = ({
</Box>
)}
<Grid gridTemplateColumns={'repeat(2, minmax(0, 1fr))'} gridGap={[2, 4]}>
{selectDatasets.map((item) => (
<Flex
key={item.datasetId}
overflow={'hidden'}
alignItems={'center'}
p={2}
bg={'white'}
boxShadow={'0 4px 8px -2px rgba(16,24,40,.1),0 2px 4px -2px rgba(16,24,40,.06)'}
borderRadius={'md'}
border={'base'}
_hover={{
'& .controler': {
display: 'flex'
}
}}
>
<Avatar src={item.avatar} w={'1.5rem'} borderRadius={'sm'} />
<Box
ml={2}
flex={'1 0 0'}
w={0}
className={'textEllipsis'}
fontSize={'sm'}
color={'myGray.900'}
>
{item.name}
</Box>
{/* Icon */}
<Box className="controler" display={['flex', 'none']} alignItems={'center'}>
<MyIconButton
icon={'common/viewLight'}
onClick={() =>
router.push({
pathname: '/dataset/detail',
query: {
datasetId: item.datasetId
}
})
{selectDatasets.map((dataset) => (
<DatasetCard
key={dataset.datasetId}
dataset={dataset}
onDelete={(datasetId) => {
setAppForm((state) => ({
...state,
dataset: {
...state.dataset,
datasets:
state.dataset.datasets?.filter((pre) => pre.datasetId !== datasetId) || []
}
/>
<MyDeleteIconButton
onClick={() => {
setAppForm((state) => ({
...state,
dataset: {
...state.dataset,
datasets:
state.dataset.datasets?.filter(
(pre) => pre.datasetId !== item.datasetId
) || []
}
}));
}}
/>
</Box>
</Flex>
}));
}}
/>
))}
</Grid>
</Box>
......@@ -543,7 +498,8 @@ const EditForm = ({
datasetId: item.datasetId,
name: item.name,
avatar: item.avatar,
vectorModel: item.vectorModel
vectorModel: item.vectorModel,
isDeleted: item.isDeleted
}))}
onClose={onCloseDatasetSelect}
onChange={(e) => {
......
......@@ -14,7 +14,7 @@ import dynamic from 'next/dynamic';
import { Box, Button, Flex } from '@chakra-ui/react';
import { type FieldErrors, useForm } from 'react-hook-form';
import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import { checkInputIsReference } from '@fastgpt/global/core/workflow/utils';
import { nodeInputIsReference } from '@fastgpt/global/core/workflow/utils';
import { useContextSelector } from 'use-context-selector';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { AppContext } from '../../../context';
......@@ -153,7 +153,7 @@ export const useDebug = () => {
// BUG: 工具调用的情况下,无法填写非必填
const renderInputs = runtimeNode.inputs.filter((input) => {
if (runtimeNode.flowNodeType === FlowNodeTypeEnum.pluginInput) return true;
if (checkInputIsReference(input)) return true;
if (nodeInputIsReference(input)) return true;
if (!input.value) return true;
});
......
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import { checkInputIsReference } from '@fastgpt/global/core/workflow/utils';
import { nodeInputIsReference } from '@fastgpt/global/core/workflow/utils';
import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
const primitiveValueTypes = new Set<WorkflowIOValueTypeEnum>([
......@@ -9,7 +9,7 @@ const primitiveValueTypes = new Set<WorkflowIOValueTypeEnum>([
]);
export const getDebugInputFormValue = (input: FlowNodeInputItemType) => {
if (checkInputIsReference(input)) return undefined;
if (nodeInputIsReference(input)) return undefined;
const value = input.value ?? input.defaultValue;
if (typeof value === 'object' && value !== null) {
......
......@@ -46,6 +46,7 @@ import SandboxTipTag from '@/pageComponents/app/detail/components/SandboxTipTag'
import { RechargeModal } from '@/components/support/wallet/NotSufficientModal';
import { useToast } from '@fastgpt/web/hooks/useToast';
import MyTag from '@fastgpt/web/components/common/Tag/index';
import DatasetCard from '@/components/core/app/DatasetCard';
const PromptEditor = dynamic(() => import('@fastgpt/web/components/common/Textarea/PromptEditor'));
const SkillSelectModal = dynamic(
......@@ -867,28 +868,7 @@ const NodeAgent = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
{t('common:Choose')}
</Button>
{selectedDatasets.map((dataset) => (
<Flex
key={dataset.datasetId}
alignItems={'center'}
h={10}
boxShadow={'sm'}
bg={'white'}
border={'base'}
px={2}
borderRadius={'md'}
>
<Avatar src={dataset.avatar} w={'18px'} borderRadius={'xs'} />
<Box
ml={1.5}
flex={'1 0 0'}
w={0}
className="textEllipsis"
fontWeight={'bold'}
fontSize={['sm', 'sm']}
>
{dataset.name}
</Box>
</Flex>
<DatasetCard key={dataset.datasetId} dataset={dataset} />
))}
</Grid>
{isOpenDatasetSelect && (
......@@ -897,7 +877,8 @@ const NodeAgent = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
datasetId: d.datasetId,
name: d.name,
avatar: d.avatar,
vectorModel: d.vectorModel
vectorModel: d.vectorModel,
isDeleted: d.isDeleted
}))}
onChange={(e) => {
if (!datasetSelectInput) return;
......
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { RenderInputProps } from '../type';
import { Box, Button, Flex, Grid, Switch, useDisclosure } from '@chakra-ui/react';
import { type SelectedDatasetType } from '@fastgpt/global/core/workflow/type/io';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { useTranslation } from 'next-i18next';
import { DatasetSearchModeEnum } from '@fastgpt/global/core/dataset/constants';
import dynamic from 'next/dynamic';
......@@ -11,6 +10,7 @@ import { useContextSelector } from 'use-context-selector';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { WorkflowActionsContext } from '@/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext';
import DatasetCard from '@/components/core/app/DatasetCard';
const DatasetSelectModal = dynamic(() => import('@/components/core/app/DatasetSelectModal'));
......@@ -52,6 +52,21 @@ export const SelectDatasetRender = React.memo(function SelectDatasetRender({
});
}, [inputs]);
const onDeleteDataset = useCallback(
(datasetId: string) => {
onChangeNode({
nodeId,
key: item.key,
type: 'updateInput',
value: {
...item,
value: selectedDatasets.filter((dataset) => dataset.datasetId !== datasetId)
}
});
},
[item, nodeId, onChangeNode, selectedDatasets]
);
const Render = useMemo(() => {
return (
<>
......@@ -68,29 +83,8 @@ export const SelectDatasetRender = React.memo(function SelectDatasetRender({
>
{t('common:Choose')}
</Button>
{selectedDatasets.map((item) => (
<Flex
key={item.datasetId}
alignItems={'center'}
h={10}
boxShadow={'sm'}
bg={'white'}
border={'base'}
px={2}
borderRadius={'md'}
>
<Avatar src={item.avatar} w={'18px'} borderRadius={'xs'} />
<Box
ml={1.5}
flex={'1 0 0'}
w={0}
className="textEllipsis"
fontWeight={'bold'}
fontSize={['sm', 'sm']}
>
{item.name}
</Box>
</Flex>
{selectedDatasets.map((dataset) => (
<DatasetCard key={dataset.datasetId} dataset={dataset} onDelete={onDeleteDataset} />
))}
</Grid>
{isOpenDatasetSelect && (
......@@ -99,7 +93,8 @@ export const SelectDatasetRender = React.memo(function SelectDatasetRender({
datasetId: item.datasetId,
name: item.name,
avatar: item.avatar,
vectorModel: item.vectorModel
vectorModel: item.vectorModel,
isDeleted: item.isDeleted
}))}
onChange={(e) => {
onChangeNode({
......@@ -124,6 +119,7 @@ export const SelectDatasetRender = React.memo(function SelectDatasetRender({
onChangeNode,
onCloseDatasetSelect,
onOpenDatasetSelect,
onDeleteDataset,
selectedDatasets,
t
]);
......
......@@ -10,7 +10,6 @@ import {
type FlowNodeItemType,
type StoreNodeItemType
} from '@fastgpt/global/core/workflow/type/node';
import type { SelectedAgentSkillItemType } from '@fastgpt/global/core/app/formEdit/type';
import { type TFunction } from 'i18next';
import { type Edge, type Node } from 'reactflow';
......@@ -21,26 +20,6 @@ export const uiWorkflow2StoreWorkflow = ({
nodes: Node<FlowNodeItemType, string | undefined>[];
edges: Edge<any>[];
}) => {
const formatInputs = (inputs: StoreNodeItemType['inputs']) =>
inputs.map((input) => {
if (input.key !== NodeInputKeyEnum.skills || !Array.isArray(input.value)) {
return input;
}
// isDeleted 只用于编辑页提示,保存时不能写回应用配置。
return {
...input,
value: (input.value as SelectedAgentSkillItemType[]).map(
({ skillId, name, description, avatar }) => ({
skillId,
name,
description,
...(avatar === undefined ? {} : { avatar })
})
)
};
});
const formatNodes: StoreNodeItemType[] = nodes.map((item) => ({
nodeId: item.data.nodeId,
parentNodeId: item.data.parentNodeId,
......@@ -52,7 +31,7 @@ export const uiWorkflow2StoreWorkflow = ({
showStatus: item.data.showStatus,
position: item.position,
version: item.data.version,
inputs: formatInputs(item.data.inputs),
inputs: item.data.inputs,
outputs: item.data.outputs,
isFolded: item.data.isFolded,
pluginId: item.data.pluginId,
......
......@@ -34,7 +34,10 @@ import { removeUnauthModels } from '@fastgpt/global/core/workflow/utils';
import { getS3AvatarSource } from '@fastgpt/service/common/s3/sources/avatar';
import { isS3ObjectKey } from '@fastgpt/service/common/s3/utils';
import { MongoAppTemplate } from '@fastgpt/service/core/app/templates/templateSchema';
import { updateParentFoldersUpdateTime } from '@fastgpt/service/core/app/controller';
import {
beforeUpdateAppFormat,
updateParentFoldersUpdateTime
} from '@fastgpt/service/core/app/controller';
import { copyAvatarImage } from '@fastgpt/service/common/file/image/controller';
import { extractAppResourceRefsFromNodes } from '@fastgpt/service/core/app/resourceRefs';
......@@ -156,6 +159,10 @@ export const onCreateApp = async ({
}
}
beforeUpdateAppFormat({
nodes: modules
});
const create = async (session: ClientSession) => {
const resourceRefs = extractAppResourceRefsFromNodes(modules);
const _avatar = await (async () => {
......
......@@ -82,9 +82,9 @@ describe('POST /api/core/ai/skill/list', () => {
}
]);
const createSkillNode = (skillId: string): StoreNodeItemType =>
const createSkillNode = (skill: { _id: unknown; name: string }): StoreNodeItemType =>
({
nodeId: `node-${skillId}`,
nodeId: `node-${String(skill._id)}`,
name: 'Agent',
flowNodeType: FlowNodeTypeEnum.agent,
inputs: [
......@@ -93,7 +93,12 @@ describe('POST /api/core/ai/skill/list', () => {
label: 'Skills',
renderTypeList: [FlowNodeInputTypeEnum.selectSkill],
valueType: WorkflowIOValueTypeEnum.arrayObject,
value: [{ skillId }]
value: [
{
skillId: String(skill._id),
name: skill.name
}
]
}
],
outputs: []
......@@ -102,7 +107,7 @@ describe('POST /api/core/ai/skill/list', () => {
const appId = await onCreateApp({
name: 'Skill Ref App',
type: AppTypeEnum.chatAgent,
modules: [createSkillNode(String(publishedSkill._id))],
modules: [createSkillNode(publishedSkill)],
edges: [],
chatConfig: {},
teamId: user.teamId,
......@@ -116,7 +121,7 @@ describe('POST /api/core/ai/skill/list', () => {
auth: user,
query: { appId },
body: {
nodes: [createSkillNode(String(draftSkill._id))],
nodes: [createSkillNode(draftSkill)],
edges: [],
chatConfig: {},
isPublish: false,
......@@ -149,7 +154,7 @@ describe('POST /api/core/ai/skill/list', () => {
auth: user,
query: { appId },
body: {
nodes: [createSkillNode(String(draftSkill._id))],
nodes: [createSkillNode(draftSkill)],
edges: [],
chatConfig: {},
isPublish: true,
......
......@@ -4,7 +4,10 @@ import {
filterExportModules,
getEditorVariables
} from '@/pageComponents/app/detail/WorkflowComponents/utils';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import {
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import type { AppDetailType } from '@fastgpt/global/core/app/type';
......@@ -216,7 +219,7 @@ describe('WorkflowComponents utils', () => {
]);
});
it('should strip skill deleted marker when saving workflow', () => {
it('should keep selected skill snapshot for later server-side save formatting', () => {
const nodes = [
{
data: {
......@@ -256,7 +259,8 @@ describe('WorkflowComponents utils', () => {
{
skillId: 'skill-1',
name: 'Deleted Skill',
description: ''
description: '',
isDeleted: true
},
{
skillId: 'skill-2',
......@@ -265,6 +269,91 @@ describe('WorkflowComponents utils', () => {
}
]);
});
it('should keep dataset reference value when saving workflow', () => {
const referenceValue = ['sourceNode', 'datasets'];
const nodes = [
{
data: {
nodeId: 'datasetNode',
name: 'Dataset Search',
intro: '',
avatar: '',
flowNodeType: FlowNodeTypeEnum.datasetSearchNode,
showStatus: true,
inputs: [
{
key: NodeInputKeyEnum.datasetSelectList,
renderTypeList: [
FlowNodeInputTypeEnum.selectDataset,
FlowNodeInputTypeEnum.reference
],
selectedTypeIndex: 1,
value: referenceValue
}
],
outputs: []
},
position: { x: 0, y: 0 }
}
];
const result = uiWorkflow2StoreWorkflow({ nodes, edges: [] });
expect(result.nodes[0].inputs[0].value).toEqual(referenceValue);
});
it('should keep selected dataset snapshot for later server-side save formatting', () => {
const nodes = [
{
data: {
nodeId: 'datasetNode',
name: 'Dataset Search',
intro: '',
avatar: '',
flowNodeType: FlowNodeTypeEnum.datasetSearchNode,
showStatus: true,
inputs: [
{
key: NodeInputKeyEnum.datasetSelectList,
renderTypeList: [
FlowNodeInputTypeEnum.selectDataset,
FlowNodeInputTypeEnum.reference
],
selectedTypeIndex: 0,
value: [
{
datasetId: 'dataset-1',
avatar: 'avatar.png',
name: 'Deleted Dataset',
vectorModel: {
model: 'text-embedding'
},
isDeleted: true
}
]
}
],
outputs: []
},
position: { x: 0, y: 0 }
}
];
const result = uiWorkflow2StoreWorkflow({ nodes, edges: [] });
expect(result.nodes[0].inputs[0].value).toEqual([
{
datasetId: 'dataset-1',
avatar: 'avatar.png',
name: 'Deleted Dataset',
vectorModel: {
model: 'text-embedding'
},
isDeleted: true
}
]);
});
});
describe('filterExportModules', () => {
......
import { describe, expect, it } from 'vitest';
import { beforeUpdateAppFormat } from '@fastgpt/service/core/app/controller';
import {
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
describe('beforeUpdateAppFormat', () => {
it('保存前统一压缩知识库选择项,去掉编辑态删除标记和快照字段', () => {
const nodes = [
{
flowNodeType: FlowNodeTypeEnum.datasetSearchNode,
inputs: [
{
key: NodeInputKeyEnum.datasetSelectList,
renderTypeList: [FlowNodeInputTypeEnum.selectDataset, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 0,
value: [
{
datasetId: 'dataset-1',
avatar: 'avatar.png',
name: 'Deleted Dataset',
vectorModel: {
model: 'text-embedding'
},
isDeleted: true
}
]
}
]
} as StoreNodeItemType
];
beforeUpdateAppFormat({ nodes });
expect(nodes[0].inputs[0].value).toEqual([
{
datasetId: 'dataset-1'
}
]);
});
it('保存前兼容旧版单对象知识库选择项', () => {
const nodes = [
{
flowNodeType: FlowNodeTypeEnum.datasetSearchNode,
inputs: [
{
key: NodeInputKeyEnum.datasetSelectList,
renderTypeList: [FlowNodeInputTypeEnum.selectDataset, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 0,
value: {
datasetId: 'dataset-legacy',
avatar: 'avatar.png',
name: 'Legacy Dataset',
vectorModel: {
model: 'text-embedding'
}
}
}
]
} as StoreNodeItemType
];
beforeUpdateAppFormat({ nodes });
expect(nodes[0].inputs[0].value).toEqual([
{
datasetId: 'dataset-legacy'
}
]);
});
it('保存前兼容已压缩的知识库选择项数组', () => {
const nodes = [
{
flowNodeType: FlowNodeTypeEnum.datasetSearchNode,
inputs: [
{
key: NodeInputKeyEnum.datasetSelectList,
renderTypeList: [FlowNodeInputTypeEnum.selectDataset, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 0,
value: [
{
datasetId: 'dataset-1'
},
{
datasetId: 'dataset-2'
}
]
}
]
} as StoreNodeItemType
];
beforeUpdateAppFormat({ nodes });
expect(nodes[0].inputs[0].value).toEqual([
{
datasetId: 'dataset-1'
},
{
datasetId: 'dataset-2'
}
]);
});
it('保存前统一压缩 Agent datasetParams 中的知识库选择项', () => {
const nodes = [
{
flowNodeType: FlowNodeTypeEnum.agent,
inputs: [
{
key: NodeInputKeyEnum.datasetParams,
value: {
datasets: [
{
datasetId: 'dataset-1',
avatar: 'avatar.png',
name: 'Deleted Dataset',
vectorModel: {
model: 'text-embedding'
},
isDeleted: true
}
],
similarity: 0.5,
limit: 5
}
}
]
} as StoreNodeItemType
];
beforeUpdateAppFormat({ nodes });
expect(nodes[0].inputs[0].value).toMatchObject({
datasets: [
{
datasetId: 'dataset-1'
}
],
similarity: 0.5,
limit: 5
});
});
it('保存前保留知识库选择输入的引用模式值', () => {
const referenceValue = ['sourceNode', 'datasets'];
const nodes = [
{
flowNodeType: FlowNodeTypeEnum.datasetSearchNode,
inputs: [
{
key: NodeInputKeyEnum.datasetSelectList,
renderTypeList: [FlowNodeInputTypeEnum.selectDataset, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 1,
value: referenceValue
}
]
} as StoreNodeItemType
];
beforeUpdateAppFormat({ nodes });
expect(nodes[0].inputs[0].value).toBe(referenceValue);
});
it('保存前遇到非法知识库选择项时抛错,避免清空后继续保存', () => {
const nodes = [
{
flowNodeType: FlowNodeTypeEnum.datasetSearchNode,
inputs: [
{
key: NodeInputKeyEnum.datasetSelectList,
renderTypeList: [FlowNodeInputTypeEnum.selectDataset, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 0,
value: [
{
name: 'Invalid Dataset'
}
]
}
]
} as StoreNodeItemType
];
expect(() => beforeUpdateAppFormat({ nodes })).toThrow();
});
it('保存前移除 Agent Skill 的编辑态删除标记和展示快照字段', () => {
const nodes = [
{
flowNodeType: FlowNodeTypeEnum.agent,
inputs: [
{
key: NodeInputKeyEnum.skills,
renderTypeList: [FlowNodeInputTypeEnum.selectSkill, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 0,
value: [
{
skillId: 'skill-1',
name: 'Deleted Skill',
description: 'Snapshot description',
avatar: 'skill-avatar.png',
isDeleted: true
},
{
skillId: 'skill-2',
name: 'Normal Skill',
description: '',
isDeleted: false
}
]
}
]
} as StoreNodeItemType
];
beforeUpdateAppFormat({ nodes });
expect(nodes[0].inputs[0].value).toEqual([
{
skillId: 'skill-1',
name: 'Deleted Skill'
},
{
skillId: 'skill-2',
name: 'Normal Skill'
}
]);
});
});
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MongoAgentSkills } from '@fastgpt/service/core/ai/skill/model/schema';
import { MongoDataset } from '@fastgpt/service/core/dataset/schema';
import { getEmbeddingModel } from '@fastgpt/service/core/ai/model';
import { AgentSkillSourceEnum, AgentSkillTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import { DatasetTypeEnum, DatasetTypeMap } from '@fastgpt/global/core/dataset/constants';
import {
FlowNodeInputTypeEnum,
FlowNodeOutputTypeEnum,
......@@ -8,7 +11,10 @@ import {
} from '@fastgpt/global/core/workflow/node/constant';
import { NodeInputKeyEnum, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import type { SelectedAgentSkillItemType } from '@fastgpt/global/core/app/formEdit/type';
import type {
AppFormEditFormType,
SelectedAgentSkillItemType
} from '@fastgpt/global/core/app/formEdit/type';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { getUser } from '@test/datas/users';
......@@ -190,4 +196,356 @@ describe('rewriteAppWorkflowToDetail - agent skills', () => {
]
});
});
it('保留 Agent 工具和 Skill 输入的引用模式值,不按选择列表重写', async () => {
const toolReferenceValue = ['source-node', 'tools'];
const skillReferenceValue = ['source-node', 'skills'];
const toolInput = {
key: NodeInputKeyEnum.selectedTools,
renderTypeList: [FlowNodeInputTypeEnum.selectTool, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 1,
value: toolReferenceValue
};
const skillsInput = {
key: NodeInputKeyEnum.skills,
renderTypeList: [FlowNodeInputTypeEnum.selectSkill, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 1,
value: skillReferenceValue
};
const nodes = [
{
nodeId: 'agent',
flowNodeType: FlowNodeTypeEnum.agent,
inputs: [toolInput, skillsInput],
outputs: []
} as StoreNodeItemType
];
await rewriteAppWorkflowToDetail({
nodes,
teamId: 'team-1',
ownerTmbId: 'tmb-1',
isRoot: false
});
expect(toolInput.value).toEqual(toolReferenceValue);
expect(skillsInput.value).toEqual(skillReferenceValue);
expect(getChildAppPreviewNodeMock).not.toHaveBeenCalled();
});
it('校验嵌套工具权限时透传 root 身份', async () => {
const toolAppId = '507f1f77bcf86cd799439011';
getChildAppPreviewNodeMock.mockResolvedValue({
id: toolAppId,
flowNodeType: FlowNodeTypeEnum.tool,
name: 'Personal Tool',
avatar: '',
intro: '',
inputs: [],
outputs: [],
version: 'v1'
});
authAppByTmbIdMock.mockResolvedValue({});
const nodes = [
{
nodeId: 'agent',
flowNodeType: FlowNodeTypeEnum.agent,
inputs: [
{
key: NodeInputKeyEnum.selectedTools,
value: [
{
id: toolAppId,
config: {}
}
]
}
],
outputs: []
} as StoreNodeItemType
];
await rewriteAppWorkflowToDetail({
nodes,
teamId: 'team-1',
ownerTmbId: 'tmb-1',
isRoot: true
});
expect(authAppByTmbIdMock).toHaveBeenCalledWith(
expect.objectContaining({
tmbId: 'tmb-1',
appId: toolAppId,
isRoot: true
})
);
});
it('保留 Agent 知识库选择输入的引用模式值,不按知识库列表重写', async () => {
const user = await getUser(`agent-dataset-reference-${getNanoid(6)}`);
const dataset = await MongoDataset.create({
name: 'Reference Trigger Dataset',
teamId: user.teamId,
tmbId: user.tmbId
});
const referenceValue = ['source-node', 'datasets'];
const datasetSelectInput = {
key: NodeInputKeyEnum.datasetSelectList,
renderTypeList: [FlowNodeInputTypeEnum.selectDataset, FlowNodeInputTypeEnum.reference],
selectedTypeIndex: 1,
value: referenceValue
};
const nodes = [
{
nodeId: 'agent',
flowNodeType: FlowNodeTypeEnum.agent,
inputs: [
{
key: NodeInputKeyEnum.selectedTools,
value: []
},
datasetSelectInput,
{
key: NodeInputKeyEnum.datasetParams,
value: {
datasets: [
{
datasetId: String(dataset._id),
avatar: 'old-avatar',
name: 'Old Name',
vectorModel: {
model: 'old-model'
}
}
]
}
}
],
outputs: []
} as StoreNodeItemType
];
await rewriteAppWorkflowToDetail({
nodes,
teamId: user.teamId,
ownerTmbId: user.tmbId,
isRoot: false
});
expect(datasetSelectInput.value).toEqual(referenceValue);
});
it('刷新 ChatAgent 的知识库参数快照信息', async () => {
const user = await getUser(`agent-dataset-params-${getNanoid(6)}`);
const dataset = await MongoDataset.create({
name: 'Current Dataset Name',
avatar: '/icon/current-dataset.svg',
vectorModel: 'text-embedding-3-small',
teamId: user.teamId,
tmbId: user.tmbId
});
const datasetParamsInput = {
key: NodeInputKeyEnum.datasetParams,
value: {
datasets: [
{
datasetId: String(dataset._id),
avatar: 'old-avatar',
name: 'Old Dataset Name',
vectorModel: {
model: 'old-model'
}
}
]
}
};
const nodes = [
{
nodeId: 'agent',
flowNodeType: FlowNodeTypeEnum.agent,
inputs: [
{
key: NodeInputKeyEnum.selectedTools,
value: []
},
datasetParamsInput
],
outputs: []
} as StoreNodeItemType
];
await rewriteAppWorkflowToDetail({
nodes,
teamId: user.teamId,
ownerTmbId: user.tmbId,
isRoot: false
});
const rewrittenDatasetParams = datasetParamsInput.value as AppFormEditFormType['dataset'];
expect(rewrittenDatasetParams.datasets).toEqual([
{
datasetId: String(dataset._id),
name: 'Current Dataset Name',
avatar: '/icon/current-dataset.svg',
vectorModel: getEmbeddingModel('text-embedding-3-small'),
isDeleted: false
}
]);
});
it('兼容旧版单对象知识库选择项并补齐详情快照', async () => {
const user = await getUser(`legacy-single-dataset-detail-${getNanoid(6)}`);
const dataset = await MongoDataset.create({
name: 'Legacy Dataset Name',
avatar: '/icon/legacy-dataset.svg',
vectorModel: 'text-embedding-3-small',
teamId: user.teamId,
tmbId: user.tmbId
});
const datasetSelectInput = {
key: NodeInputKeyEnum.datasetSelectList,
value: {
datasetId: String(dataset._id)
}
};
const nodes = [
{
nodeId: 'dataset-search',
flowNodeType: FlowNodeTypeEnum.datasetSearchNode,
inputs: [datasetSelectInput],
outputs: []
} as StoreNodeItemType
];
await rewriteAppWorkflowToDetail({
nodes,
teamId: user.teamId,
ownerTmbId: user.tmbId,
isRoot: false
});
expect(datasetSelectInput.value).toEqual([
{
datasetId: String(dataset._id),
name: 'Legacy Dataset Name',
avatar: '/icon/legacy-dataset.svg',
vectorModel: getEmbeddingModel('text-embedding-3-small'),
isDeleted: false
}
]);
});
it('已删除知识库在 app detail 改写阶段使用通用知识库默认头像', async () => {
const user = await getUser(`deleted-dataset-detail-${getNanoid(6)}`);
const deletedDataset = await MongoDataset.create({
teamId: user.teamId,
tmbId: user.tmbId,
type: DatasetTypeEnum.dataset,
name: 'Deleted Dataset',
avatar: '/icon/logo.svg',
vectorModel: 'text-embedding-3-small',
agentModel: 'gpt-4o-mini',
deleteTime: new Date()
});
const deletedDatasetId = String(deletedDataset._id);
const datasetSelectInput = {
key: NodeInputKeyEnum.datasetSelectList,
value: [
{
datasetId: deletedDatasetId,
name: 'Deleted Dataset Snapshot',
avatar: '/icon/logo.svg',
vectorModel: {
model: 'text-embedding-3-small'
}
}
]
};
const nodes = [
{
nodeId: 'agent',
flowNodeType: FlowNodeTypeEnum.agent,
inputs: [
{
key: NodeInputKeyEnum.selectedTools,
value: []
},
datasetSelectInput
],
outputs: []
} as StoreNodeItemType
];
await rewriteAppWorkflowToDetail({
nodes,
teamId: user.teamId,
ownerTmbId: user.tmbId,
isRoot: false
});
expect(datasetSelectInput.value).toEqual([
{
datasetId: deletedDatasetId,
name: 'Deleted Dataset Snapshot',
avatar: DatasetTypeMap[DatasetTypeEnum.dataset].avatar,
vectorModel: {
model: 'text-embedding-3-small'
},
isDeleted: true
}
]);
});
it('缺失知识库在 app detail 改写阶段保留合法快照并标记删除态', async () => {
const user = await getUser(`missing-dataset-detail-${getNanoid(6)}`);
const missingDataset = await MongoDataset.create({
name: 'Missing Dataset',
teamId: user.teamId,
tmbId: user.tmbId
});
const missingDatasetId = String(missingDataset._id);
await MongoDataset.deleteOne({ _id: missingDataset._id });
const datasetSelectInput = {
key: NodeInputKeyEnum.datasetSelectList,
value: [
{
datasetId: missingDatasetId,
name: 'Missing Dataset Snapshot',
avatar: '/icon/snapshot.svg',
vectorModel: {
model: 'text-embedding-3-small'
}
}
]
};
const nodes = [
{
nodeId: 'dataset-search',
flowNodeType: FlowNodeTypeEnum.datasetSearchNode,
inputs: [datasetSelectInput],
outputs: []
} as StoreNodeItemType
];
await rewriteAppWorkflowToDetail({
nodes,
teamId: user.teamId,
ownerTmbId: user.tmbId,
isRoot: false
});
expect(datasetSelectInput.value).toEqual([
{
datasetId: missingDatasetId,
name: 'Missing Dataset Snapshot',
avatar: DatasetTypeMap[DatasetTypeEnum.dataset].avatar,
vectorModel: {
model: 'text-embedding-3-small'
},
isDeleted: true
}
]);
});
});
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