Commit ed4840a4 by light5980 Committed by GitHub

feat(app): 支持导入导出应用名称与介绍 (#7041)

* feat: app name autofill

* perf: import app

* fix: ts

---------

Co-authored-by: archer <545436317@qq.com>
parent 79391f63
......@@ -40,6 +40,7 @@ fastgpt-plugin:
5. 插件服务启动时会自动注册 active 插件到 local-pool 运行时。
6. 应用/知识库增加虚拟列表渲染。
7. 增加单独的 openapi 文档,区分 devapi 文档。
8. 导出工作流模板,同时导出名字和介绍。
## ⚙️ 优化
......
......@@ -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-05T13:29:50+08:00",
"content/self-host/upgrading/4-15/41504.mdx": "2026-06-05T15:13:52+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",
......
......@@ -13,6 +13,7 @@ import {
ZipEntryInfoSchema
} from '../../../../core/ai/skill/type';
import { ChatGenerateStatusEnum } from '../../../../core/chat/constants';
import { SkillPermissionSchema } from '../../../../support/permission/skill/controller.schema';
const IdSchema = z.string().min(1).meta({ description: '资源 ID' });
const SandboxInstanceKeySchema = z.string().min(1).describe('FastGPT sandbox instance key');
......@@ -42,7 +43,7 @@ export const ListSkillsResponseItemSchema = AgentSkillListItemSchema.omit({
type: AgentSkillTypeSchema,
createTime: z.string(),
updateTime: z.string(),
permission: z.number().optional(),
permission: SkillPermissionSchema,
sourceMember: z
.object({
name: z.string(),
......@@ -135,7 +136,7 @@ export const GetSkillDetailResponseSchema = z.object({
currentVersionId: z.string().optional(),
createTime: z.string(),
updateTime: z.string(),
permission: z.any().optional(),
permission: SkillPermissionSchema,
appCount: z.number().optional()
});
export type GetSkillDetailResponse = z.infer<typeof GetSkillDetailResponseSchema>;
......
......@@ -35,6 +35,24 @@ const emptyObjectToUndefined = (value: unknown) => {
return value;
};
const appTypeValues = new Set(Object.values(AppTypeEnum));
const preprocessListAppType = (value: unknown) => {
const isAppType = (item: unknown): item is AppTypeEnum =>
typeof item === 'string' && appTypeValues.has(item as AppTypeEnum);
if (value === '') {
return undefined;
}
if (Array.isArray(value)) {
const validTypes = value.filter(isAppType);
return validTypes.length > 0 ? validTypes : undefined;
}
return isAppType(value) ? value : undefined;
};
export const OpenAPIAppScheduledTriggerConfigSchema = z
.preprocess(emptyObjectToUndefined, AppScheduledTriggerConfigTypeSchema.optional())
.meta({
......@@ -131,7 +149,7 @@ export const ListAppBodySchema = z
}),
type: z
.preprocess(
(value) => (value === '' ? undefined : value),
preprocessListAppType,
z.union([z.enum(AppTypeEnum), z.array(z.enum(AppTypeEnum))]).optional()
)
.optional()
......
import z from 'zod';
import { BoolSchema, IntSchema } from '../../../common/zod';
import type { SkillPermission } from './controller';
// HTTP 响应会把 SkillPermission 类实例序列化为普通对象,声明前端实际依赖的技能权限字段。
const SkillPermissionObjectSchema = z
.object({
role: IntSchema.meta({ description: '技能权限角色值' }),
isOwner: BoolSchema.meta({ description: '是否为技能所有者' }),
hasManagePer: BoolSchema.meta({ description: '是否拥有技能管理权限' }),
hasWritePer: BoolSchema.meta({ description: '是否拥有技能写权限' }),
hasReadPer: BoolSchema.meta({ description: '是否拥有技能读权限' }),
hasManageRole: BoolSchema.meta({ description: '是否包含技能管理角色' }),
hasWriteRole: BoolSchema.meta({ description: '是否包含技能写角色' }),
hasReadRole: BoolSchema.meta({ description: '是否包含技能读角色' })
})
.passthrough()
.meta({ description: '技能权限对象序列化后的 JSON 结构' });
export const SkillPermissionSchema =
SkillPermissionObjectSchema as unknown as z.ZodType<SkillPermission>;
......@@ -13,4 +13,12 @@ describe('ListAppBodySchema', () => {
ListAppBodySchema.parse({ type: [AppTypeEnum.folder, AppTypeEnum.workflow] }).type
).toEqual([AppTypeEnum.folder, AppTypeEnum.workflow]);
});
it('should ignore app types outside enum values', () => {
expect(ListAppBodySchema.parse({ type: 'unknown' })).toEqual({});
expect(ListAppBodySchema.parse({ type: ['unknown', AppTypeEnum.workflow] }).type).toEqual([
AppTypeEnum.workflow
]);
expect(ListAppBodySchema.parse({ type: ['unknown'] })).toEqual({});
});
});
......@@ -2,6 +2,7 @@ import { type UserType } from '@fastgpt/global/support/user/type';
import { MongoUser } from './schema';
import { getTmbInfoByTmbId, getUserDefaultTeam } from './team/controller';
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { TeamPermission } from '@fastgpt/global/support/permission/user/controller';
export async function authUserExist({ userId, username }: { userId?: string; username?: string }) {
if (userId) {
......@@ -15,10 +16,12 @@ export async function authUserExist({ userId, username }: { userId?: string; use
export async function getUserDetail({
tmbId,
userId
userId,
isRoot = false
}: {
tmbId?: string;
userId?: string;
isRoot?: boolean;
}): Promise<UserType> {
const tmb = await (async () => {
if (tmbId) {
......@@ -38,14 +41,20 @@ export async function getUserDetail({
return Promise.reject(ERROR_ENUM.unAuthorization);
}
const permission = isRoot ? new TeamPermission({ isOwner: true }) : tmb.permission;
const team = {
...tmb,
permission
};
return {
_id: user._id,
username: user.username,
avatar: tmb.avatar,
timezone: user.timezone,
promotionRate: user.promotionRate,
team: tmb,
permission: tmb.permission,
team,
permission,
contact: user.contact,
language: user.language,
tags: user.tags
......
......@@ -50,12 +50,16 @@
"app.version_name_tips": "Version name cannot be empty",
"app.version_past": "Previously Published",
"app.version_publish_tips": "This version will be saved to the team cloud, synchronized with the entire team, and update the app version on all release channels.",
"app_intro": "App Introduction",
"app_intro_placeholder": "Describe use cases and access paths",
"app_intro_too_long": "App introduction cannot exceed 500 characters",
"apply_code": "Apply",
"apply_code_failed": "Failed to apply code",
"auto_execute": "Automatic execution",
"auto_execute_default_prompt_placeholder": "Default questions sent when executing automatically",
"auto_execute_tip": "When enabled, the workflow runs automatically when a user enters the chat. Execution order: 1. Conversation opener → 2. Global variables → 3. Auto-execute.",
"auto_save": "Auto save",
"avatar_and_name": "Avatar & Name",
"chat.collapse_deleted_items": "Collapse deleted records",
"chat.expand_deleted_items": "Expand deleted records",
"chat_agent_beta_tip": "Agent mode is in the testing stage, and major interaction/scheduling logic changes may be made in the future.",
......@@ -516,7 +520,7 @@
"type.hidden": "Hide app",
"type_http_tool_set_intro": "Batch import API tool",
"type_mcp_intro": "Connect to MCP service",
"type_not_recognized": "App type not recognized",
"type_not_recognized": "Wrong template application type",
"type_plugin_description": "Output the specified results with one click.",
"type_plugin_intro": "Commonly used to encapsulate workflow",
"type_simple_description": "The most basic conversation application, no complicated configuration is required, and it can be created and used quickly.",
......
{
"search_skill": "Search",
"create_skill": "New Skill",
"create_your_first_skill": "Create your first Skill",
"no_skills": "No skills yet",
"copy_skill": "Create a copy",
"related_apps_count": "{{count}} linked apps",
......@@ -18,6 +19,7 @@
"permission_settings": "Permission Settings",
"export_config": "Export Config",
"create_folder": "New Folder",
"unnamed_skill": "Unnamed",
"skill_name_placeholder": "Enter skill name",
"skill_avatar_and_name": "Avatar & Name",
"skill_intro_label": "App Introduction",
......
......@@ -50,12 +50,16 @@
"app.version_name_tips": "版本名称不能为空",
"app.version_past": "发布过",
"app.version_publish_tips": "该版本将被保存至团队云端,同步给整个团队,同时更新所有发布渠道的应用版本",
"app_intro": "应用介绍",
"app_intro_placeholder": "介绍使用场景及途径",
"app_intro_too_long": "应用介绍不能超过 500 字符",
"apply_code": "应用",
"apply_code_failed": "应用代码失败",
"auto_execute": "自动执行",
"auto_execute_default_prompt_placeholder": "自动执行时,发送的默认问题",
"auto_execute_tip": "开启后,用户进入对话界面将自动触发工作流。执行顺序:1、对话开场白;2、全局变量;3、自动执行。",
"auto_save": "自动保存",
"avatar_and_name": "头像 & 名称",
"chat.collapse_deleted_items": "收起已删除记录",
"chat.expand_deleted_items": "展开已删除记录",
"chat_agent_beta_tip": "Agent 模式处于测试阶段,未来可能会进行比较大的交互/调度逻辑变更。",
......@@ -516,7 +520,7 @@
"type.hidden": "隐藏应用",
"type_http_tool_set_intro": "批量导入 API 工具",
"type_mcp_intro": "连接 MCP 服务",
"type_not_recognized": "未识别到应用类型",
"type_not_recognized": "模板应用类型错误",
"type_plugin_description": "一键输出指定结果。",
"type_plugin_intro": "常用于封装工作流",
"type_simple_description": "最基础的对话应用,无需复杂配置,快速创建和使用。",
......
{
"search_skill": "搜索",
"create_skill": "新建技能",
"create_your_first_skill": "创建你的第一个技能",
"no_skills": "暂无 Skill",
"copy_skill": "创建副本",
"related_apps_count": "关联应用 {{count}}",
......@@ -18,6 +19,7 @@
"permission_settings": "权限设置",
"export_config": "导出配置",
"create_folder": "新建文件夹",
"unnamed_skill": "未命名",
"skill_name_placeholder": "请输入 Skill 名称",
"skill_avatar_and_name": "头像 & 名称",
"skill_intro_label": "应用介绍",
......@@ -27,8 +29,8 @@
"skill_requirement_tooltip_example": "## 目标\n根据会议记录自动生成会议纪要。\n\n## 流程\n1. 识别会议主题和参与人员\n2. 提取讨论的关键要点\n3. 整理出明确的结论和决策\n4. 提取需要跟进的行动项,并标注负责人(如有)\n\n## 要求\n1. 结果以结构化格式输出\n2. 包含:会议主题、参与人、讨论要点、决策结论、行动项\n3. 内容简洁清晰,避免冗余描述",
"skill_requirement_default": "## 目标\n\n## 流程\n\n## 要求",
"ai_optimize": "AI 优化",
"import_skill": "导入 Skill",
"import_skill_select_file": "请选择 Skill 压缩包",
"import_skill": "导入技能",
"import_skill_select_file": "上传技能",
"import_skill_file_type_tip": "支持 {{ext}} 格式",
"import_skill_max_size_tip": "单次最多上传 {{maxCount}} 个文件,单个文件最大 {{maxSize}}",
"unsupported_file_format": "不支持 {{ext}} 文件格式",
......
......@@ -48,12 +48,16 @@
"app.version_name_tips": "版本名稱不能空白",
"app.version_past": "已發布過",
"app.version_publish_tips": "此版本將儲存至團隊雲端,同步給整個團隊,同時更新所有發布通道的應用程式版本",
"app_intro": "應用介紹",
"app_intro_placeholder": "介紹使用場景及途徑",
"app_intro_too_long": "應用介紹不能超過 500 字元",
"apply_code": "應用",
"apply_code_failed": "應用代碼失敗",
"auto_execute": "自動執行",
"auto_execute_default_prompt_placeholder": "自動執行時,傳送的預設問題",
"auto_execute_tip": "開啟後,使用者進入對話式介面將自動觸發工作流程。\n執行順序:1、對話開場白;2、全域變數;3、自動執行。",
"auto_save": "自動儲存",
"avatar_and_name": "頭像 & 名稱",
"chat.collapse_deleted_items": "收起已刪除記錄",
"chat.expand_deleted_items": "展開已刪除記錄",
"chat_agent_beta_tip": "Agent 模式處於測試階段,未來可能會進行比較大的互動/調度邏輯變更。",
......@@ -502,7 +506,7 @@
"type.hidden": "隱藏應用",
"type_http_tool_set_intro": "批量導入 API 工具",
"type_mcp_intro": "連接 MCP 服務",
"type_not_recognized": "未識別到應用程式類型",
"type_not_recognized": "模板應用程式類型錯誤",
"type_plugin_description": "一鍵輸出指定結果。",
"type_plugin_intro": "常用於封裝工作流",
"type_simple_description": "最基礎的對話應用,無需複雜配置,快速創建和使用。",
......
{
"search_skill": "搜尋",
"create_skill": "新建技能",
"create_your_first_skill": "創建你的第一個技能",
"no_skills": "暫無 Skill",
"copy_skill": "建立副本",
"related_apps_count": "關聯應用 {{count}}",
......@@ -18,6 +19,7 @@
"permission_settings": "權限設置",
"export_config": "導出配置",
"create_folder": "新建文件夾",
"unnamed_skill": "未命名",
"skill_name_placeholder": "請輸入 Skill 名稱",
"skill_avatar_and_name": "頭像 & 名稱",
"skill_intro_label": "應用介紹",
......@@ -27,8 +29,8 @@
"skill_requirement_tooltip_example": "## 目標\n根據會議記錄自動生成會議紀要。\n\n## 流程\n1. 識別會議主題和參與人員\n2. 提取討論的關鍵要點\n3. 整理出明確的結論和決策\n4. 提取需要跟進的行動項,並標注負責人(如有)\n\n## 要求\n1. 結果以結構化格式輸出\n2. 包含:會議主題、參與人、討論要點、決策結論、行動項\n3. 內容簡潔清晰,避免冗余描述",
"skill_requirement_default": "## 目標\n\n## 流程\n\n## 要求",
"ai_optimize": "AI 優化",
"import_skill": "導入 Skill",
"import_skill_select_file": "請選擇 Skill 壓縮包",
"import_skill": "導入技能",
"import_skill_select_file": "上傳技能",
"import_skill_file_type_tip": "支持 {{ext}} 格式",
"import_skill_max_size_tip": "單次最多上傳 {{maxCount}} 個文件,單個文件最大 {{maxSize}}",
"unsupported_file_format": "不支持 {{ext}} 文件格式",
......
......@@ -8,10 +8,20 @@ import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
type Props = {
value: string;
onChange: (value: string) => void;
onBlur?: (value: string) => void;
onFileChange?: (value: string) => void;
rows?: number;
textareaHeight?: string;
};
const ImportAppConfigEditor = ({ value, onChange, rows = 16 }: Props) => {
const ImportAppConfigEditor = ({
value,
onChange,
onBlur,
onFileChange,
rows = 16,
textareaHeight
}: Props) => {
const { t } = useTranslation();
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
......@@ -45,8 +55,10 @@ const ImportAppConfigEditor = ({ value, onChange, rows = 16 }: Props) => {
if (e.target) {
try {
const res = JSON.parse(e.target.result as string);
onChange(JSON.stringify(res, null, 2));
} catch (error) {
const jsonStr = JSON.stringify(res, null, 2);
onChange(jsonStr);
onFileChange?.(jsonStr);
} catch {
toast({
title: t('app:invalid_json_format'),
status: 'error'
......@@ -56,7 +68,7 @@ const ImportAppConfigEditor = ({ value, onChange, rows = 16 }: Props) => {
};
reader.readAsText(file);
},
[onChange, t, toast]
[onChange, onFileChange, t, toast]
);
const onSelectFile = useCallback(
......@@ -72,7 +84,6 @@ const ImportAppConfigEditor = ({ value, onChange, rows = 16 }: Props) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
console.log(file);
readJSONFile(file);
},
[readJSONFile]
......@@ -80,20 +91,27 @@ const ImportAppConfigEditor = ({ value, onChange, rows = 16 }: Props) => {
return (
<>
<Box w={['100%', '31rem']} h={'full'} display={'flex'} flexDir={'column'}>
<Box
w={'100%'}
maxW={'31rem'}
h={textareaHeight ? 'auto' : 'full'}
display={'flex'}
flexDir={'column'}
>
<Flex justify={'space-between'} align={'center'} pb={3} flexShrink={0}>
<Box fontSize={'sm'} color={'myGray.900'} fontWeight={'500'}>
{t('common:json_config')}
</Box>
<Button onClick={onOpen} variant={'whiteBase'} p={0}>
<Flex px={'0.88rem'} py={'0.44rem'} color={'myGray.600'} fontSize={'mini'}>
<Flex px={'0.88rem'} alignItems={'center'} color={'myGray.600'} fontSize={'mini'}>
<MyIcon name={'file/uploadFile'} w={'1rem'} mr={'0.38rem'} />
{t('common:upload_file')}
</Flex>
</Button>
</Flex>
<Box
flex={1}
flex={textareaHeight ? 'unset' : 1}
h={textareaHeight ?? 'full'}
position={'relative'}
onDragEnter={handleDragEnter}
onDragOver={(e) => e.preventDefault()}
......@@ -101,7 +119,7 @@ const ImportAppConfigEditor = ({ value, onChange, rows = 16 }: Props) => {
onDragLeave={handleDragLeave}
>
<Textarea
bg={'myGray.50'}
bg={'white'}
border={'1px solid'}
borderRadius={'md'}
borderColor={'myGray.200'}
......@@ -109,6 +127,7 @@ const ImportAppConfigEditor = ({ value, onChange, rows = 16 }: Props) => {
placeholder={t('app:or_drag_JSON')}
rows={rows}
onChange={(e) => onChange(e.target.value)}
onBlur={(e) => onBlur?.(e.target.value)}
h={'full'}
resize={'none'}
opacity={isDragging ? 0.3 : 1}
......
......@@ -146,7 +146,9 @@ const AppCard = ({
label: (
<Flex>
<ExportConfigPopover
appType={appDetail.type}
appName={appDetail.name}
appIntro={appDetail.intro}
appForm={appForm}
chatConfig={appDetail.chatConfig}
filterSensitiveInfo={filterSensitiveInfo}
......
......@@ -14,9 +14,12 @@ import { type RequireOnlyOne } from '@fastgpt/global/common/type/utils';
import { type StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { type StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import type { AppTypeEnum } from '@fastgpt/global/core/app/constants';
type ExportConfigPopoverProps = {
appType: AppTypeEnum;
appName: string;
appIntro?: string | null;
chatConfig?: AppChatConfigType;
filterSensitiveInfo?: boolean;
onFilterSensitiveInfoChange?: (value: boolean) => void;
......@@ -34,7 +37,9 @@ const ExportConfigPopover = ({
appForm,
getWorkflowData,
chatConfig,
appType,
appName,
appIntro,
filterSensitiveInfo: filterSensitiveInfoProp,
onFilterSensitiveInfoChange
}: ExportConfigPopoverProps) => {
......@@ -60,21 +65,31 @@ const ExportConfigPopover = ({
let config = '';
if (appForm) {
const appConfig = filterSensitiveInfo ? filterSensitiveFormData(appForm) : appForm;
config = JSON.stringify(
filterSensitiveInfo ? filterSensitiveFormData(appForm) : appForm,
{
...appConfig,
type: appType,
name: appName,
intro: appIntro ?? ''
},
null,
2
);
} else if (getWorkflowData) {
const workflowData = getWorkflowData();
if (!workflowData) return;
const nodes = filterSensitiveInfo
? filterSensitiveNodesData(workflowData.nodes)
: workflowData.nodes;
config = JSON.stringify(
{
nodes: filterSensitiveInfo
? filterSensitiveNodesData(workflowData.nodes)
: workflowData.nodes,
nodes,
edges: workflowData.edges,
chatConfig
chatConfig,
type: appType,
name: appName,
intro: appIntro ?? ''
},
null,
2
......@@ -100,7 +115,17 @@ const ExportConfigPopover = ({
});
}
},
[appForm, appName, chatConfig, copyData, getWorkflowData, t, filterSensitiveInfo]
[
appForm,
appIntro,
appName,
appType,
chatConfig,
copyData,
getWorkflowData,
t,
filterSensitiveInfo
]
);
return (
......@@ -117,7 +142,7 @@ const ExportConfigPopover = ({
</MyBox>
}
>
{({ onClose }) => (
{() => (
<Box p={1} onClick={(e) => e.stopPropagation()}>
<Flex
py={'0.38rem'}
......
......@@ -30,8 +30,8 @@ const AppCard = ({ showSaveStatus, isSaved }: { showSaveStatus: boolean; isSaved
const { isOpen: isOpenImport, onOpen: onOpenImport, onClose: onCloseImport } = useDisclosure();
const InfoMenu = useCallback(
({ children }: { children: React.ReactNode }) => {
const renderInfoMenu = useCallback(
(children: React.ReactNode) => {
return (
<MyPopover
placement={'bottom-end'}
......@@ -41,7 +41,7 @@ const AppCard = ({ showSaveStatus, isSaved }: { showSaveStatus: boolean; isSaved
trigger={'hover'}
Trigger={children}
>
{({ onClose }) => (
{() => (
<Box p={1.5}>
<MyBox
display={'flex'}
......@@ -93,8 +93,10 @@ const AppCard = ({ showSaveStatus, isSaved }: { showSaveStatus: boolean; isSaved
cursor={'pointer'}
>
<ExportConfigPopover
appType={appDetail.type}
chatConfig={appDetail.chatConfig}
appName={appDetail.name}
appIntro={appDetail.intro}
getWorkflowData={flowData2StoreData}
/>
</MyBox>
......@@ -145,9 +147,11 @@ const AppCard = ({ showSaveStatus, isSaved }: { showSaveStatus: boolean; isSaved
},
[
appDetail.chatConfig,
appDetail.intro,
appDetail.name,
appDetail.permission.hasWritePer,
appDetail.permission.isOwner,
appDetail.type,
feConfigs?.show_team_chat,
flowData2StoreData,
onDelApp,
......@@ -189,7 +193,7 @@ const AppCard = ({ showSaveStatus, isSaved }: { showSaveStatus: boolean; isSaved
</Box>
</HStack>
<InfoMenu>
{renderInfoMenu(
<IconButton
aria-label="Expand"
icon={<MyIcon name={'common/select'} w={'18px'} color={'myGray.500'} />}
......@@ -204,18 +208,18 @@ const AppCard = ({ showSaveStatus, isSaved }: { showSaveStatus: boolean; isSaved
bg: 'myGray.50'
}}
/>
</InfoMenu>
)}
{isOpenImport && <ImportSettings onClose={onCloseImport} />}
</HStack>
);
}, [
InfoMenu,
appDetail.avatar,
appDetail.name,
isOpenImport,
isSaved,
onCloseImport,
renderInfoMenu,
showSaveStatus,
t
]);
......
......@@ -9,6 +9,7 @@ import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { removeUnauthModels } from '@fastgpt/global/core/workflow/utils';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { WorkflowUtilsContext } from '../context/workflowUtilsContext';
import { parseWorkflowImportConfig } from '@/pageComponents/dashboard/agent/utils/appTemplateParse';
const ImportAppConfigEditor = dynamic(() => import('@/pageComponents/app/ImportAppConfigEditor'), {
ssr: false
......@@ -51,15 +52,18 @@ const ImportSettings = ({ onClose }: Props) => {
return onClose();
}
try {
const data = JSON.parse(value);
removeUnauthModels({ modules: data.nodes, allowedModels: myModels });
await initData(data);
const workflowConfig = parseWorkflowImportConfig({
config: JSON.parse(value),
t
});
await removeUnauthModels({ modules: workflowConfig.nodes, allowedModels: myModels });
await initData(workflowConfig);
toast({
title: t('app:import_configs_success'),
status: 'success'
});
onClose();
} catch (error) {
} catch {
toast({
title: t('app:import_configs_failed')
});
......
......@@ -17,6 +17,7 @@ import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import { useContextSelector } from 'use-context-selector';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { AppContext } from '../../../context';
import { useTranslation } from 'next-i18next';
import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs';
import { WorkflowBufferDataContext } from '../../context/workflowInitContext';
import LabelAndFormRender from '@/components/core/app/formRender/LabelAndForm';
......@@ -45,6 +46,7 @@ enum TabEnum {
export const useDebug = () => {
const { t } = useSafeTranslation();
const { t: workflowT } = useTranslation();
const { toast } = useToast();
const setNodes = useContextSelector(WorkflowBufferDataContext, (v) => v.setNodes);
......@@ -162,7 +164,7 @@ export const useDebug = () => {
getNodeById,
edges,
chatConfig: appDetail.chatConfig,
t,
t: workflowT,
childrenNodeIdListMap
});
const renderInputs = runtimeNode.inputs.filter((input) => {
......@@ -323,6 +325,7 @@ export const useDebug = () => {
runtimeEdges,
defaultGlobalVariables,
t,
workflowT,
variables.length,
customVar,
internalVar,
......
......@@ -669,7 +669,7 @@ const ForbiddenCreateButton = () => {
backgroundSize: '100% 100%'
}}
>
<MyIcon name={'common/disable'} w={'34px'} color={'#DFE2EA'} zIndex={1} />
<MyIcon name={'common/disable'} w={'26px'} color={'#DFE2EA'} zIndex={1} />
<Box color={'myGray.500'} fontSize={'11px'} fontWeight={'medium'} zIndex={1}>
{t('app:has_no_create_per')}
</Box>
......
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { getAppType, getDefaultAppForm } from '@fastgpt/global/core/app/utils';
import type { AppFormEditFormType } from '@fastgpt/global/core/app/formEdit/type';
import { AppFormEditFormV1TypeSchema } from '@fastgpt/global/core/app/formEdit/type';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import type { StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
import type { AppChatConfigType } from '@fastgpt/global/core/app/type';
import { form2AppWorkflow } from '@/pageComponents/app/detail/Edit/SimpleApp/utils';
export type JsonImportModalScene = 'agent' | 'tool';
type ImportWorkflowConfig = {
nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[];
chatConfig?: AppChatConfigType;
};
type ParsedImportConfig = {
workflow: ImportWorkflowConfig;
appType: AppTypeEnum.simple | AppTypeEnum.workflow | AppTypeEnum.workflowTool;
};
type SupportedImportAppType = ParsedImportConfig['appType'];
const supportedImportAppTypes = [
AppTypeEnum.simple,
AppTypeEnum.workflow,
AppTypeEnum.workflowTool
] as const;
const dashboardImportAppTypesByScene: Record<
JsonImportModalScene,
readonly SupportedImportAppType[]
> = {
agent: [AppTypeEnum.simple, AppTypeEnum.workflow],
tool: [AppTypeEnum.workflowTool]
};
const isSupportedImportAppType = (
type: unknown
): type is (typeof supportedImportAppTypes)[number] =>
supportedImportAppTypes.includes(type as (typeof supportedImportAppTypes)[number]);
export const isDashboardImportAppTypeAllowed = ({
appType,
scene
}: {
appType: SupportedImportAppType;
scene: JsonImportModalScene;
}) => dashboardImportAppTypesByScene[scene].includes(appType);
/**
* 归一化 simple 应用表单配置。
*
* 老版本导出的 simple JSON 可能缺少数组或默认字段,这里只补齐表单编辑器
* 已有默认值并做 schema 校验,避免在转换 workflow 前因为历史字段缺失报错。
*/
export const normalizeSimpleImportForm = (config: Record<string, unknown>) => {
const defaultForm = getDefaultAppForm();
const form = {
...defaultForm,
...config,
aiSettings: {
...defaultForm.aiSettings,
...((config.aiSettings as Record<string, unknown> | undefined) || {})
},
dataset: {
...defaultForm.dataset,
...((config.dataset as Record<string, unknown> | undefined) || {})
},
selectedTools: Array.isArray(config.selectedTools) ? config.selectedTools : [],
selectedAgentSkills: Array.isArray(config.selectedAgentSkills)
? config.selectedAgentSkills
: [],
chatConfig: {
...defaultForm.chatConfig,
...((config.chatConfig as Record<string, unknown> | undefined) || {})
}
};
return AppFormEditFormV1TypeSchema.safeParse(form);
};
export const resolveImportAppType = (config: Record<string, unknown>) => {
const metaType = config.type;
if (metaType !== undefined) {
if (!isSupportedImportAppType(metaType)) {
return '';
}
return metaType;
}
if ('nodes' in config && !Array.isArray(config.nodes)) {
return '';
}
try {
return getAppType(config as any);
} catch {
return '';
}
};
/**
* 解析工作台 JSON 导入配置。
*
* 顶层 `type` 存在时按导出元信息校验业务结构;无 `type` 时回退
* 现有 `getAppType` 结构识别逻辑,以兼容老版本导出 JSON。
*/
export const parseDashboardImportConfig = ({
config,
scene,
t
}: {
config: unknown;
scene: JsonImportModalScene;
t: any;
}): ParsedImportConfig => {
if (!config || typeof config !== 'object') {
throw new Error(t('app:type_not_recognized'));
}
const workflowConfig = config as Record<string, unknown>;
const appType = resolveImportAppType(config as Record<string, unknown>);
if (!appType) {
throw new Error(t('app:type_not_recognized'));
}
if (!isDashboardImportAppTypeAllowed({ appType, scene })) {
throw new Error(t('app:type_not_recognized'));
}
if (appType === AppTypeEnum.simple) {
if (
!workflowConfig.aiSettings ||
typeof workflowConfig.aiSettings !== 'object' ||
Array.isArray(workflowConfig.aiSettings)
) {
throw new Error(t('app:type_not_recognized'));
}
const parsedForm = normalizeSimpleImportForm(workflowConfig);
if (!parsedForm.success) {
throw new Error(t('app:type_not_recognized'));
}
return {
workflow: form2AppWorkflow(parsedForm.data as AppFormEditFormType, t),
appType
};
}
if (!Array.isArray(workflowConfig.nodes)) {
throw new Error(t('app:type_not_recognized'));
}
const matchedStartNodeType = appType === AppTypeEnum.workflow ? 'workflowStart' : 'pluginInput';
const hasMatchedStartNode = workflowConfig.nodes.some(
(node) =>
!!node &&
typeof node === 'object' &&
(node as { flowNodeType?: unknown }).flowNodeType === matchedStartNodeType
);
if (!hasMatchedStartNode) {
throw new Error(t('app:type_not_recognized'));
}
return {
workflow: {
nodes: workflowConfig.nodes as StoreNodeItemType[],
edges: Array.isArray(workflowConfig.edges)
? (workflowConfig.edges as StoreEdgeItemType[])
: [],
chatConfig: (workflowConfig.chatConfig || {}) as AppChatConfigType
},
appType
};
};
/**
* 解析工作流详情内的 JSON 导入配置。
*
* 该入口只允许导入 workflow 配置。导出的 `name`、`intro` 等应用元信息
* 只用于工作台新建应用,工作流内部导入时会忽略。
*/
export const parseWorkflowImportConfig = ({ config, t }: { config: unknown; t: any }) => {
const { workflow, appType } = parseDashboardImportConfig({ config, scene: 'agent', t });
if (appType !== AppTypeEnum.workflow) {
throw new Error(t('app:type_not_recognized'));
}
return workflow;
};
......@@ -83,7 +83,9 @@ const ImportSkillModal = ({ parentId, onClose, onSuccess }: Props) => {
({ name, avatar, file }: ValidImportSkillFormType) => {
const formData = new FormData();
formData.append('file', file);
formData.append('name', name);
if (name.trim()) {
formData.append('name', name.trim());
}
formData.append('avatar', avatar);
if (parentId) formData.append('parentId', parentId);
return importSkill(formData);
......@@ -102,6 +104,7 @@ const ImportSkillModal = ({ parentId, onClose, onSuccess }: Props) => {
if (!data.file) return;
await onImport({
...data,
name: data.name.trim(),
file: data.file
});
};
......@@ -173,24 +176,110 @@ const ImportSkillModal = ({ parentId, onClose, onSuccess }: Props) => {
closeOnOverlayClick={false}
footer={
<>
<Button variant={'whiteBase'} onClick={onClose}>
<Button h={'32px'} variant={'whiteBase'} onClick={onClose}>
{t('common:Cancel')}
</Button>
<Button isLoading={isImporting} onClick={handleSubmit(handleImport, handleInvalid)}>
<Button
h={'32px'}
isLoading={isImporting}
onClick={handleSubmit(handleImport, handleInvalid)}
>
{t('common:Confirm')}
</Button>
</>
}
>
<Flex flexDirection={'column'} gap={6}>
<Flex flexDirection={'column'} gap={4}>
<Box>
<FormLabel mb={2}>{t('skill:import_skill_select_file')}</FormLabel>
{selectedFile ? (
<Flex
h={'220px'}
alignItems={'center'}
justifyContent={'center'}
gap={2}
border={'1px solid'}
borderColor={'myGray.200'}
borderRadius={'md'}
p={3}
>
<MyIcon
name={'common/importLight'}
w={'24px'}
flexShrink={0}
color={'myGray.500'}
/>
<Box maxW={'260px'} fontSize={'sm'} color={'myGray.700'} isTruncated>
{selectedFile.name}
</Box>
<Box fontSize={'xs'} color={'myGray.500'} flexShrink={0}>
{formatFileSize(selectedFile.size)}
</Box>
<Box
cursor={'pointer'}
color={'myGray.400'}
_hover={{ color: 'myGray.700' }}
onClick={() =>
setValue('file', undefined, {
shouldDirty: true,
shouldValidate: true
})
}
flexShrink={0}
>
<MyIcon name={'common/closeLight'} w={'16px'} />
</Box>
</Flex>
) : (
<Flex
h={'220px'}
flexDirection={'column'}
alignItems={'center'}
justifyContent={'center'}
px={3}
borderWidth={'1.5px'}
borderStyle={'dashed'}
borderRadius={'md'}
cursor={'pointer'}
borderColor={isDragging ? 'primary.600' : 'borderColor.high'}
_hover={{ bg: 'primary.50', borderColor: 'primary.600' }}
onDragEnter={handleDragEnter}
onDragOver={(e) => e.preventDefault()}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={onOpen}
>
<MyIcon name={'common/uploadFileFill'} w={'32px'} color={'primary.600'} />
<Box fontWeight={'bold'} mt={2}>
{isDragging
? t('file:release_the_mouse_to_upload_the_file')
: t('file:select_and_drag_file_tip')}
</Box>
<Box color={'myGray.500'} fontSize={'xs'} mt={1}>
{t('skill:import_skill_file_type_tip', {
ext: ACCEPT_TYPES.split(',').join(' ')
})}
</Box>
{typeof maxUploadBytes === 'number' && (
<Box color={'myGray.500'} fontSize={'xs'}>
{t('skill:import_skill_max_size_tip', {
maxCount: 1,
maxSize: formatFileSize(maxUploadBytes)
})}
</Box>
)}
</Flex>
)}
</Box>
<Box>
<FormLabel mb={2}>{t('common:input_name')}</FormLabel>
<FormLabel mb={2}>{t('skill:skill_avatar_and_name')}</FormLabel>
<Flex alignItems={'center'}>
<MyTooltip label={t('common:set_avatar')}>
<Flex
borderRadius={'6px'}
w={'34px'}
h={'34px'}
borderRadius={'4px'}
w={'32px'}
h={'32px'}
border={'1px solid'}
borderColor={'myGray.200'}
justifyContent={'center'}
......@@ -200,91 +289,20 @@ const ImportSkillModal = ({ parentId, onClose, onSuccess }: Props) => {
cursor={'pointer'}
onClick={handleAvatarSelectorOpen}
>
<Avatar src={avatar} w={'24px'} borderRadius={'6px'} />
<Avatar src={avatar} w={'24px'} borderRadius={'4px'} />
</Flex>
</MyTooltip>
<Input
flex={1}
size={'sm'}
autoFocus
placeholder={t('skill:skill_name_placeholder')}
h={'32px'}
placeholder={t('skill:unnamed_skill')}
{...register('name', {
required: true,
setValueAs: (value: string) => value.trim()
})}
/>
</Flex>
</Box>
{selectedFile ? (
<Flex
alignItems={'center'}
gap={2}
border={'1px solid'}
borderColor={'myGray.200'}
borderRadius={'md'}
p={3}
>
<MyIcon name={'common/importLight'} w={'24px'} flexShrink={0} color={'myGray.500'} />
<Box flex={1} fontSize={'sm'} color={'myGray.700'} isTruncated>
{selectedFile.name}
</Box>
<Box fontSize={'xs'} color={'myGray.500'} flexShrink={0}>
{formatFileSize(selectedFile.size)}
</Box>
<Box
cursor={'pointer'}
color={'myGray.400'}
_hover={{ color: 'myGray.700' }}
onClick={() =>
setValue('file', undefined, {
shouldDirty: true,
shouldValidate: true
})
}
flexShrink={0}
>
<MyIcon name={'common/closeLight'} w={'16px'} />
</Box>
</Flex>
) : (
<Flex
flexDirection={'column'}
alignItems={'center'}
justifyContent={'center'}
px={3}
py={7}
borderWidth={'1.5px'}
borderStyle={'dashed'}
borderRadius={'md'}
cursor={'pointer'}
borderColor={isDragging ? 'primary.600' : 'borderColor.high'}
_hover={{ bg: 'primary.50', borderColor: 'primary.600' }}
onDragEnter={handleDragEnter}
onDragOver={(e) => e.preventDefault()}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={onOpen}
>
<MyIcon name={'common/uploadFileFill'} w={'32px'} />
<Box fontWeight={'bold'} mt={2}>
{isDragging
? t('file:release_the_mouse_to_upload_the_file')
: t('file:select_and_drag_file_tip')}
</Box>
<Box color={'myGray.500'} fontSize={'xs'} mt={1}>
{t('skill:import_skill_file_type_tip', { ext: ACCEPT_TYPES.split(',').join(' ') })}
</Box>
{typeof maxUploadBytes === 'number' && (
<Box color={'myGray.500'} fontSize={'xs'}>
{t('skill:import_skill_max_size_tip', {
maxCount: 1,
maxSize: formatFileSize(maxUploadBytes)
})}
</Box>
)}
</Flex>
)}
<FileInput onSelect={(files) => files[0] && handleFile(files[0])} />
</Flex>
</MyModal>
......
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Grid, IconButton, HStack, Flex } from '@chakra-ui/react';
import { Box, Grid, IconButton, HStack, Flex, VStack } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import MyIcon from '@fastgpt/web/components/common/Icon';
......@@ -47,6 +47,7 @@ import type {
import ListCreateCard from '@/pageComponents/dashboard/ListCreateCard';
import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList';
import { getWebReqUrl } from '@fastgpt/web/common/system/utils';
const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal'));
const MoveModal = dynamic(() => import('@/components/common/folder/MoveModal'));
......@@ -190,13 +191,14 @@ const List = ({
const router = useRouter();
const { isPc } = useSystem();
const { skills, loadSkills, isFetchingSkills, searchKey } = useContextSelector(
const { skills, loadSkills, isFetchingSkills, searchKey, folderDetail } = useContextSelector(
SkillListContext,
(v) => ({
skills: v.skills,
loadSkills: v.loadSkills,
isFetchingSkills: v.isFetchingSkills,
searchKey: v.searchKey
searchKey: v.searchKey,
folderDetail: v.folderDetail
})
);
......@@ -206,9 +208,9 @@ const List = ({
const { gridRef, renderVirtualGridItems } = useVirtualGridList({
list: skills,
listKey: `${router.pathname}-${router.query.parentId || ''}-${searchKey}`,
reservedSlotCount: onClickCreate && !searchKey ? 1 : 0,
reservedSlotCount: 1,
estimatedRowHeight: 160,
estimatedRowGap: 12
estimatedRowGap: 20
});
const selectedSkill = useMemo(
......@@ -523,28 +525,45 @@ const List = ({
if (skills.length === 0 && isFetchingSkills) return null;
if (skills.length === 0 && (!onClickCreate || !!searchKey)) {
return <EmptyTip text={searchKey ? undefined : t('skill:no_skills')} />;
}
return (
<>
<Grid
ref={gridRef}
py={4}
gridTemplateColumns={[
'1fr',
'repeat(2,1fr)',
'repeat(2,1fr)',
'repeat(3,1fr)',
'repeat(4,1fr)'
]}
gridGap={3}
alignItems={'stretch'}
>
{onClickCreate && !searchKey && <ListCreateCard onClick={onClickCreate} />}
{renderVirtualGridItems(renderSkillCard)}
</Grid>
{skills.length === 0 && !folderDetail ? (
searchKey ? (
<EmptyTip />
) : isPc && onClickCreate ? (
<CreateButton onClick={onClickCreate} />
) : (
<Grid
py={4}
gridTemplateColumns={[
'1fr',
'repeat(2,1fr)',
'repeat(2,1fr)',
'repeat(3,1fr)',
'repeat(4,1fr)'
]}
gridGap={5}
alignItems={'stretch'}
>
{onClickCreate ? <ListCreateCard onClick={onClickCreate} /> : <ForbiddenCreateButton />}
</Grid>
)
) : (
<Grid
ref={gridRef}
py={4}
gridTemplateColumns={
folderDetail
? ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)']
: ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)']
}
gridGap={5}
alignItems={'stretch'}
>
{onClickCreate ? <ListCreateCard onClick={onClickCreate} /> : <ForbiddenCreateButton />}
{renderVirtualGridItems(renderSkillCard)}
</Grid>
)}
<DeleteConfirmModal />
<ConfirmCopyModal />
{!!editedSkill && (
......@@ -607,4 +626,123 @@ 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 (
<MyBox
py={4}
px={5}
cursor={'not-allowed'}
border={'base'}
bg={'white'}
borderRadius={'10px'}
position={'relative'}
display={'flex'}
flexDirection={'column'}
>
<Box color={'myGray.900'} fontWeight={'medium'}>
{t('common:new_create')}
</Box>
<Box
mt={4}
mb={2}
h={'100%'}
w={'100%'}
display={'flex'}
alignItems={'center'}
justifyContent={'center'}
position={'relative'}
flex={'1 0 56px'}
>
<Box
position={'absolute'}
top={'1px'}
left={'1px'}
right={'1px'}
bottom={'1px'}
bg={'myGray.50'}
borderRadius={'14px'}
/>
<Box
w={'100%'}
h={'100%'}
display={'flex'}
flexDirection={'column'}
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='%23D7D7D7' stroke-width='1' stroke-dasharray='6 6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat center`,
backgroundSize: '100% 100%'
}}
>
<MyIcon name={'common/disable'} w={'26px'} color={'#DFE2EA'} zIndex={1} />
<Box color={'myGray.500'} fontSize={'11px'} fontWeight={'medium'} zIndex={1}>
{t('app:has_no_create_per')}
</Box>
</Box>
</Box>
</MyBox>
);
};
export default List;
import React, { type Dispatch, type ReactNode, type SetStateAction, useState } from 'react';
import { createContext } from 'use-context-selector';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { getSkillList, getSkillFolderPath } from '@/web/core/skill/api';
import { getSkillList, getSkillFolderPath, getSkillDetail } from '@/web/core/skill/api';
import type { ListSkillsResponse } from '@fastgpt/global/core/ai/skill/api';
import type { ParentTreePathItemType } from '@fastgpt/global/common/parentFolder/type';
import { useRouter } from 'next/router';
import { SkillPermission } from '@fastgpt/global/support/permission/skill/controller';
import type { SkillPermission } from '@fastgpt/global/support/permission/skill/controller';
export type SkillListItemType = Omit<
ListSkillsResponse['list'][number],
......@@ -24,6 +24,9 @@ type SkillListContextType = {
setSearchKey: Dispatch<SetStateAction<string>>;
parentId: string | null;
paths: ParentTreePathItemType[];
folderDetail?: {
permission: SkillPermission;
};
};
export const SkillListContext = createContext<SkillListContextType>({
......@@ -37,7 +40,8 @@ export const SkillListContext = createContext<SkillListContextType>({
throw new Error('Function not implemented.');
},
parentId: null,
paths: []
paths: [],
folderDetail: undefined
});
const SkillListContextProvider = ({ children }: { children: ReactNode }) => {
......@@ -59,8 +63,7 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => {
res.list.map((item) => ({
...item,
createTime: new Date(item.createTime),
updateTime: new Date(item.updateTime),
permission: new SkillPermission({ role: item.permission ?? 0 })
updateTime: new Date(item.updateTime)
}))
),
{
......@@ -83,6 +86,19 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => {
}
);
const { data: folderDetail } = useRequest(
() => {
if (!parentId) return Promise.resolve(undefined);
return getSkillDetail({ skillId: parentId }).then((res) => ({
permission: res.permission
}));
},
{
manual: false,
refreshDeps: [parentId]
}
);
const contextValue: SkillListContextType = {
skills: data || [],
isFetchingSkills,
......@@ -90,7 +106,8 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => {
searchKey,
setSearchKey,
parentId,
paths
paths,
folderDetail
};
return <SkillListContext.Provider value={contextValue}>{children}</SkillListContext.Provider>;
......
......@@ -10,7 +10,6 @@ import {
} from '@fastgpt/global/core/ai/skill/constants';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { getSkillDetail, streamCreateEditDebugSandbox } from '@/web/core/skill/api';
import { SkillPermission } from '@fastgpt/global/support/permission/skill/controller';
export enum TabEnum {
config = 'config',
......@@ -180,7 +179,7 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
createTime: new Date(res.createTime),
updateTime: new Date(res.updateTime),
appCount: res.appCount ?? 0,
permission: new SkillPermission({ role: res.permission ?? 0 })
permission: res.permission
};
return detail;
});
......
......@@ -54,7 +54,8 @@ async function handler(
const userDetail = await getUserDetail({
tmbId: user?.lastLoginTmbId,
userId: user._id
userId: user._id,
isRoot: username === 'root'
});
user.lastLoginTmbId = userDetail.team.tmbId;
......
......@@ -6,8 +6,8 @@ import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils';
import type { UserType } from '@fastgpt/global/support/user/type';
async function handler(req: ApiRequestProps, _res: ApiResponseType): Promise<UserType> {
const { tmbId, userId, teamId } = await authCert({ req, authToken: true });
const user = await getUserDetail({ tmbId });
const { tmbId, userId, teamId, isRoot } = await authCert({ req, authToken: true });
const user = await getUserDetail({ tmbId, isRoot });
pushTrack.dailyUserActive({
uid: userId,
......
......@@ -239,7 +239,9 @@ const MyApps = ({ MenuIcon }: { MenuIcon: JSX.Element }) => {
onEdit={({ id, ...data }) => onUpdateApp(id, data)}
/>
)}
{isOpenJsonImportModal && <JsonImportModal onClose={onCloseJsonImportModal} />}
{isOpenJsonImportModal && (
<JsonImportModal scene={'agent'} onClose={onCloseJsonImportModal} />
)}
</Flex>
);
};
......
......@@ -40,8 +40,16 @@ const SkillPageContent = ({ MenuIcon }: { MenuIcon: JSX.Element }) => {
const { guardSkillSandboxOperation, SkillSandboxOperationGuardModal } =
useSkillSandboxOperationGuard();
const { skills, isFetchingSkills, loadSkills, searchKey, setSearchKey, parentId, paths } =
useContextSelector(SkillListContext, (v) => v);
const {
skills,
isFetchingSkills,
loadSkills,
searchKey,
setSearchKey,
parentId,
paths,
folderDetail
} = useContextSelector(SkillListContext, (v) => v);
const { runAsync: onCreateFolder } = useRequest(postCreateSkillFolder, {
onSuccess() {
......@@ -59,7 +67,9 @@ const SkillPageContent = ({ MenuIcon }: { MenuIcon: JSX.Element }) => {
});
};
const hasCreatePer = !!userInfo?.team.permission.hasSkillCreatePer;
const hasCreatePer = folderDetail
? folderDetail.permission.hasWritePer
: userInfo?.team.permission.hasSkillCreatePer;
return (
<Flex flexDirection={'column'} h={'100%'}>
......
......@@ -234,7 +234,7 @@ const MyTools = ({ MenuIcon }: { MenuIcon: JSX.Element }) => {
onEdit={({ id, ...data }) => onUpdateApp(id, data)}
/>
)}
{isOpenJsonImportModal && <JsonImportModal onClose={onCloseJsonImportModal} />}
{isOpenJsonImportModal && <JsonImportModal scene={'tool'} onClose={onCloseJsonImportModal} />}
</Flex>
);
};
......
......@@ -45,7 +45,11 @@ export const getSkillFolderList = ({ parentId }: GetResourceFolderListProps) =>
source: 'mine',
type: AgentSkillTypeEnum.folder,
parentId: parentId ?? null
}).then((res) => res.list.map((item) => ({ id: item._id, name: item.name })));
}).then((res) =>
res.list
.filter((item) => item.permission.hasWritePer)
.map((item) => ({ id: item._id, name: item.name }))
);
/** 获取 Skill 详情 */
export const getSkillDetail = (data: GetSkillDetailQuery) =>
......
......@@ -54,6 +54,26 @@ describe('tokenLogin API', () => {
expect(res.data.team.tmbId).toBe(String(testTmb._id));
});
it('should return owner permissions for root session', async () => {
const res = await Call(tokenLoginApi.default, {
auth: {
userId: String(testUser._id),
teamId: String(testTeam._id),
tmbId: String(testTmb._id),
isRoot: true,
sessionId: 'session123'
} as any
});
expect(res.code).toBe(200);
expect(res.data.permission.isOwner).toBe(true);
expect(res.data.permission.hasAppCreatePer).toBe(true);
expect(res.data.permission.hasSkillCreatePer).toBe(true);
expect(res.data.team.permission.isOwner).toBe(true);
expect(res.data.team.permission.hasAppCreatePer).toBe(true);
expect(res.data.team.permission.hasSkillCreatePer).toBe(true);
});
it('should call pushTrack.dailyUserActive', async () => {
await Call(tokenLoginApi.default, {
auth: {
......
import { describe, expect, it, vi } from 'vitest';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { DatasetSearchModeEnum } from '@fastgpt/global/core/dataset/constants';
vi.mock('@/pageComponents/app/detail/Edit/SimpleApp/utils', () => ({
form2AppWorkflow: vi.fn((data) => ({
nodes: [
{
flowNodeType: 'workflowStart',
formData: data
}
],
edges: [],
chatConfig: data.chatConfig
}))
}));
const {
normalizeSimpleImportForm,
parseDashboardImportConfig,
parseWorkflowImportConfig,
resolveImportAppType,
isDashboardImportAppTypeAllowed
} = await import('@/pageComponents/dashboard/agent/utils/appTemplateParse');
const t = (key: string) => key;
const createSimpleConfig = (extra: Record<string, unknown> = {}) => ({
aiSettings: {
model: 'gpt-4o',
isResponseAnswerText: true,
maxHistories: 6
},
dataset: {
datasets: [],
similarity: 0.4,
limit: 3000,
searchMode: DatasetSearchModeEnum.embedding,
usingReRank: true,
rerankModel: '',
rerankWeight: 0.5,
datasetSearchUsingExtensionQuery: true,
datasetSearchExtensionBg: ''
},
selectedTools: [],
selectedAgentSkills: [],
chatConfig: {},
...extra
});
describe('normalizeSimpleImportForm', () => {
it('should fill missing simple form arrays and default fields', () => {
const result = normalizeSimpleImportForm({
aiSettings: {
model: 'gpt-4o',
isResponseAnswerText: true,
maxHistories: 3
},
dataset: {
datasets: []
},
chatConfig: {}
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.selectedTools).toEqual([]);
expect(result.data.selectedAgentSkills).toEqual([]);
expect(result.data.dataset.limit).toBe(3000);
expect(result.data.dataset.searchMode).toBe(DatasetSearchModeEnum.embedding);
});
});
describe('resolveImportAppType', () => {
it('should prefer supported top-level type from new JSON', () => {
expect(
resolveImportAppType({
type: AppTypeEnum.workflow,
nodes: [{ flowNodeType: 'pluginInput' }]
})
).toBe(AppTypeEnum.workflow);
});
it('should reject chatAgent and unknown top-level type', () => {
expect(resolveImportAppType({ type: AppTypeEnum.chatAgent })).toBe('');
expect(resolveImportAppType({ type: 'workflow' })).toBe('');
});
it('should fall back to old JSON structure detection when type is missing', () => {
expect(resolveImportAppType(createSimpleConfig())).toBe(AppTypeEnum.simple);
expect(
resolveImportAppType({
nodes: [{ flowNodeType: 'workflowStart' }],
edges: []
})
).toBe(AppTypeEnum.workflow);
expect(
resolveImportAppType({
nodes: [{ flowNodeType: 'pluginInput' }],
edges: []
})
).toBe(AppTypeEnum.workflowTool);
});
it('should return empty type for malformed node list items', () => {
expect(
resolveImportAppType({
nodes: [null]
})
).toBe('');
});
});
describe('parseDashboardImportConfig', () => {
it('should parse simple JSON in agent dashboard and ignore import meta', () => {
const result = parseDashboardImportConfig({
config: createSimpleConfig({
type: AppTypeEnum.simple,
name: 'Simple app',
intro: 'Simple intro'
}),
scene: 'agent',
t
});
expect(result.appType).toBe(AppTypeEnum.simple);
expect(result.workflow.nodes[0].flowNodeType).toBe('workflowStart');
expect((result.workflow.nodes[0] as any).formData).not.toHaveProperty('type');
expect((result.workflow.nodes[0] as any).formData).not.toHaveProperty('name');
expect((result.workflow.nodes[0] as any).formData).not.toHaveProperty('intro');
});
it('should parse workflow JSON in agent dashboard', () => {
const result = parseDashboardImportConfig({
config: {
type: AppTypeEnum.workflow,
name: 'Workflow',
intro: 'Workflow intro',
nodes: [{ flowNodeType: 'workflowStart' }],
edges: [{ source: 'a', sourceHandle: 'a-out', target: 'b', targetHandle: 'b-in' }],
chatConfig: { welcomeText: 'hello' }
},
scene: 'agent',
t
});
expect(result).toEqual({
appType: AppTypeEnum.workflow,
workflow: {
nodes: [{ flowNodeType: 'workflowStart' }],
edges: [{ source: 'a', sourceHandle: 'a-out', target: 'b', targetHandle: 'b-in' }],
chatConfig: { welcomeText: 'hello' }
}
});
});
it('should parse workflow tool JSON in tool dashboard', () => {
const result = parseDashboardImportConfig({
config: {
type: AppTypeEnum.workflowTool,
nodes: [{ flowNodeType: 'pluginInput' }],
edges: []
},
scene: 'tool',
t
});
expect(result.appType).toBe(AppTypeEnum.workflowTool);
});
it('should reject workflow tool JSON in agent dashboard', () => {
expect(() =>
parseDashboardImportConfig({
config: {
type: AppTypeEnum.workflowTool,
nodes: [{ flowNodeType: 'pluginInput' }],
edges: []
},
scene: 'agent',
t
})
).toThrow('app:type_not_recognized');
});
it('should reject simple and workflow JSON in tool dashboard', () => {
expect(() =>
parseDashboardImportConfig({
config: createSimpleConfig({
type: AppTypeEnum.simple
}),
scene: 'tool',
t
})
).toThrow('app:type_not_recognized');
expect(() =>
parseDashboardImportConfig({
config: {
type: AppTypeEnum.workflow,
nodes: [{ flowNodeType: 'workflowStart' }],
edges: []
},
scene: 'tool',
t
})
).toThrow('app:type_not_recognized');
});
it('should reject chatAgent and unknown typed JSON with existing type_not_recognized text', () => {
expect(() =>
parseDashboardImportConfig({
config: { type: AppTypeEnum.chatAgent },
scene: 'agent',
t
})
).toThrow('app:type_not_recognized');
expect(() =>
parseDashboardImportConfig({
config: { type: 'workflow' },
scene: 'agent',
t
})
).toThrow('app:type_not_recognized');
});
it('should reject top-level type and structure mismatch', () => {
expect(() =>
parseDashboardImportConfig({
config: {
type: AppTypeEnum.workflow,
nodes: [{ flowNodeType: 'pluginInput' }],
edges: []
},
scene: 'agent',
t
})
).toThrow('app:type_not_recognized');
expect(() =>
parseDashboardImportConfig({
config: {
type: AppTypeEnum.simple,
nodes: [{ flowNodeType: 'workflowStart' }],
edges: []
},
scene: 'agent',
t
})
).toThrow('app:type_not_recognized');
});
it('should reject malformed old workflow JSON safely', () => {
expect(() =>
parseDashboardImportConfig({
config: {
nodes: {}
},
scene: 'agent',
t
})
).toThrow('app:type_not_recognized');
});
});
describe('isDashboardImportAppTypeAllowed', () => {
it('should match app type with dashboard scene', () => {
expect(isDashboardImportAppTypeAllowed({ appType: AppTypeEnum.simple, scene: 'agent' })).toBe(
true
);
expect(isDashboardImportAppTypeAllowed({ appType: AppTypeEnum.workflow, scene: 'agent' })).toBe(
true
);
expect(
isDashboardImportAppTypeAllowed({ appType: AppTypeEnum.workflowTool, scene: 'agent' })
).toBe(false);
expect(
isDashboardImportAppTypeAllowed({ appType: AppTypeEnum.workflowTool, scene: 'tool' })
).toBe(true);
});
});
describe('parseWorkflowImportConfig', () => {
it('should parse workflow JSON and ignore app meta in workflow detail import', () => {
const result = parseWorkflowImportConfig({
config: {
type: AppTypeEnum.workflow,
name: 'Workflow name',
intro: 'Workflow intro',
nodes: [{ flowNodeType: 'workflowStart' }],
edges: [{ source: 'a', sourceHandle: 'a-out', target: 'b', targetHandle: 'b-in' }],
chatConfig: { welcomeText: 'hello' }
},
t
});
expect(result).toEqual({
nodes: [{ flowNodeType: 'workflowStart' }],
edges: [{ source: 'a', sourceHandle: 'a-out', target: 'b', targetHandle: 'b-in' }],
chatConfig: { welcomeText: 'hello' }
});
});
it('should reject non-workflow JSON in workflow detail import', () => {
expect(() =>
parseWorkflowImportConfig({
config: {
type: AppTypeEnum.workflowTool,
nodes: [{ flowNodeType: 'pluginInput' }],
edges: []
},
t
})
).toThrow('app:type_not_recognized');
expect(() =>
parseWorkflowImportConfig({
config: createSimpleConfig({
type: AppTypeEnum.simple
}),
t
})
).toThrow('app:type_not_recognized');
});
});
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