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);
}
});
});
};
......
......@@ -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'
}
]);
});
});
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