Commit 01732466 by DigHuang Committed by GitHub

feat(skill): optimize skill first-use UX and cross-tab associate flow (#7421)

* feat(skill): optimize skill first-use UX and cross-tab associate flow

Add dashboard empty-state guidance, skill selector create redirect,
and cross-tab associate bridge for Agent skill binding after creation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(skill): normalize root parentId to null and simplify modal/list flow

- Root directory is now uniformly `null`; collapse fetchParentId/parentId in
  useSkillSelectData and drop `?? ''` in skill context (server treats falsy
  parentId as root, so behavior is unchanged).
- Create/Import modals no longer swallow onSuccess errors with a toast;
  callers handle exceptions and list refresh becomes best-effort.
- Drop redundant redirectToDetail in CreateSkillModal; openDetailInNewTab
  fully controls new-tab vs current-page navigation.
- Extract passesSandboxGuard helper in skill List.
- Dedupe the shared glow-ellipse/filter geometry in SkillEmptyActionCard.

refactor(skill): remove unused skill association logic and clean up related components

chore(skill): update submodule and remove unused SVG assets

- Updated submodule reference in `pro`.
- Deleted unused SVG files related to skill selection and import functionality, including `createCardBgDefault.svg`, `createCardBgHover.svg`, `importCardBgDefault.svg`, `importCardBgHover.svg`, and `skillSelectEmptyIcon.svg`.
- Refactored `SkillSelectModal` and related components to streamline button actions and improve code clarity.

refactor(skill): simplify EmptyTip component and update SkillSelectModal logic

* feat(skill): implement skill import size validation and enhance user feedback

* refactor(skill): simplify empty card glow effect and import size check

---------

Co-authored-by: siigure <siiigureouo@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
parent 800a963c
......@@ -59,7 +59,7 @@ export type ListSkillsResponse = z.infer<typeof ListSkillsResponseSchema>;
export const CreateSkillBodySchema = z.object({
parentId: NullableParentIdSchema,
name: z.string().describe('技能名称'),
name: z.string().trim().min(1).describe('技能名称'),
description: z.string().optional().describe('技能描述'),
category: z.array(AgentSkillCategorySchema).optional().describe('技能分类'),
avatar: z.string().optional().describe('技能头像')
......
......@@ -6,14 +6,15 @@ import { useTranslation } from 'next-i18next';
type Props = FlexProps & {
text?: string | React.ReactNode;
iconSize?: string | number;
textGap?: string | number;
};
const EmptyTip = ({ text, iconSize = '48px', ...props }: Props) => {
const EmptyTip = ({ text, iconSize = '48px', textGap = 2, ...props }: Props) => {
const { t } = useTranslation();
return (
<Flex mt={5} flexDirection={'column'} alignItems={'center'} py={'10vh'} {...props}>
<MyIcon name="empty" w={iconSize} h={iconSize} color={'transparent'} />
<Box mt={2} color={'myGray.500'} fontSize={'sm'}>
<Box mt={textGap} color={'myGray.500'} fontSize={'sm'}>
{text || t('common:no_more_data')}
</Box>
</Flex>
......
......@@ -140,6 +140,7 @@
"dataset_create_failed": "Knowledge base creation failed",
"dataset_create_success": "The knowledge base was created successfully and files are being indexed in the background.",
"dataset_empty_tips": "You don’t have a knowledge base yet, create one first.",
"dataset_empty_tips_no_permission": "You don't have a knowledge base yet, and your account doesn't have permission to create one. Please contact your administrator.",
"dataset_select": "Optional knowledge base",
"delete_all_input_guide_confirm": "Are you sure you want to clear the input guide lexicon?",
"document_quote": "Document Reference",
......
......@@ -400,6 +400,7 @@
"core.dataset.Dataset ID": "Dataset ID",
"core.dataset.Delete Confirm": "Confirm to Delete This Dataset? Data Cannot Be Recovered After Deletion, Please Confirm!",
"core.dataset.Empty Dataset Tips": "No Dataset Yet, Create One Now!",
"core.dataset.Empty Dataset Tips No Permission": "No knowledge base yet. Your account doesn't have permission to create one. Please contact your administrator.",
"core.dataset.Folder placeholder": "This is a Directory",
"core.dataset.Intro Placeholder": "This Dataset Has No Introduction Yet",
"core.dataset.My Dataset": "My Dataset",
......@@ -909,7 +910,7 @@
"navbar.Studio": "Studio",
"navbar.Tools": "Tools",
"navbar.plugin": "Plugins",
"new_create": "Create New",
"new_create": "New",
"next_step": "Next",
"no": "No",
"no_child_folder": "No Subdirectories, Place Here",
......
......@@ -8,6 +8,7 @@
"copy_skill_confirm": "The system will create a skill with the same configuration for you, but the permissions will not be copied. Please confirm!",
"copy_success": "Copied successfully",
"create_skill": "New Skill",
"create_skill_desc": "Tell AI your requirements and create or optimize Skills through natural language.",
"create_your_first_skill": "Create your first Skill",
"delete_failed": "Delete failed",
"delete_success": "Deleted successfully",
......@@ -23,6 +24,8 @@
"export_failed": "Export failed",
"export_success": "Exported successfully",
"history_versions": "History Versions",
"import_existing_skill": "Import existing Skill",
"import_existing_skill_desc": "Import common Skills from outside directly into the platform. Supports .zip format.",
"import_skill": "Import Skill",
"import_skill_file_type_tip": "Supports {{ext}} formats",
"import_skill_max_size_tip": "Upload up to {{maxCount}} file at a time, max file size: {{maxSize}}",
......@@ -31,7 +34,8 @@
"move_skill": "Move Skill",
"move_skill_hint": "After moving, the selected skill/folder will inherit the permission settings of the new folder.",
"no_current_version": "No available Skill version yet. Recreate or import the Skill before editing.",
"no_skills": "No skills yet",
"no_skills": "You don't have any skills yet",
"no_skills_no_permission": "You don't have any skills yet, and your account doesn't have permission to create skills. Please contact your administrator.",
"permission.des.manage": "Can manage Agent Skill and collaborators",
"permission.des.read": "Can view Agent Skill",
"permission.des.write": "Can edit Agent Skill",
......
......@@ -140,6 +140,7 @@
"dataset_create_failed": "知识库创建失败",
"dataset_create_success": "知识库创建成功,正在后台索引文件",
"dataset_empty_tips": "你还没有知识库,先创建一个吧",
"dataset_empty_tips_no_permission": "您还没有知识库,且当前账号没有创建知识库的权限,请联系管理员。",
"dataset_select": "可选知识库",
"delete_all_input_guide_confirm": "确定要清空输入引导词库吗?",
"document_quote": "文档引用",
......
......@@ -400,6 +400,7 @@
"core.dataset.Dataset ID": "知识库 ID",
"core.dataset.Delete Confirm": "确认删除该知识库?删除后数据无法恢复,请确认!",
"core.dataset.Empty Dataset Tips": "还没有知识库,快去创建一个吧!",
"core.dataset.Empty Dataset Tips No Permission": "还没有知识库,且当前账号没有创建知识库的权限,请联系管理员获取权限。",
"core.dataset.Folder placeholder": "这是一个目录",
"core.dataset.Intro Placeholder": "这个知识库还没有介绍~",
"core.dataset.My Dataset": "我的知识库",
......
......@@ -8,6 +8,7 @@
"copy_skill_confirm": "系统将为您创建一个相同配置技能,但权限不会进行复制,请确认!",
"copy_success": "复制成功",
"create_skill": "新建技能",
"create_skill_desc": "告诉 AI 你的需求,通过自然语言创建和优化技能",
"create_your_first_skill": "创建你的第一个技能",
"delete_failed": "删除失败",
"delete_success": "删除成功",
......@@ -23,6 +24,8 @@
"export_failed": "导出失败",
"export_success": "导出成功",
"history_versions": "历史版本",
"import_existing_skill": "导入已有技能",
"import_existing_skill_desc": "将外部的常用技能直接导入平台,支持zip格式",
"import_skill": "导入技能",
"import_skill_file_type_tip": "支持 {{ext}} 格式",
"import_skill_max_size_tip": "单次最多上传 {{maxCount}} 个文件,单个文件最大 {{maxSize}}",
......@@ -31,7 +34,8 @@
"move_skill": "移动技能",
"move_skill_hint": "移动后,所选技能/文件夹将继承新文件夹的权限设置。",
"no_current_version": "技能暂无可用版本,请重新创建或导入后再编辑。",
"no_skills": "暂无技能",
"no_skills": "您还没有技能",
"no_skills_no_permission": "您还没有技能,且当前账号没有创建技能的权限,请联系管理员。",
"permission.des.manage": "可管理 Agent 技能和协作者",
"permission.des.read": "可查看 Agent 技能",
"permission.des.write": "可编辑 Agent 技能",
......
......@@ -136,6 +136,7 @@
"dataset_create_failed": "知識庫創建失敗",
"dataset_create_success": "知識庫創建成功,正在後台索引文件",
"dataset_empty_tips": "你還沒有知識庫,先創建一個吧",
"dataset_empty_tips_no_permission": "您還沒有知識庫,且當前帳號沒有建立知識庫的權限,請聯繫管理員。",
"dataset_select": "可選知識庫",
"delete_all_input_guide_confirm": "確定要清除輸入導引詞彙庫嗎?",
"document_quote": "文件引用",
......
......@@ -395,6 +395,7 @@
"core.dataset.Dataset ID": "知識庫 ID",
"core.dataset.Delete Confirm": "確認刪除此知識庫?刪除後資料無法復原,請確認!",
"core.dataset.Empty Dataset Tips": "還沒有知識庫,快來建立一個吧!",
"core.dataset.Empty Dataset Tips No Permission": "還沒有知識庫,且當前帳號沒有建立知識庫的權限,請聯繫管理員取得權限。",
"core.dataset.Folder placeholder": "這是一個目錄",
"core.dataset.Intro Placeholder": "這個知識庫還沒有介紹",
"core.dataset.My Dataset": "我的知識庫",
......
......@@ -8,6 +8,7 @@
"copy_skill_confirm": "系統將為您創建一個相同配置技能,但權限不會進行複製,請確認!",
"copy_success": "複製成功",
"create_skill": "新建技能",
"create_skill_desc": "告訴 AI 你的需求,通過自然語言創建和優化技能",
"create_your_first_skill": "創建你的第一個技能",
"delete_failed": "刪除失敗",
"delete_success": "刪除成功",
......@@ -23,6 +24,8 @@
"export_failed": "導出失敗",
"export_success": "導出成功",
"history_versions": "歷史版本",
"import_existing_skill": "導入已有技能",
"import_existing_skill_desc": "將外部的常用技能直接導入平台,支持 .zip 格式",
"import_skill": "導入技能",
"import_skill_file_type_tip": "支持 {{ext}} 格式",
"import_skill_max_size_tip": "單次最多上傳 {{maxCount}} 個文件,單個文件最大 {{maxSize}}",
......@@ -31,7 +34,8 @@
"move_skill": "移動技能",
"move_skill_hint": "移動後,所選技能/文件夾將繼承新文件夾的權限設置。",
"no_current_version": "技能暫無可用版本,請重新建立或匯入後再編輯。",
"no_skills": "暫無技能",
"no_skills": "您還沒有技能",
"no_skills_no_permission": "您還沒有技能,且當前帳號沒有建立技能的權限,請聯繫管理員。",
"permission.des.manage": "可管理 Agent 技能和協作者",
"permission.des.read": "可查看 Agent 技能",
"permission.des.write": "可編輯 Agent 技能",
......
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M16.0002 5.33325C16.7365 5.33325 17.3335 5.93021 17.3335 6.66659V14.6666H25.3335C26.0699 14.6666 26.6668 15.2635 26.6668 15.9999C26.6668 16.7363 26.0699 17.3333 25.3335 17.3333H17.3335V25.3333C17.3335 26.0696 16.7365 26.6666 16.0002 26.6666C15.2638 26.6666 14.6668 26.0696 14.6668 25.3333V17.3333H6.66683C5.93045 17.3333 5.3335 16.7363 5.3335 15.9999C5.3335 15.2635 5.93045 14.6666 6.66683 14.6666H14.6668V6.66659C14.6668 5.93021 15.2638 5.33325 16.0002 5.33325Z"
fill="url(#paint0_linear_create_action)"
/>
<defs>
<linearGradient
id="paint0_linear_create_action"
x1="19.7762"
y1="6.03725"
x2="12.2242"
y2="25.9626"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.13736" stop-color="#86EFAC" />
<stop offset="0.89759" stop-color="#36D399" />
</linearGradient>
</defs>
</svg>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.3696 19.4631C24.6576 20.3953 25.3014 21.3951 26.3048 22.4644C27.3083 23.5337 28.319 24.1953 29.3334 24.4549C28.3539 24.6614 27.3449 25.3122 26.3048 26.4052C25.2648 27.5001 24.6209 28.4743 24.3696 29.3334C23.9623 28.3299 23.3038 27.3539 22.3921 26.4052C21.4804 25.4547 20.4935 24.804 19.4277 24.4549C20.444 24.1423 21.4309 23.4788 22.3921 22.4625C23.3533 21.4463 24.0119 20.4464 24.3696 19.4631ZM25.8554 3.61015L27.8439 5.59885C28.4441 6.19875 28.7809 7.01137 28.7806 7.85844C28.7802 8.70551 28.4427 9.51786 27.8421 10.1173L10.1348 27.7706C9.53137 28.3698 8.71402 28.706 7.86212 28.7053C7.01022 28.7047 6.1934 28.3672 5.59099 27.767L3.60252 25.7764C3.00259 25.1763 2.66606 24.3635 2.66675 23.5165C2.66744 22.6694 3.00529 21.8572 3.60619 21.258L21.3117 3.60649C21.6099 3.30861 21.9641 3.07227 22.3541 2.91101C22.7441 2.74975 23.1622 2.66675 23.5845 2.66675C24.0067 2.66675 24.4248 2.74975 24.8148 2.91101C25.2048 3.07227 25.5591 3.30861 25.8573 3.60649L25.8554 3.61015ZM18.0501 10.7369L5.55247 23.1973C5.50976 23.2398 5.47588 23.2902 5.45276 23.3457C5.42964 23.4012 5.41774 23.4608 5.41774 23.5209C5.41774 23.581 5.42964 23.6405 5.45276 23.696C5.47588 23.7515 5.50976 23.8019 5.55247 23.8444L7.53727 25.8313C7.62393 25.9174 7.74136 25.9658 7.86379 25.9658C7.98622 25.9658 8.10365 25.9174 8.19031 25.8313L20.6861 13.369L18.0501 10.7369ZM7.27495 3.47306C7.51342 4.24807 8.0509 5.08157 8.88921 5.97355C9.72386 6.86371 10.5658 7.41755 11.4133 7.63141C10.5952 7.80506 9.75504 8.34793 8.88738 9.25819C8.01971 10.1685 7.48407 10.9837 7.27495 11.6984C6.93559 10.863 6.38528 10.0496 5.62584 9.25636C4.86641 8.46673 4.04461 7.92387 3.1586 7.63141C4.00241 7.37186 4.82605 6.81985 5.62584 5.97173C6.42747 5.12543 6.97595 4.29376 7.27679 3.47123L7.27495 3.47306ZM23.2579 5.54584L19.9927 8.7994L22.6342 11.4315L25.8939 8.18159C25.9367 8.13914 25.9705 8.08872 25.9937 8.0332C26.0168 7.97768 26.0287 7.91817 26.0287 7.85806C26.0287 7.79796 26.0168 7.73844 25.9937 7.68292C25.9705 7.62741 25.9367 7.57698 25.8939 7.53453L23.9073 5.54584C23.8647 5.50328 23.8141 5.46952 23.7584 5.44648C23.7027 5.42345 23.6429 5.41159 23.5826 5.41159C23.5223 5.41159 23.4626 5.42345 23.4069 5.44648C23.3511 5.46952 23.3005 5.50328 23.2579 5.54584Z" fill="url(#paint0_linear_1207_11534)"/>
<defs>
<linearGradient id="paint0_linear_1207_11534" x1="20.7201" y1="3.54675" x2="11.2801" y2="28.4534" gradientUnits="userSpaceOnUse">
<stop offset="0.13736" stop-color="#86EFAC"/>
<stop offset="0.89759" stop-color="#36D399"/>
</linearGradient>
</defs>
</svg>
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.88817 2.60847C7.88817 2.41049 7.72767 2.25 7.52969 2.25C4.67636 2.25 2.36328 4.56308 2.36328 7.41641V19.5836C2.36328 22.4369 4.67636 24.75 7.52969 24.75H19.6969C22.5502 24.75 24.8633 22.4369 24.8633 19.5836V7.41641C24.8633 4.56308 22.5502 2.25 19.6969 2.25H17.103C16.4816 2.25 15.978 2.75368 15.978 3.375C15.978 3.99632 16.4816 4.5 17.103 4.5H19.6969C21.3076 4.5 22.6133 5.80572 22.6133 7.41641V19.5836C22.6133 21.1943 21.3076 22.5 19.6969 22.5H7.52969C5.919 22.5 4.61328 21.1943 4.61328 19.5836V7.41641C4.61328 5.80572 5.919 4.5 7.52969 4.5C7.72767 4.5 7.88817 4.33951 7.88817 4.14153V2.60847Z" fill="url(#paint0_linear_1299_1397)"/>
<path d="M12.5719 8.03145C12.3932 6.78009 12.0188 5.99431 11.3896 5.55014C9.8919 4.49296 8.7802 4.50003 8.61668 4.50107L8.60584 4.50112H7.48084V2.25112H8.60584C9.00195 2.25112 10.6433 2.26927 12.6871 3.71196C14.0687 4.68718 14.5875 6.23067 14.7993 7.71336C14.972 8.92262 14.9623 10.2818 14.9535 11.534C14.9515 11.8046 14.9497 12.0701 14.9497 12.3281C14.9497 13.0572 14.9341 13.7421 14.911 14.356L15.8578 13.4093C16.2971 12.9699 17.0094 12.9699 17.4488 13.4093C17.8881 13.8486 17.8881 14.5609 17.4488 15.0002L14.4836 17.9654C14.2449 18.2041 13.9256 18.3131 13.6133 18.2924C13.301 18.3131 12.9817 18.2041 12.743 17.9654L9.77785 15.0002C9.33851 14.5609 9.33851 13.8486 9.77785 13.4093C10.2172 12.9699 10.9295 12.9699 11.3688 13.4093L12.5074 14.5478L12.6454 14.6859C12.6766 13.9974 12.6997 13.1949 12.6997 12.3281C12.6997 12.0306 12.7015 11.738 12.7033 11.4506C12.7112 10.2054 12.7185 9.05802 12.5719 8.03145Z" fill="url(#paint1_linear_1299_1397)"/>
<defs>
<linearGradient id="paint0_linear_1299_1397" x1="17.5958" y1="2.9925" x2="9.63078" y2="24.0075" gradientUnits="userSpaceOnUse">
<stop offset="0.13736" stop-color="#86EFAC"/>
<stop offset="0.89759" stop-color="#36D399"/>
</linearGradient>
<linearGradient id="paint1_linear_1299_1397" x1="17.5958" y1="2.9925" x2="9.63078" y2="24.0075" gradientUnits="userSpaceOnUse">
<stop offset="0.13736" stop-color="#86EFAC"/>
<stop offset="0.89759" stop-color="#36D399"/>
</linearGradient>
</defs>
</svg>
......@@ -165,7 +165,14 @@ export const DatasetSelectModal = ({
<ModalBody flex={1} h={0} overflow="hidden">
{isRootEmpty ? (
<VStack h={'full'} justifyContent={'center'}>
<EmptyTip text={t('app:dataset_empty_tips')} py={4} />
<EmptyTip
text={
userInfo?.team?.permission.hasDatasetCreatePer
? t('app:dataset_empty_tips')
: t('app:dataset_empty_tips_no_permission')
}
py={4}
/>
{userInfo?.team?.permission.hasDatasetCreatePer && (
<Button onClick={onOpenQuickCreate}>{t('common:Create')}</Button>
)}
......
......@@ -17,9 +17,13 @@ export const useSkillSelectData = () => {
const [searchKey, setSearchKey] = useState('');
const [navStack, setNavStack] = useState<SkillSelectNavItemType[]>([]);
const parentId = navStack.length > 0 ? navStack[navStack.length - 1].id : '';
const parentId: ParentIdType = navStack.length > 0 ? navStack[navStack.length - 1].id : null;
const { data: skillList = [], loading: isLoadingSkillList } = useRequest(
const {
data: skillList = [],
loading: isLoadingSkillList,
refreshAsync: refreshSkillList
} = useRequest(
async () => {
const { list } = await getSkillList({
source: 'mine',
......@@ -64,6 +68,8 @@ export const useSkillSelectData = () => {
searchKey,
setSearchKey,
paths,
parentId,
refreshSkillList,
onEnterFolder,
onUpdateParentId
};
......
......@@ -2,10 +2,25 @@ import React from 'react';
import { Box } from '@chakra-ui/react';
import MyBox from '@fastgpt/web/components/common/MyBox';
import MyIcon from '@fastgpt/web/components/common/Icon';
import type { IconNameType } from '@fastgpt/web/components/common/Icon/type';
import { useTranslation } from 'next-i18next';
const ListCreateCard = ({ onClick, label }: { onClick: () => void; label?: string }) => {
const ListCreateCard = ({
onClick,
label,
icon = 'common/addLight',
accentColor = '#7895FE',
hoverBg = 'primary.50'
}: {
onClick: () => void;
label?: string;
icon?: IconNameType;
/** 虚线框与加号颜色 */
accentColor?: string;
hoverBg?: string;
}) => {
const { t } = useTranslation();
const dashedBorderSvg = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25' viewBox='0 0 330 56' preserveAspectRatio='none'%3E%3Crect x='0.5' y='0.5' width='329' height='55' rx='12' fill='none' stroke='${encodeURIComponent(accentColor)}' stroke-width='1' stroke-dasharray='6 6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")`;
return (
<MyBox
......@@ -47,7 +62,7 @@ const ListCreateCard = ({ onClick, label }: { onClick: () => void; label?: strin
left={'1px'}
right={'1px'}
bottom={'1px'}
bg={'primary.50'}
bg={hoverBg}
borderRadius={'14px'}
/>
<Box
......@@ -57,11 +72,11 @@ const ListCreateCard = ({ onClick, label }: { onClick: () => void; label?: strin
alignItems={'center'}
justifyContent={'center'}
sx={{
background: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25' viewBox='0 0 330 56' preserveAspectRatio='none'%3E%3Crect x='0.5' y='0.5' width='329' height='55' rx='12' fill='none' stroke='%237895FE' stroke-width='1' stroke-dasharray='6 6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat center`,
background: `${dashedBorderSvg} no-repeat center`,
backgroundSize: '100% 100%'
}}
>
<MyIcon name={'common/addLight'} w={8} color={'#7895FE'} zIndex={1} />
<MyIcon name={icon} w={8} color={accentColor} zIndex={1} />
</Box>
</Box>
</MyBox>
......
......@@ -7,6 +7,7 @@ import Avatar from '@fastgpt/web/components/common/Avatar';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'next-i18next';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useUploadAvatar } from '@fastgpt/web/common/file/hooks/useUploadAvatar';
import { getUploadAvatarPresignedUrl } from '@/web/common/file/api';
import { postCreateSkill } from '@/web/core/skill/api';
......@@ -23,10 +24,14 @@ type FormType = {
type Props = {
parentId?: string | null;
onClose: () => void;
onSuccess?: (skillId: string) => void | Promise<void>;
/** 创建成功后在新标签页打开详情;默认 false 在当前页跳转。 */
openDetailInNewTab?: boolean;
};
const CreateSkillModal = ({ parentId, onClose }: Props) => {
const CreateSkillModal = ({ parentId, onClose, onSuccess, openDetailInNewTab = false }: Props) => {
const { t } = useTranslation();
const { toast } = useToast();
const router = useRouter();
const { register, setValue, control, handleSubmit } = useForm<FormType>({
......@@ -46,7 +51,7 @@ const CreateSkillModal = ({ parentId, onClose }: Props) => {
}
});
const { runAsync: onCreate, loading: isCreating } = useRequest(
const { runAsync: createSkill, loading: isCreating } = useRequest(
async ({ avatar, name, intro }: FormType) => {
return postCreateSkill({
parentId: parentId ?? null,
......@@ -56,14 +61,53 @@ const CreateSkillModal = ({ parentId, onClose }: Props) => {
});
},
{
onSuccess(skillId) {
onClose();
router.push(`/skill/detail?skillId=${skillId}`);
},
errorToast: t('common:create_failed')
}
);
const handleConfirm = () => {
const onValid = async (data: FormType) => {
// 校验通过后再于同步用户手势内预建空白窗口:handleSubmit 同步校验通过后会立即调用 onValid,
// 仍在 click 手势调用栈内,不会被 Safari / 严格弹窗策略拦截。
// 关键:放在这里(而非校验前)可避免名称为空时「先开空白标签页、onInvalid 又关掉」造成的闪退。
// 注:不能用 noopener —— 需保留窗口句柄以便创建成功后设置 location;目标页为同源可信路由。
const popup = openDetailInNewTab ? window.open('', '_blank') : null;
let skillId: string;
try {
skillId = await createSkill(data);
} catch {
// 创建失败:关掉预建窗口(useRequest 已展示错误提示)。
popup?.close();
return;
}
// 创建成功后立即关闭弹窗:后续回调失败不应让弹窗卡住,也不应掩盖创建已成功。
onClose();
// 打开详情页:新标签页模式下若窗口被拦截则不跳走当前页(如 Agent 编辑器);
// 当前页模式下直接 router.push。
const detailUrl = `/skill/detail?skillId=${skillId}`;
if (openDetailInNewTab) {
if (popup && !popup.closed) {
popup.location.href = detailUrl;
}
} else {
await router.push(detailUrl);
}
// 创建后的关联/刷新交给调用方,由调用方自行处理异常。
await onSuccess?.(skillId);
};
const onInvalid = () => {
// 校验未通过(名称为空 / 纯空格):稳定提示错误,弹窗保持打开,不再开/关空白标签页。
toast({ status: 'warning', title: t('common:name_is_empty') });
};
handleSubmit(onValid, onInvalid)();
};
return (
<>
<MyModal
......@@ -79,7 +123,7 @@ const CreateSkillModal = ({ parentId, onClose }: Props) => {
<Button variant={'whiteBase'} onClick={onClose}>
{t('common:Cancel')}
</Button>
<Button isLoading={isCreating} onClick={handleSubmit((data) => onCreate(data))}>
<Button isLoading={isCreating} onClick={handleConfirm}>
{t('common:Confirm')}
</Button>
</>
......@@ -111,7 +155,10 @@ const CreateSkillModal = ({ parentId, onClose }: Props) => {
flex={1}
size={'sm'}
placeholder={t('skill:skill_name_placeholder')}
{...register('name', { required: true })}
{...register('name', {
required: true,
setValueAs: (value: string) => value.trim()
})}
/>
</Flex>
</Box>
......
......@@ -32,7 +32,7 @@ type ValidImportSkillFormType = ImportSkillFormType & {
type Props = {
parentId?: string | null;
onClose: () => void;
onSuccess?: () => void;
onSuccess?: (skillId: string) => void | Promise<void>;
};
const ImportSkillModal = ({ parentId, onClose, onSuccess }: Props) => {
......@@ -67,7 +67,7 @@ const ImportSkillModal = ({ parentId, onClose, onSuccess }: Props) => {
maxCount: 1
});
const { runAsync: onImport, loading: isImporting } = useRequest(
const { runAsync: importSkillReq, loading: isImporting } = useRequest(
({ name, avatar, file }: ValidImportSkillFormType) => {
return importSkill({
file,
......@@ -77,10 +77,6 @@ const ImportSkillModal = ({ parentId, onClose, onSuccess }: Props) => {
});
},
{
onSuccess() {
onSuccess?.();
onClose();
},
successToast: t('common:import_success'),
errorToast: t('common:import_failed')
}
......@@ -88,11 +84,15 @@ const ImportSkillModal = ({ parentId, onClose, onSuccess }: Props) => {
const handleImport = async (data: ImportSkillFormType) => {
if (!data.file) return;
await onImport({
const skillId = await importSkillReq({
...data,
name: data.name.trim(),
file: data.file
});
// 导入成功后立即关闭弹窗。
onClose();
// 关联/刷新交给调用方,由调用方自行处理异常。
await onSuccess?.(skillId);
};
const handleInvalid = (errors: FieldErrors<ImportSkillFormType>) => {
......
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Grid, IconButton, HStack, Flex, VStack } from '@chakra-ui/react';
import { Box, Grid, IconButton, HStack, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import MyIcon from '@fastgpt/web/components/common/Icon';
......@@ -45,8 +45,8 @@ import type {
} from '@fastgpt/global/common/parentFolder/type';
import ListCreateCard from '@/pageComponents/dashboard/ListCreateCard';
import SkillDashboardEmptyHero from '@/pageComponents/dashboard/skill/SkillDashboardEmptyHero';
import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList';
import { getWebReqUrl } from '@fastgpt/web/common/system/utils';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { i18nT } from '@fastgpt/global/common/i18n/utils';
......@@ -183,20 +183,22 @@ const RelatedAppsPopover = ({ skillId, count }: { skillId: string; count: number
const List = ({
onClickCreate,
onClickImport,
guardSkillSandboxOperation
}: {
onClickCreate?: () => void;
guardSkillSandboxOperation?: () => boolean;
onClickImport?: () => void;
guardSkillSandboxOperation: () => boolean;
}) => {
const { t } = useTranslation();
const router = useRouter();
const { isPc } = useSystem();
const { skills, loadSkills, isFetchingSkills, searchKey, folderDetail } = useContextSelector(
const { skills, refreshSkills, isFetchingSkills, searchKey, folderDetail } = useContextSelector(
SkillListContext,
(v) => ({
skills: v.skills,
loadSkills: v.loadSkills,
refreshSkills: v.refreshSkills,
isFetchingSkills: v.isFetchingSkills,
searchKey: v.searchKey,
folderDetail: v.folderDetail
......@@ -232,7 +234,7 @@ const List = ({
const { runAsync: onClickDeleteSkill } = useRequest(deleteSkill, {
onSuccess() {
loadSkills();
refreshSkills();
},
successToast: t('skill:delete_success'),
errorToast: t('skill:delete_failed')
......@@ -242,7 +244,7 @@ const List = ({
(skillId: string) => postCopySkill({ skillId }),
{
onSuccess() {
loadSkills();
refreshSkills();
},
successToast: t('skill:copy_success'),
errorToast: t('skill:copy_failed')
......@@ -259,7 +261,7 @@ const List = ({
}),
{
onSuccess() {
loadSkills();
refreshSkills();
setEditedSkill(undefined);
},
successToast: t('skill:edit_success'),
......@@ -277,7 +279,7 @@ const List = ({
},
{
onSuccess() {
loadSkills();
refreshSkills();
setMoveSkillId(undefined);
},
errorToast: t('skill:move_failed')
......@@ -311,7 +313,7 @@ const List = ({
type: 'grayBg' as const,
label: t('common:dataset.Edit Info'),
onClick: () => {
if (!isFolder && guardSkillSandboxOperation && !guardSkillSandboxOperation()) {
if (!isFolder && !guardSkillSandboxOperation()) {
return;
}
setEditedSkill({
......@@ -412,7 +414,7 @@ const List = ({
if (isFolder) {
router.push({ query: { ...router.query, parentId: skill._id } });
} else {
if (isSkillReady && guardSkillSandboxOperation && !guardSkillSandboxOperation()) return;
if (isSkillReady && !guardSkillSandboxOperation()) return;
router.push(`/skill/detail?skillId=${skill._id}`);
}
}}
......@@ -523,8 +525,8 @@ const List = ({
{skills.length === 0 && !folderDetail ? (
searchKey ? (
<EmptyTip />
) : isPc && onClickCreate ? (
<CreateButton onClick={onClickCreate} />
) : onClickCreate && onClickImport ? (
<SkillDashboardEmptyHero onClickImport={onClickImport} onClickCreate={onClickCreate} />
) : (
<Grid
py={4}
......@@ -538,7 +540,7 @@ const List = ({
gridGap={5}
alignItems={'stretch'}
>
{onClickCreate ? <ListCreateCard onClick={onClickCreate} /> : <ForbiddenCreateButton />}
<ForbiddenCreateButton />
</Grid>
)
) : (
......@@ -585,13 +587,13 @@ const List = ({
postChangeSkillOwner({
skillId: selectedSkill._id,
ownerId: tmbId
}).then(() => loadSkills())
}).then(() => refreshSkills())
}
hasParent={!!selectedSkill.parentId}
refetchResource={loadSkills}
refetchResource={refreshSkills}
isInheritPermission={selectedSkill.inheritPermission}
resumeInheritPermission={() =>
resumeInheritPer(selectedSkill._id).then(() => loadSkills())
resumeInheritPer(selectedSkill._id).then(() => refreshSkills())
}
avatar={selectedSkill.avatar}
name={selectedSkill.name}
......@@ -619,66 +621,6 @@ const List = ({
);
};
const CreateButton = ({ onClick }: { onClick: () => void }) => {
const { t } = useTranslation();
const [isHoverCreateButton, setIsHoverCreateButton] = useState(false);
return (
<Box
position="relative"
width="100%"
minH={'150px'}
overflow="hidden"
rounded={'sm'}
cursor={'pointer'}
onClick={onClick}
onMouseEnter={() => setIsHoverCreateButton(true)}
onMouseLeave={() => setIsHoverCreateButton(false)}
boxShadow={'0 4px 27.1px 0 rgba(199, 212, 233, 0.29)'}
userSelect={'none'}
mt={4}
>
<Box
as="img"
src={getWebReqUrl('/imgs/app/createButton.jpg')}
alt="create skill"
width="100%"
maxW="100%"
display="block"
transition="transform 0.4s cubic-bezier(0.4, 0, 0.2, 1)"
transform={isHoverCreateButton ? 'scale(1.2) translateY(-12px)' : 'scale(1) translateY(0)'}
/>
<VStack
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
color="#334155"
fontSize="32px"
fontWeight="medium"
>
<Flex gap={2.5} alignItems={'center'}>
<MyIcon name={'core/skill/default'} w={8} />
{t('skill:create_your_first_skill')}
</Flex>
<Box
mt={4}
h={14}
w={'330px'}
display={'flex'}
alignItems={'center'}
justifyContent={'center'}
sx={{
background: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='330' height='56'%3E%3Crect x='0.5' y='0.5' width='329' height='55' rx='12' fill='none' stroke='%237895FE' stroke-width='1' stroke-dasharray='6 6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat center`
}}
>
<MyIcon name={'common/addLight'} w={8} color={'#7895FE'} />
</Box>
</VStack>
</Box>
);
};
const ForbiddenCreateButton = () => {
const { t } = useTranslation();
return (
......
import React from 'react';
import { Box, Flex, Image } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import SkillEmptyActionCard from '@/pageComponents/dashboard/skill/SkillEmptyActionCard';
type Props = {
onClickImport: () => void;
onClickCreate: () => void;
};
const SkillDashboardEmptyHero = ({ onClickImport, onClickCreate }: Props) => {
const { t } = useTranslation();
return (
<Flex
flexDirection={'column'}
alignItems={'flex-start'}
alignSelf={'stretch'}
flexShrink={0}
w={'full'}
p={8}
gap={8}
>
<Flex w={'full'} justifyContent={'center'} flexShrink={0}>
<Flex alignItems={'center'} gap={2.5} flexShrink={0}>
<Image
src={'/imgs/skill/createFirstSkillIcon.svg'}
alt={''}
w={8}
h={8}
flexShrink={0}
display={'block'}
/>
<Box color={'myGray.700'} fontSize={'3xl'} fontWeight={'medium'} lineHeight={'40px'}>
{t('skill:create_your_first_skill')}
</Box>
</Flex>
</Flex>
<Flex
w={'full'}
justifyContent={'center'}
alignItems={'stretch'}
direction={['column', 'row']}
gap={6}
>
<SkillEmptyActionCard
onClick={onClickImport}
title={t('skill:import_existing_skill')}
description={t('skill:import_existing_skill_desc')}
variant={'import'}
actionIconSrc={'/imgs/skill/importActionIcon.svg'}
/>
<SkillEmptyActionCard
onClick={onClickCreate}
title={t('skill:create_skill')}
description={t('skill:create_skill_desc')}
variant={'create'}
actionIconSrc={'/imgs/skill/createActionIcon.svg'}
/>
</Flex>
</Flex>
);
};
export default SkillDashboardEmptyHero;
import React from 'react';
import { Box, Flex, Image } from '@chakra-ui/react';
const HOVER_OPACITY_TRANSITION = 'opacity 0.3s ease-out';
const CARD_BOX_SHADOW = {
default: '0 4px 22px 0 rgba(0, 0, 0, 0.05)',
hover: '0 4px 22px 0 rgba(0, 0, 0, 0.08)'
} as const;
const CARD_VIEW_BOX = '0 0 540.5 208';
type CardVariant = 'import' | 'create';
type EllipseGeometry = { cx: number; cy: number; rx: number; ry: number; rotate: number };
type FilterBox = { x: number; y: number; width: number; height: number };
/**
* 与 Figma 导出一致:椭圆 + feGaussianBlur。
* 两个 variant 共享同一组几何(仅光晕颜色不同),故抽出为模块常量。
*/
const ELLIPSE_DEFAULT: EllipseGeometry = {
cx: 404.464,
cy: 225.211,
rx: 86.5957,
ry: 163.128,
rotate: -75.6974
};
const ELLIPSE_HOVER: EllipseGeometry = {
cx: 377.858,
cy: 197.664,
rx: 93.7504,
ry: 176.606,
rotate: -85
};
const FILTER_DEFAULT: FilterBox = { x: 187.203, y: 74.372, width: 434.524, height: 301.678 };
const FILTER_HOVER: FilterBox = { x: 144.001, y: 45.265, width: 467.714, height: 304.797 };
const GLOW_VARIANT_CONFIG: Record<CardVariant, { fill: string; fillOpacity: number }> = {
import: { fill: '#AAE2F8', fillOpacity: 0.3 },
create: { fill: '#C8FDE9', fillOpacity: 0.45 }
};
type Props = {
onClick: () => void;
title: string;
description: string;
variant: CardVariant;
actionIconSrc: string;
};
const GlowFilter = ({ id, box }: { id: string; box: FilterBox }) => (
<filter
id={id}
x={box.x}
y={box.y}
width={box.width}
height={box.height}
filterUnits={'userSpaceOnUse'}
colorInterpolationFilters={'sRGB'}
>
<feFlood floodOpacity={'0'} result={'BackgroundImageFix'} />
<feBlend mode={'normal'} in={'SourceGraphic'} in2={'BackgroundImageFix'} result={'shape'} />
<feGaussianBlur stdDeviation={'28.8653'} result={'effect1_foregroundBlur'} />
</filter>
);
const SkillCardGlow = ({ variant, uid }: { variant: CardVariant; uid: string }) => {
const color = GLOW_VARIANT_CONFIG[variant];
const defaultFilterId = `${uid}-default-blur`;
const hoverFilterId = `${uid}-hover-blur`;
return (
<Box
as={'svg'}
position={'absolute'}
inset={0}
w={'full'}
h={'full'}
viewBox={CARD_VIEW_BOX}
fill={'none'}
pointerEvents={'none'}
overflow={'visible'}
preserveAspectRatio={'none'}
sx={{
'& .skill-card-glow': {
transition: HOVER_OPACITY_TRANSITION
},
'& .skill-card-glow-default': {
opacity: 1
},
'& .skill-card-glow-hover': {
opacity: 0
}
}}
>
<defs>
<GlowFilter id={defaultFilterId} box={FILTER_DEFAULT} />
<GlowFilter id={hoverFilterId} box={FILTER_HOVER} />
</defs>
<g className={'skill-card-glow skill-card-glow-default'} filter={`url(#${defaultFilterId})`}>
<ellipse
cx={ELLIPSE_DEFAULT.cx}
cy={ELLIPSE_DEFAULT.cy}
rx={ELLIPSE_DEFAULT.rx}
ry={ELLIPSE_DEFAULT.ry}
transform={`rotate(${ELLIPSE_DEFAULT.rotate} ${ELLIPSE_DEFAULT.cx} ${ELLIPSE_DEFAULT.cy})`}
fill={color.fill}
fillOpacity={color.fillOpacity}
/>
</g>
<g className={'skill-card-glow skill-card-glow-hover'} filter={`url(#${hoverFilterId})`}>
<ellipse
cx={ELLIPSE_HOVER.cx}
cy={ELLIPSE_HOVER.cy}
rx={ELLIPSE_HOVER.rx}
ry={ELLIPSE_HOVER.ry}
transform={`rotate(${ELLIPSE_HOVER.rotate} ${ELLIPSE_HOVER.cx} ${ELLIPSE_HOVER.cy})`}
fill={color.fill}
fillOpacity={color.fillOpacity}
/>
</g>
</Box>
);
};
const SkillEmptyActionCard = ({ onClick, title, description, variant, actionIconSrc }: Props) => {
const uid = React.useId().replace(/:/g, '');
return (
<Flex
as={'button'}
type={'button'}
aria-label={title}
textAlign={'left'}
position={'relative'}
cursor={'pointer'}
onClick={onClick}
direction={'column'}
alignItems={'flex-start'}
minH={'208px'}
w={'full'}
maxW={['full', '540px']}
minW={0}
p={[5, 8]}
borderRadius={'lg'}
overflow={'hidden'}
bg={
'linear-gradient(109deg, rgba(241, 246, 249, 0.10) 13.12%, rgba(230, 245, 242, 0.10) 83.48%), rgba(255, 255, 255, 0.80)'
}
boxShadow={CARD_BOX_SHADOW.default}
transition={'box-shadow 0.3s ease-out'}
_hover={{
boxShadow: CARD_BOX_SHADOW.hover
}}
_focusVisible={{
outline: '2px solid',
outlineColor: 'primary.500',
outlineOffset: '2px'
}}
sx={{
WebkitAppearance: 'none',
appearance: 'none',
border: 0,
font: 'inherit',
'&:hover .skill-card-glow-default': {
opacity: 0
},
'&:hover .skill-card-glow-hover': {
opacity: 1
}
}}
>
<SkillCardGlow variant={variant} uid={uid} />
<Flex position={'relative'} zIndex={1} direction={'column'} gap={2} w={'full'} flexShrink={0}>
<Box color={'myGray.600'} fontSize={'xl'} fontWeight={'medium'} lineHeight={'32px'}>
{title}
</Box>
<Box
color={'myGray.500'}
fontSize={'md'}
fontWeight={'normal'}
lineHeight={'24px'}
letterSpacing={'0.5px'}
>
{description}
</Box>
</Flex>
<Flex
position={'relative'}
zIndex={1}
mt={4}
py={4}
px={[8, '120px']}
justifyContent={'center'}
alignItems={'center'}
gap={2.5}
alignSelf={'stretch'}
borderRadius={'lg'}
border={'1px dashed'}
borderColor={'#86EFAC'}
bg={'rgba(255, 255, 255, 0.50)'}
>
<Image src={actionIconSrc} alt={''} w={8} h={8} flexShrink={0} display={'block'} />
</Flex>
</Flex>
);
};
export default SkillEmptyActionCard;
......@@ -20,7 +20,7 @@ export type SkillListItemType = Omit<
type SkillListContextType = {
skills: SkillListItemType[];
isFetchingSkills: boolean;
loadSkills: () => Promise<any>;
refreshSkills: () => void;
searchKey: string;
setSearchKey: Dispatch<SetStateAction<string>>;
parentId: string | null;
......@@ -33,7 +33,7 @@ type SkillListContextType = {
export const SkillListContext = createContext<SkillListContextType>({
skills: [],
isFetchingSkills: false,
loadSkills: async () => {
refreshSkills: () => {
throw new Error('Function not implemented.');
},
searchKey: '',
......@@ -53,11 +53,11 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => {
const {
data,
runAsync: loadSkills,
refresh: refreshSkills,
loading: isFetchingSkills
} = useRequest(
() =>
getSkillList({ source: 'mine', searchKey: searchKey, parentId: parentId ?? '' }).then((res) =>
getSkillList({ source: 'mine', searchKey: searchKey, parentId }).then((res) =>
res.list.map((item) => ({
...item,
createTime: new Date(item.createTime),
......@@ -100,7 +100,7 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => {
const contextValue: SkillListContextType = {
skills: data || [],
isFetchingSkills,
loadSkills,
refreshSkills,
searchKey,
setSearchKey,
parentId,
......
......@@ -10,6 +10,7 @@ import Avatar from '@fastgpt/web/components/common/Avatar';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useUserStore } from '@/web/support/user/useUserStore';
import { checkTeamExportDatasetLimit } from '@/web/support/user/team/api';
import { downloadFetch } from '@/web/common/system/utils';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
......@@ -52,6 +53,10 @@ function List() {
searchKey,
setSearchKey
} = useContextSelector(DatasetsContext, (v) => v);
const { userInfo } = useUserStore();
const canCreateDataset = folderDetail
? folderDetail.permission.hasWritePer
: !!userInfo?.team?.permission.hasDatasetCreatePer;
const router = useRouter();
const { parentId = null } = router.query as { parentId?: string | null };
const [editPerDatasetId, setEditPerDatasetId] = useState<string>();
......@@ -405,7 +410,11 @@ function List() {
{myDatasets.length === 0 && (
<EmptyTip
pt={'35vh'}
text={t('common:core.dataset.Empty Dataset Tips')}
text={
canCreateDataset
? t('common:core.dataset.Empty Dataset Tips')
: t('common:core.dataset.Empty Dataset Tips No Permission')
}
flexGrow="1"
></EmptyTip>
)}
......
......@@ -50,7 +50,7 @@ const SkillPageContent = ({ MenuIcon }: { MenuIcon: JSX.Element }) => {
const {
skills,
isFetchingSkills,
loadSkills,
refreshSkills,
searchKey,
setSearchKey,
parentId,
......@@ -63,7 +63,7 @@ const SkillPageContent = ({ MenuIcon }: { MenuIcon: JSX.Element }) => {
const { runAsync: onCreateFolder } = useRequest(postCreateSkillFolder, {
onSuccess() {
loadSkills();
refreshSkills();
},
errorToast: 'Error'
});
......@@ -177,6 +177,15 @@ const SkillPageContent = ({ MenuIcon }: { MenuIcon: JSX.Element }) => {
}
: undefined
}
onClickImport={
hasCreatePer
? () => {
if (guardSkillSandboxOperation()) {
setShowImportModal(true);
}
}
: undefined
}
guardSkillSandboxOperation={guardSkillSandboxOperation}
/>
</MyBox>
......@@ -193,14 +202,19 @@ const SkillPageContent = ({ MenuIcon }: { MenuIcon: JSX.Element }) => {
)}
{showCreateModal && (
<CreateSkillModal parentId={parentId} onClose={() => setShowCreateModal(false)} />
<CreateSkillModal
parentId={parentId}
onClose={() => setShowCreateModal(false)}
onSuccess={refreshSkills}
openDetailInNewTab
/>
)}
{showImportModal && (
<ImportSkillModal
parentId={parentId}
onClose={() => setShowImportModal(false)}
onSuccess={() => loadSkills()}
onSuccess={refreshSkills}
/>
)}
......
import { describe, expect, it } from 'vitest';
import { CreateSkillBodySchema } from '@fastgpt/global/core/ai/skill/api';
describe('CreateSkillBodySchema - name validation', () => {
it('rejects an empty name', () => {
const result = CreateSkillBodySchema.safeParse({ parentId: null, name: '' });
expect(result.success).toBe(false);
});
it('rejects a whitespace-only name', () => {
const result = CreateSkillBodySchema.safeParse({ parentId: null, name: ' ' });
expect(result.success).toBe(false);
});
it('accepts a normal name', () => {
const result = CreateSkillBodySchema.safeParse({ parentId: null, name: 'My Skill' });
expect(result.success).toBe(true);
});
it('trims surrounding whitespace from a valid name', () => {
const result = CreateSkillBodySchema.safeParse({ parentId: null, name: ' My Skill ' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('My 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