From 77c7e1b7054f3b1143a4022016261c710123faca Mon Sep 17 00:00:00 2001 From: DigHuang <114602213+DigHuang@users.noreply.github.com> Date: Tue, 19 May 2026 16:05:31 +0800 Subject: [PATCH] feat(skill): create pending skills asynchronously --- packages/global/core/agentSkills/constants.ts | 6 + packages/global/core/agentSkills/type.ts | 7 +- .../global/openapi/core/agentSkills/api.ts | 3 + packages/service/common/bullmq/index.ts | 2 +- .../service/core/agentSkills/controller.ts | 52 ++- packages/service/core/agentSkills/creation.ts | 313 ++++++++++++++++++ packages/service/core/agentSkills/schema.ts | 22 +- .../dashboard/skill/CreateSkillModal.tsx | 5 +- .../pageComponents/dashboard/skill/List.tsx | 180 +++++----- .../dashboard/skill/detail/Header.tsx | 55 +-- .../skill/detail/config/SandboxError.tsx | 20 +- .../dashboard/skill/detail/context.tsx | 49 ++- .../src/pages/api/core/agentSkills/create.ts | 145 +++----- .../src/pages/api/core/agentSkills/detail.ts | 4 +- .../src/pages/api/core/agentSkills/edit.ts | 9 +- .../src/pages/api/core/agentSkills/list.ts | 1 + .../pages/api/core/agentSkills/save-deploy.ts | 9 +- .../src/pages/api/core/agentSkills/update.ts | 7 +- .../app/src/pages/dashboard/skill/index.tsx | 6 +- .../app/src/service/common/bullmq/index.ts | 2 + 20 files changed, 652 insertions(+), 245 deletions(-) create mode 100644 packages/service/core/agentSkills/creation.ts diff --git a/packages/global/core/agentSkills/constants.ts b/packages/global/core/agentSkills/constants.ts index 042d42f41a42..c54257edd8ee 100644 --- a/packages/global/core/agentSkills/constants.ts +++ b/packages/global/core/agentSkills/constants.ts @@ -83,6 +83,12 @@ export const agentSkillsVersionCollectionName = 'agent_skills_versions'; export const skillSandboxCollectionName = 'skill_sandbox_info'; +export enum AgentSkillCreationStatusEnum { + creating = 'creating', + ready = 'ready', + failed = 'failed' +} + // Agent Skill types export enum AgentSkillTypeEnum { folder = 'folder', diff --git a/packages/global/core/agentSkills/type.ts b/packages/global/core/agentSkills/type.ts index 11abe9566707..1afe954fea4f 100644 --- a/packages/global/core/agentSkills/type.ts +++ b/packages/global/core/agentSkills/type.ts @@ -3,6 +3,7 @@ import { AgentSkillSourceEnum, AgentSkillCategoryEnum, AgentSkillTypeEnum, + AgentSkillCreationStatusEnum, SandboxProtocolEnum, SandboxTypeEnum } from './constants'; @@ -17,6 +18,7 @@ const BufferSchema = z.custom( export const AgentSkillSourceSchema = z.enum(AgentSkillSourceEnum); export const AgentSkillCategorySchema = z.enum(AgentSkillCategoryEnum); export const AgentSkillTypeSchema = z.enum(AgentSkillTypeEnum); +export const AgentSkillCreationStatusSchema = z.enum(AgentSkillCreationStatusEnum); export const SandboxProtocolSchema = z.enum(SandboxProtocolEnum); export const SandboxTypeSchema = z.enum(SandboxTypeEnum); export const SandboxStatusSchema = z.enum([ @@ -76,7 +78,9 @@ export const AgentSkillSchema = z.object({ deleteTime: z.coerce.date().nullable().optional(), currentVersion: z.number(), versionCount: z.number(), - currentStorage: AgentSkillStorageSchema.optional() + currentStorage: AgentSkillStorageSchema.optional(), + creationStatus: AgentSkillCreationStatusSchema.optional(), + creationError: z.string().optional() }); export type AgentSkillSchemaType = z.infer; @@ -91,6 +95,7 @@ export const AgentSkillListItemSchema = z.object({ author: z.string(), category: z.array(AgentSkillCategorySchema), avatar: z.string().optional(), + creationStatus: AgentSkillCreationStatusSchema.optional(), createTime: z.coerce.date(), updateTime: z.coerce.date(), appCount: z.number().optional(), diff --git a/packages/global/openapi/core/agentSkills/api.ts b/packages/global/openapi/core/agentSkills/api.ts index d0a875a2590e..7936bfd61800 100644 --- a/packages/global/openapi/core/agentSkills/api.ts +++ b/packages/global/openapi/core/agentSkills/api.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { TeamMemberStatusEnum } from '../../../support/user/team/constant'; import { AgentSkillCategorySchema, + AgentSkillCreationStatusSchema, AgentSkillListItemSchema, AgentSkillSourceSchema, AgentSkillStorageSchema, @@ -125,6 +126,8 @@ export const GetSkillDetailResponseSchema = z.object({ category: z.array(AgentSkillCategorySchema), config: AgentSkillConfigSchema, avatar: z.string().optional(), + creationStatus: AgentSkillCreationStatusSchema.optional(), + creationError: z.string().optional(), teamId: z.string().optional(), tmbId: z.string().optional(), createTime: z.string(), diff --git a/packages/service/common/bullmq/index.ts b/packages/service/common/bullmq/index.ts index 00f55dd40852..6165f204649c 100644 --- a/packages/service/common/bullmq/index.ts +++ b/packages/service/common/bullmq/index.ts @@ -10,7 +10,6 @@ import { import { getLogger, LogCategories } from '../logger'; import { newQueueRedisConnection, newWorkerRedisConnection } from '../redis'; import { delay } from '@fastgpt/global/common/system/utils'; -import { getErrText } from '@fastgpt/global/common/error/utils'; const logger = getLogger(LogCategories.INFRA.QUEUE); @@ -28,6 +27,7 @@ export enum QueueNames { evaluation = 'evaluation', s3FileDelete = 's3FileDelete', collectionUpdate = 'collectionUpdate', + agentSkillCreate = 'agentSkillCreate', // Delete Queue datasetDelete = 'datasetDelete', diff --git a/packages/service/core/agentSkills/controller.ts b/packages/service/core/agentSkills/controller.ts index 8d9d99a6eb43..de5720fb445c 100644 --- a/packages/service/core/agentSkills/controller.ts +++ b/packages/service/core/agentSkills/controller.ts @@ -2,6 +2,7 @@ import { MongoAgentSkills } from './schema'; import { MongoAgentSkillsVersion } from './version/schema'; import { AgentSkillSourceEnum, + AgentSkillCreationStatusEnum, AgentSkillTypeEnum } from '@fastgpt/global/core/agentSkills/constants'; import type { AgentSkillSchemaType, SkillPackageType } from '@fastgpt/global/core/agentSkills/type'; @@ -27,6 +28,11 @@ type CreateSkillData = { avatar?: string; teamId: string; tmbId: string; + creationStatus?: AgentSkillCreationStatusEnum; + creationPayload?: { + requirements?: string; + model?: string; + }; }; // UpdateSkillData excludes markdown to ensure consistency with version management @@ -48,6 +54,8 @@ export async function createSkill(data: CreateSkillData, session?: ClientSession source: AgentSkillSourceEnum.personal, currentVersion: 0, versionCount: 0, + creationStatus: data.creationStatus ?? AgentSkillCreationStatusEnum.ready, + creationPayload: data.creationPayload, updateTime: new Date() }); await skill.save({ session }); @@ -91,10 +99,48 @@ export async function updateCurrentStorage( size: number; }, session?: ClientSession -): Promise { +): Promise { + const result = await MongoAgentSkills.updateOne( + { _id: skillId, deleteTime: null }, + { + $set: { + currentStorage: storageInfo, + creationStatus: AgentSkillCreationStatusEnum.ready, + updateTime: new Date() + }, + $unset: { + creationError: '', + creationPayload: '' + } + }, + { session } + ); + + return result.matchedCount > 0; +} + +/** Mark an async-created skill as failed while keeping it visible for deletion and diagnosis. */ +export async function updateSkillCreationFailed({ + skillId, + error, + session +}: { + skillId: string; + error: string; + session?: ClientSession; +}): Promise { await MongoAgentSkills.updateOne( { _id: skillId, deleteTime: null }, - { $set: { currentStorage: storageInfo, updateTime: new Date() } }, + { + $set: { + creationStatus: AgentSkillCreationStatusEnum.failed, + creationError: error, + updateTime: new Date() + }, + $unset: { + creationPayload: '' + } + }, { session } ); } @@ -402,7 +448,7 @@ export async function getSkillFolderPath( return []; } - const targetId = type === 'current' ? skillId : skill.parentId ?? null; + const targetId = type === 'current' ? skillId : (skill.parentId ?? null); return await getParents(targetId); } diff --git a/packages/service/core/agentSkills/creation.ts b/packages/service/core/agentSkills/creation.ts new file mode 100644 index 000000000000..f2326ef5aa2d --- /dev/null +++ b/packages/service/core/agentSkills/creation.ts @@ -0,0 +1,313 @@ +import { getQueue, getWorker, QueueNames, type Job, type Worker } from '../../common/bullmq'; +import { mongoSessionRun } from '../../common/mongo/sessionRun'; +import { MongoAgentSkills } from './schema'; +import { updateCurrentStorage, updateSkillCreationFailed } from './controller'; +import { buildSkillMd, generateSkillMd } from './skillMdBuilder'; +import { extractSkillFromMarkdown } from './utils'; +import { createSkillPackage } from './zipBuilder'; +import { deleteSkillPackage, type SkillStorageInfo, uploadSkillPackage } from './storage'; +import { createVersion } from './version/controller'; +import { getLogger, LogCategories } from '../../common/logger'; +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { SkillErrEnum } from '@fastgpt/global/common/error/code/agentSkill'; +import { AgentSkillCreationStatusEnum } from '@fastgpt/global/core/agentSkills/constants'; +import { createUsage } from '../../support/wallet/usage/controller'; +import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; +import { i18nT } from '@fastgpt/global/common/i18n/utils'; +import { formatModelChars2Points } from '../../support/wallet/usage/utils'; + +const logger = getLogger(LogCategories.MODULE.AGENT_SKILLS.CREATION); + +export type AgentSkillCreateJobData = { + skillId: string; + teamId: string; + tmbId: string; + name: string; + description: string; + requirements?: string; + model?: string; +}; + +const agentSkillCreateQueue = getQueue(QueueNames.agentSkillCreate, { + defaultJobOptions: { + attempts: 1, + removeOnComplete: true, + removeOnFail: { + age: 30 * 24 * 60 * 60, + count: 1000 + } + } +}); +let hookedWorker: Worker | null = null; + +export const addAgentSkillCreateJob = async (data: AgentSkillCreateJobData) => { + const skillId = String(data.skillId); + const existingJob = await agentSkillCreateQueue.getJob(skillId); + if (existingJob) { + const state = await existingJob.getState(); + if (state !== 'completed' && state !== 'failed') { + return existingJob; + } + await existingJob.remove(); + } + + return agentSkillCreateQueue.add(skillId, data, { + jobId: skillId + }); +}; + +/** + * Re-enqueue visible pending skills on worker startup. + * + * The create API persists the generation input before adding a BullMQ job. If + * the process stops between those two steps, this startup scan turns the + * pending row back into an idempotent job and prevents an endless creating + * state on the detail page. + */ +async function resumePendingSkillCreationJobs(): Promise { + const pendingSkills = await MongoAgentSkills.find( + { + creationStatus: AgentSkillCreationStatusEnum.creating, + deleteTime: null + }, + { + _id: 1, + teamId: 1, + tmbId: 1, + name: 1, + description: 1, + creationPayload: 1 + } + ).lean(); + + const results = await Promise.allSettled( + pendingSkills.flatMap((skill) => { + if (!skill.teamId || !skill.tmbId) { + logger.warn('Pending skill missing owner info, skip resume', { + skillId: skill._id.toString() + }); + return []; + } + + return addAgentSkillCreateJob({ + skillId: skill._id.toString(), + teamId: skill.teamId.toString(), + tmbId: skill.tmbId.toString(), + name: skill.name, + description: skill.description, + requirements: skill.creationPayload?.requirements, + model: skill.creationPayload?.model + }); + }) + ); + + const failedCount = results.filter((result) => result.status === 'rejected').length; + if (pendingSkills.length > 0) { + logger.info('Pending skill creation jobs resumed', { + total: pendingSkills.length, + failed: failedCount + }); + } +} + +/** + * Finish a pending skill creation job. + * + * The API creates the visible skill row first so the detail page has a stable + * skillId. This worker performs the slow package generation and version setup, + * then marks the skill as ready. Failure is persisted on the skill row so + * refreshes and later visits can show the terminal state. + */ +export async function completePendingSkillCreation(data: AgentSkillCreateJobData): Promise { + const { skillId, teamId, tmbId, name, description } = data; + let uploadedStorageInfo: SkillStorageInfo | undefined; + + const skill = await MongoAgentSkills.findOne({ + _id: skillId, + teamId, + deleteTime: null + }); + + if (!skill) { + logger.warn('Pending skill not found, skip creation job', { skillId, teamId }); + return; + } + + if ( + (!skill.creationStatus || skill.creationStatus === AgentSkillCreationStatusEnum.ready) && + skill.currentStorage + ) { + logger.info('Pending skill already completed, skip duplicate creation job', { skillId }); + return; + } + + try { + const requirements = data.requirements ?? skill.creationPayload?.requirements; + const model = data.model ?? skill.creationPayload?.model; + let skillMd: string; + let packageRootName = name; + + if (requirements && model) { + const [generatedSkillMd, usage] = await generateSkillMd({ + name, + description, + requirements: requirements.trim(), + model + }); + + skillMd = generatedSkillMd; + + const { skill: generatedSkill, error: parseError } = extractSkillFromMarkdown(skillMd); + if (parseError || !generatedSkill?.name) { + logger.warn('AI generated invalid SKILL.md', { + skillId, + name, + parseError + }); + throw SkillErrEnum.invalidSkillPackage; + } + packageRootName = generatedSkill.name; + + const { totalPoints, modelName } = formatModelChars2Points({ + model, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens + }); + + createUsage({ + teamId, + tmbId, + appName: i18nT('common:support.wallet.usage.Assist Generate Skill'), + totalPoints, + source: UsageSourceEnum.assist_generate_skill, + list: [ + { + moduleName: i18nT('common:support.wallet.usage.Assist Generate Skill'), + amount: totalPoints, + model: modelName, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens + } + ] + }); + } else { + skillMd = buildSkillMd({ + name: packageRootName, + description + }); + } + + const zipBuffer = await createSkillPackage({ name: packageRootName, skillMd }); + + const storageInfo = await uploadSkillPackage({ + teamId, + skillId, + version: 0, + zipBuffer + }); + uploadedStorageInfo = storageInfo; + + const isStorageLinked = await mongoSessionRun(async (session) => { + const isUpdated = await updateCurrentStorage(skillId, storageInfo, session); + if (!isUpdated) { + return false; + } + + await createVersion( + { + skillId, + tmbId, + version: 0, + versionName: 'Initial creation', + storage: storageInfo + }, + session + ); + + return true; + }); + + if (!isStorageLinked) { + uploadedStorageInfo = undefined; + await deleteSkillPackage(storageInfo).catch((cleanupError) => { + logger.error('Failed to clean uploaded package for deleted pending skill', { + skillId, + cleanupError + }); + }); + return; + } + + uploadedStorageInfo = undefined; + } catch (error) { + if (uploadedStorageInfo) { + await deleteSkillPackage(uploadedStorageInfo).catch((cleanupError) => { + logger.error('Failed to clean uploaded skill package after creation error', { + skillId, + cleanupError + }); + }); + } + + const errorText = getErrText(error, 'Skill creation failed'); + await updateSkillCreationFailed({ + skillId, + error: errorText + }); + logger.error('Pending skill creation failed', { + skillId, + teamId, + error + }); + throw error; + } +} + +export const initAgentSkillCreateWorker = () => { + const worker = getWorker( + QueueNames.agentSkillCreate, + async (job: Job) => { + await completePendingSkillCreation(job.data); + }, + { + concurrency: 2 + } + ); + + if (hookedWorker !== worker) { + worker.on('failed', async (job, error) => { + try { + const skillId = job?.data.skillId; + if (!skillId) return; + + const skill = await MongoAgentSkills.findOne( + { + _id: skillId, + creationStatus: AgentSkillCreationStatusEnum.creating, + deleteTime: null + }, + { _id: 1 } + ).lean(); + if (!skill) return; + + await updateSkillCreationFailed({ + skillId, + error: getErrText(error, 'Skill creation failed') + }); + } catch (failedEventError) { + logger.error('Failed to persist skill creation failed event', { + skillId: job?.data.skillId, + error: failedEventError + }); + } + }); + + hookedWorker = worker; + } + + resumePendingSkillCreationJobs().catch((error) => { + logger.error('Failed to resume pending skill creation jobs', { error }); + }); + + return worker; +}; diff --git a/packages/service/core/agentSkills/schema.ts b/packages/service/core/agentSkills/schema.ts index 76b0e07754d0..3a0cdc9bb471 100644 --- a/packages/service/core/agentSkills/schema.ts +++ b/packages/service/core/agentSkills/schema.ts @@ -3,10 +3,18 @@ import { agentSkillsCollectionName as agentSkillsCollectionName, AgentSkillSourceEnum, AgentSkillCategoryEnum, + AgentSkillCreationStatusEnum, AgentSkillTypeEnum } from '@fastgpt/global/core/agentSkills/constants'; import type { AgentSkillSchemaType } from '@fastgpt/global/core/agentSkills/type'; +export type MongoAgentSkillSchemaType = AgentSkillSchemaType & { + creationPayload?: { + requirements?: string; + model?: string; + }; +}; + const { Schema } = connectionMongo; const AgentSkillsSchema = new Schema({ @@ -88,6 +96,18 @@ const AgentSkillsSchema = new Schema({ bucket: String, key: String, size: Number + }, + creationStatus: { + type: String, + enum: Object.values(AgentSkillCreationStatusEnum), + default: AgentSkillCreationStatusEnum.ready + }, + creationError: { + type: String + }, + creationPayload: { + requirements: String, + model: String } }); @@ -113,7 +133,7 @@ try { console.log('AgentSkill index error:', error); } -export const MongoAgentSkills = getMongoModel( +export const MongoAgentSkills = getMongoModel( agentSkillsCollectionName, AgentSkillsSchema ); diff --git a/projects/app/src/pageComponents/dashboard/skill/CreateSkillModal.tsx b/projects/app/src/pageComponents/dashboard/skill/CreateSkillModal.tsx index 279b1beea2dd..ecb7de9825bb 100644 --- a/projects/app/src/pageComponents/dashboard/skill/CreateSkillModal.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/CreateSkillModal.tsx @@ -27,10 +27,9 @@ type FormType = { type Props = { parentId?: string | null; onClose: () => void; - onSuccess?: () => void; }; -const CreateSkillModal = ({ parentId, onClose, onSuccess }: Props) => { +const CreateSkillModal = ({ parentId, onClose }: Props) => { const { t } = useTranslation(); const router = useRouter(); const { defaultModels } = useSystemStore(); @@ -69,11 +68,9 @@ const CreateSkillModal = ({ parentId, onClose, onSuccess }: Props) => { }, { onSuccess(skillId) { - onSuccess?.(); onClose(); router.push(`/skill/detail?skillId=${skillId}`); }, - successToast: t('common:create_success'), errorToast: t('common:create_failed') } ); diff --git a/projects/app/src/pageComponents/dashboard/skill/List.tsx b/projects/app/src/pageComponents/dashboard/skill/List.tsx index a01198db3a0a..966b43ca7b69 100644 --- a/projects/app/src/pageComponents/dashboard/skill/List.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/List.tsx @@ -16,6 +16,7 @@ import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; import { formatTimeToChatTime } from '@fastgpt/global/common/string/time'; import { AgentSkillSourceEnum, + AgentSkillCreationStatusEnum, AgentSkillTypeEnum } from '@fastgpt/global/core/agentSkills/constants'; import { @@ -310,6 +311,91 @@ const List = ({ const isFolder = skill.type === AgentSkillTypeEnum.folder; const isPersonal = skill.source === AgentSkillSourceEnum.personal; const relatedAppsCount = skill.appCount ?? 0; + const isSkillReady = + isFolder || + (skill.creationStatus ?? AgentSkillCreationStatusEnum.ready) === + AgentSkillCreationStatusEnum.ready; + const isSkillCreating = skill.creationStatus === AgentSkillCreationStatusEnum.creating; + const isSkillCreateFailed = skill.creationStatus === AgentSkillCreationStatusEnum.failed; + const menuList = [ + ...(isFolder || isSkillReady + ? [ + { + children: [ + { + icon: 'edit', + type: 'grayBg' as const, + label: t('common:dataset.Edit Info'), + onClick: () => { + if ( + !isFolder && + guardSkillSandboxOperation && + !guardSkillSandboxOperation() + ) { + return; + } + setEditedSkill({ + id: skill._id, + avatar: + skill.avatar ?? + (isFolder ? 'common/folderFill' : 'core/skill/default'), + name: skill.name, + intro: skill.description + }); + } + }, + { + icon: 'common/file/move', + type: 'grayBg' as const, + label: t('common:move_to'), + onClick: () => setMoveSkillId(skill._id) + }, + { + icon: 'key', + type: 'grayBg' as const, + label: t('skill:permission_settings'), + onClick: () => { + setEditPerSkillId(skill._id); + } + }, + ...(!isFolder + ? [ + { + icon: 'export', + type: 'grayBg' as const, + label: t('skill:export_config'), + onClick: () => onExportSkill(skill._id, skill.name) + }, + { + icon: 'copy', + type: 'grayBg' as const, + label: t('skill:copy_skill'), + onClick: () => + openConfirmCopy({ + onConfirm: () => onclickCopySkill(skill._id) + })() + } + ] + : []) + ] + } + ] + : []), + { + children: [ + { + type: 'danger' as const, + icon: 'delete', + label: t('common:Delete'), + onClick: () => + setDeleteTarget({ + skillId: skill._id, + refsCount: isFolder ? 0 : relatedAppsCount + }) + } + ] + } + ]; return ( {skill.name} + {(isSkillCreating || isSkillCreateFailed) && ( + + {isSkillCreateFailed ? t('common:failed') : t('skill:generating')} + + )} {/* Description */} @@ -387,7 +487,7 @@ const List = ({ avatarSize="1rem" spacing={1} /> - {!isFolder && ( + {!isFolder && isSkillReady && ( <> {relatedAppsCount > 0 ? ( @@ -426,81 +526,7 @@ const List = ({ aria-label={''} /> } - menuList={[ - { - children: [ - { - icon: 'edit', - type: 'grayBg' as const, - label: t('common:dataset.Edit Info'), - onClick: () => { - if ( - !isFolder && - guardSkillSandboxOperation && - !guardSkillSandboxOperation() - ) { - return; - } - setEditedSkill({ - id: skill._id, - avatar: - skill.avatar ?? - (isFolder ? 'common/folderFill' : 'core/skill/default'), - name: skill.name, - intro: skill.description - }); - } - }, - { - icon: 'common/file/move', - type: 'grayBg' as const, - label: t('common:move_to'), - onClick: () => setMoveSkillId(skill._id) - }, - { - icon: 'key', - type: 'grayBg' as const, - label: t('skill:permission_settings'), - onClick: () => { - setEditPerSkillId(skill._id); - } - }, - ...(!isFolder - ? [ - { - icon: 'export', - type: 'grayBg' as const, - label: t('skill:export_config'), - onClick: () => onExportSkill(skill._id, skill.name) - }, - { - icon: 'copy', - type: 'grayBg' as const, - label: t('skill:copy_skill'), - onClick: () => - openConfirmCopy({ - onConfirm: () => onclickCopySkill(skill._id) - })() - } - ] - : []) - ] - }, - { - children: [ - { - type: 'danger' as const, - icon: 'delete', - label: t('common:Delete'), - onClick: () => - setDeleteTarget({ - skillId: skill._id, - refsCount: isFolder ? 0 : relatedAppsCount - }) - } - ] - } - ]} + menuList={menuList} /> )} diff --git a/projects/app/src/pageComponents/dashboard/skill/detail/Header.tsx b/projects/app/src/pageComponents/dashboard/skill/detail/Header.tsx index 0b33a527aa3b..5565b1699de8 100644 --- a/projects/app/src/pageComponents/dashboard/skill/detail/Header.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/detail/Header.tsx @@ -35,6 +35,8 @@ const RouteTab = () => { const { t } = useTranslation(); const { currentTab, setCurrentTab } = useContextSelector(SkillDetailContext, (v) => v); + const isSkillReady = useContextSelector(SkillDetailContext, (v) => v.isSkillReady); + const tabList = [ { label: t('skill:detail_tab_config'), value: TabEnum.config }, { label: t('skill:detail_tab_preview'), value: TabEnum.preview } @@ -61,7 +63,10 @@ const RouteTab = () => { } : { color: 'myGray.500', - onClick: () => setCurrentTab(tab.value) + onClick: () => { + if (!isSkillReady && tab.value === TabEnum.preview) return; + setCurrentTab(tab.value); + } })} > {tab.label} @@ -75,10 +80,8 @@ const Header = () => { const { t } = useTranslation(); const router = useRouter(); - const { skillDetail, refreshSkillDetail, showHistories, setShowHistories } = useContextSelector( - SkillDetailContext, - (v) => v - ); + const { skillDetail, refreshSkillDetail, showHistories, setShowHistories, isSkillReady } = + useContextSelector(SkillDetailContext, (v) => v); const [editedSkill, setEditedSkill] = useState(); const [showPermModal, setShowPermModal] = useState(false); @@ -199,25 +202,27 @@ const Header = () => { {skillDetail.name} - } - w={'34px'} - h={'34px'} - bg={'white'} - border={'1px solid'} - borderColor={'myGray.250'} - borderRadius={'sm'} - boxShadow={'0 1px 2px 0 rgba(19, 51, 107, 0.05), 0 0 1px 0 rgba(19, 51, 107, 0.08)'} - _hover={{ - bg: 'myGray.50' - }} - /> - } - menuList={menuList} - /> + {isSkillReady && ( + } + w={'34px'} + h={'34px'} + bg={'white'} + border={'1px solid'} + borderColor={'myGray.250'} + borderRadius={'sm'} + boxShadow={'0 1px 2px 0 rgba(19, 51, 107, 0.05), 0 0 1px 0 rgba(19, 51, 107, 0.08)'} + _hover={{ + bg: 'myGray.50' + }} + /> + } + menuList={menuList} + /> + )} {/* 居中 Tab */} @@ -228,7 +233,7 @@ const Header = () => { {/* 右侧按钮组(历史版本抽屉打开时隐藏) */} - {!showHistories && ( + {isSkillReady && !showHistories && ( } diff --git a/projects/app/src/pageComponents/dashboard/skill/detail/config/SandboxError.tsx b/projects/app/src/pageComponents/dashboard/skill/detail/config/SandboxError.tsx index 34cdc690a70a..a374d415ba48 100644 --- a/projects/app/src/pageComponents/dashboard/skill/detail/config/SandboxError.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/detail/config/SandboxError.tsx @@ -7,10 +7,14 @@ import MyIcon from '@fastgpt/web/components/common/Icon'; const SandboxError = () => { const { t } = useTranslation(); - const { sandboxError, startSandbox } = useContextSelector(SkillDetailContext, (v) => ({ - sandboxError: v.sandboxError, - startSandbox: v.startSandbox - })); + const { sandboxError, isSkillReady, startSandbox } = useContextSelector( + SkillDetailContext, + (v) => ({ + sandboxError: v.sandboxError, + isSkillReady: v.isSkillReady, + startSandbox: v.startSandbox + }) + ); return ( @@ -23,9 +27,11 @@ const SandboxError = () => { {sandboxError} )} - + {isSkillReady && ( + + )} ); }; diff --git a/projects/app/src/pageComponents/dashboard/skill/detail/context.tsx b/projects/app/src/pageComponents/dashboard/skill/detail/context.tsx index 6ea36d566f72..0bd57037df80 100644 --- a/projects/app/src/pageComponents/dashboard/skill/detail/context.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/detail/context.tsx @@ -4,7 +4,10 @@ import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import type { AgentSkillDetailType } from '@fastgpt/global/core/agentSkills/type'; import type { SandboxStatusItemType, SandboxStatusPhase } from '@fastgpt/global/core/chat/type'; -import { AgentSkillTypeEnum } from '@fastgpt/global/core/agentSkills/constants'; +import { + AgentSkillCreationStatusEnum, + AgentSkillTypeEnum +} from '@fastgpt/global/core/agentSkills/constants'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { getSkillDetail, streamCreateEditDebugSandbox } from '@/web/core/skill/api'; import { SkillPermission } from '@fastgpt/global/support/permission/agentSkill/controller'; @@ -41,6 +44,7 @@ type SkillDetailContextType = { sandboxLogs: SandboxLogEntry[]; sandboxEndpoint: SandboxEndpoint | null; sandboxError: string | null; + isSkillReady: boolean; startSandbox: () => void; restartSandbox: () => void; }; @@ -58,6 +62,7 @@ export const SkillDetailContext = createContext({ sandboxLogs: [], sandboxEndpoint: null, sandboxError: null, + isSkillReady: false, startSandbox: () => {}, restartSandbox: () => {} }); @@ -184,6 +189,8 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => { tmbId: res.tmbId ?? '', currentVersion: 0, versionCount: 0, + creationStatus: res.creationStatus, + creationError: res.creationError, createTime: new Date(res.createTime), updateTime: new Date(res.updateTime), appCount: res.appCount ?? 0, @@ -198,13 +205,35 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => { } ); + const creationStatus = skillDetail?.creationStatus; + const isSkillCreating = creationStatus === AgentSkillCreationStatusEnum.creating; + const isSkillCreateFailed = creationStatus === AgentSkillCreationStatusEnum.failed; + const isSkillReady = + !!skillDetail && (!creationStatus || creationStatus === AgentSkillCreationStatusEnum.ready); + const visibleSandboxState: SandboxState = isSkillCreateFailed ? 'failed' : sandboxState; + const visibleSandboxError = isSkillCreateFailed + ? skillDetail?.creationError || t('common:create_failed') + : sandboxError; + const visibleCurrentTab = + !isSkillReady && currentTab === TabEnum.preview ? TabEnum.config : currentTab; + + useEffect(() => { + if (!isSkillCreating) return; + + const timer = setInterval(() => { + refreshSkillDetail(); + }, 2000); + + return () => clearInterval(timer); + }, [isSkillCreating, refreshSkillDetail]); + // Auto-start sandbox when skillId is ready useEffect(() => { - if (skillId && !hasStartedRef.current) { + if (skillId && isSkillReady && !hasStartedRef.current) { hasStartedRef.current = true; startSandbox(); } - }, [skillId, startSandbox]); + }, [skillId, isSkillReady, startSandbox]); // Cleanup on unmount useEffect(() => { @@ -219,14 +248,15 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => { skillDetail, isFetchingSkillDetail, refreshSkillDetail, - currentTab, + currentTab: visibleCurrentTab, setCurrentTab, showHistories, setShowHistories, - sandboxState, + sandboxState: visibleSandboxState, sandboxLogs, sandboxEndpoint, - sandboxError, + sandboxError: visibleSandboxError, + isSkillReady, startSandbox, restartSandbox }), @@ -235,12 +265,13 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => { skillDetail, isFetchingSkillDetail, refreshSkillDetail, - currentTab, + visibleCurrentTab, showHistories, - sandboxState, + visibleSandboxState, sandboxLogs, sandboxEndpoint, - sandboxError, + visibleSandboxError, + isSkillReady, startSandbox, restartSandbox ] diff --git a/projects/app/src/pages/api/core/agentSkills/create.ts b/projects/app/src/pages/api/core/agentSkills/create.ts index 54526ee7b9d1..83559bd652aa 100644 --- a/projects/app/src/pages/api/core/agentSkills/create.ts +++ b/projects/app/src/pages/api/core/agentSkills/create.ts @@ -4,13 +4,9 @@ import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; import { createSkill, checkSkillNameExists, - updateCurrentStorage + updateSkillCreationFailed } from '@fastgpt/service/core/agentSkills/controller'; -import { buildSkillMd, generateSkillMd } from '@fastgpt/service/core/agentSkills/skillMdBuilder'; -import { extractSkillFromMarkdown } from '@fastgpt/service/core/agentSkills/utils'; -import { createSkillPackage } from '@fastgpt/service/core/agentSkills/zipBuilder'; -import { uploadSkillPackage } from '@fastgpt/service/core/agentSkills/storage'; -import { createVersion } from '@fastgpt/service/core/agentSkills/version/controller'; +import { addAgentSkillCreateJob } from '@fastgpt/service/core/agentSkills/creation'; import { CreateSkillBodySchema, CreateSkillResponseSchema, @@ -19,6 +15,7 @@ import { } from '@fastgpt/global/core/agentSkills/api'; import { AgentSkillCategoryEnum, + AgentSkillCreationStatusEnum, AgentSkillTypeEnum } from '@fastgpt/global/core/agentSkills/constants'; import { authSkill } from '@fastgpt/service/support/permission/agentSkill/auth'; @@ -31,14 +28,11 @@ import { TeamSkillCreatePermissionVal } from '@fastgpt/global/support/permission import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema'; import { addAuditLog, getI18nSkillType } from '@fastgpt/service/support/user/audit/util'; import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; -import { formatModelChars2Points } from '@fastgpt/service/support/wallet/usage/utils'; -import { createUsage } from '@fastgpt/service/support/wallet/usage/controller'; -import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; -import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; import { getS3AvatarSource } from '@fastgpt/service/common/s3/sources/avatar'; import type { ApiRequestProps } from '@fastgpt/service/type/next'; import { SkillErrEnum } from '@fastgpt/global/common/error/code/agentSkill'; +import { getErrText } from '@fastgpt/global/common/error/utils'; const logger = getLogger(LogCategories.MODULE.AGENT_SKILLS.CREATION); @@ -56,6 +50,7 @@ async function handler(req: ApiRequestProps): Promise): Promise 500) { return Promise.reject(SkillErrEnum.invalidDescription); } - if (requirements && !model) { + if (requestedRequirements && !model) { return Promise.reject(SkillErrEnum.missingModel); } - if (requirements && requirements.length > 8000) { + if (requestedRequirements && requestedRequirements.length > 8000) { return Promise.reject(SkillErrEnum.requirementsTooLong); } @@ -104,84 +99,9 @@ async function handler(req: ApiRequestProps): Promise { - const zipBuffer = await createSkillPackage({ name: packageRootName, skillMd }); - const newSkillId = await createSkill( { parentId: parentId || null, @@ -192,27 +112,11 @@ async function handler(req: ApiRequestProps): Promise): Promise { addAuditLog({ tmbId, diff --git a/projects/app/src/pages/api/core/agentSkills/detail.ts b/projects/app/src/pages/api/core/agentSkills/detail.ts index d09ff78f1931..644646c652bd 100644 --- a/projects/app/src/pages/api/core/agentSkills/detail.ts +++ b/projects/app/src/pages/api/core/agentSkills/detail.ts @@ -12,7 +12,7 @@ import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { SkillErrEnum } from '@fastgpt/global/common/error/code/agentSkill'; async function handler( - req: ApiRequestProps<{}, GetSkillDetailQuery> + req: ApiRequestProps, GetSkillDetailQuery> ): Promise { const { skillId } = req.query; @@ -54,6 +54,8 @@ async function handler( category: skill.category, config: skill.config, avatar: skill.avatar, + creationStatus: skill.creationStatus, + creationError: skill.creationError, teamId: skill.teamId, tmbId: skill.tmbId, createTime: skill.createTime?.toISOString() || new Date().toISOString(), diff --git a/projects/app/src/pages/api/core/agentSkills/edit.ts b/projects/app/src/pages/api/core/agentSkills/edit.ts index 0cd0bf370a86..4545b4385ae7 100644 --- a/projects/app/src/pages/api/core/agentSkills/edit.ts +++ b/projects/app/src/pages/api/core/agentSkills/edit.ts @@ -10,6 +10,7 @@ import type { SandboxStatusItemType } from '@fastgpt/global/core/chat/type'; import { isValidObjectId } from 'mongoose'; import { SkillErrEnum } from '@fastgpt/global/common/error/code/agentSkill'; import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; +import { AgentSkillCreationStatusEnum } from '@fastgpt/global/core/agentSkills/constants'; const logger = getLogger(LogCategories.MODULE.AGENT_SKILLS); @@ -48,7 +49,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } // Authenticate user and verify write permission - const { teamId, tmbId } = await authSkill({ + const { teamId, tmbId, skill } = await authSkill({ req, authToken: true, authApiKey: true, @@ -56,6 +57,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) per: WritePermissionVal }); + if (skill.creationStatus && skill.creationStatus !== AgentSkillCreationStatusEnum.ready) { + sseErrRes(res, skill.creationError || SkillErrEnum.noStorage); + res.end(); + return; + } + // Validate optional parameters if (image && !image.repository) { sseErrRes(res, SkillErrEnum.missingImageRepository); diff --git a/projects/app/src/pages/api/core/agentSkills/list.ts b/projects/app/src/pages/api/core/agentSkills/list.ts index e2077bc40ef2..ffba1c3d0de3 100644 --- a/projects/app/src/pages/api/core/agentSkills/list.ts +++ b/projects/app/src/pages/api/core/agentSkills/list.ts @@ -198,6 +198,7 @@ async function handler(req: ApiRequestProps) { category: skill.category, author: skill.author, inheritPermission: skill.inheritPermission, + creationStatus: skill.creationStatus, tmbId: skill.tmbId, parentId: skill.parentId, createTime: skill.createTime, diff --git a/projects/app/src/pages/api/core/agentSkills/save-deploy.ts b/projects/app/src/pages/api/core/agentSkills/save-deploy.ts index 0523ea54bf98..4a794b6d4622 100644 --- a/projects/app/src/pages/api/core/agentSkills/save-deploy.ts +++ b/projects/app/src/pages/api/core/agentSkills/save-deploy.ts @@ -15,7 +15,10 @@ import { extractSkillFromMarkdown } from '@fastgpt/service/core/agentSkills/util import { findSandboxInstanceByAppChatType } from '@fastgpt/service/core/ai/sandbox/instance'; import { getSandboxProviderConfig } from '@fastgpt/service/core/ai/sandbox/config'; import { MongoAgentSkills } from '@fastgpt/service/core/agentSkills/schema'; -import { SandboxTypeEnum } from '@fastgpt/global/core/agentSkills/constants'; +import { + AgentSkillCreationStatusEnum, + SandboxTypeEnum +} from '@fastgpt/global/core/agentSkills/constants'; import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants'; import { SaveDeploySkillBodySchema, @@ -55,6 +58,10 @@ async function handler( authApiKey: true }); + if (skill.creationStatus && skill.creationStatus !== AgentSkillCreationStatusEnum.ready) { + return Promise.reject(skill.creationError || SkillErrEnum.noStorage); + } + // Fetch the edit-debug sandbox const providerConfig = getSandboxProviderConfig(); const sandboxInfo = await findSandboxInstanceByAppChatType({ diff --git a/projects/app/src/pages/api/core/agentSkills/update.ts b/projects/app/src/pages/api/core/agentSkills/update.ts index 37f2a90a7e74..c8d8d50ae063 100644 --- a/projects/app/src/pages/api/core/agentSkills/update.ts +++ b/projects/app/src/pages/api/core/agentSkills/update.ts @@ -17,7 +17,8 @@ import { TeamSkillCreatePermissionVal } from '@fastgpt/global/support/permission import { parseParentIdInMongo } from '@fastgpt/global/common/parentFolder/utils'; import { AgentSkillTypeEnum, - AgentSkillCategoryEnum + AgentSkillCategoryEnum, + AgentSkillCreationStatusEnum } from '@fastgpt/global/core/agentSkills/constants'; import { SkillErrEnum } from '@fastgpt/global/common/error/code/agentSkill'; import { @@ -55,6 +56,10 @@ async function handler(req: ApiRequestProps) { authApiKey: true }); + if (skill.creationStatus && skill.creationStatus !== AgentSkillCreationStatusEnum.ready) { + return Promise.reject(skill.creationError || SkillErrEnum.noStorage); + } + if (isMove) { // Move operation: check source folder, target folder, and root-level permissions if (parentId) { diff --git a/projects/app/src/pages/dashboard/skill/index.tsx b/projects/app/src/pages/dashboard/skill/index.tsx index a3ffa05c8250..fc57730c682f 100644 --- a/projects/app/src/pages/dashboard/skill/index.tsx +++ b/projects/app/src/pages/dashboard/skill/index.tsx @@ -170,11 +170,7 @@ const SkillPageContent = ({ MenuIcon }: { MenuIcon: JSX.Element }) => { )} {showCreateModal && ( - setShowCreateModal(false)} - onSuccess={() => loadSkills()} - /> + setShowCreateModal(false)} /> )} {showImportModal && ( diff --git a/projects/app/src/service/common/bullmq/index.ts b/projects/app/src/service/common/bullmq/index.ts index 0f0b35447c08..e473e3753a9f 100644 --- a/projects/app/src/service/common/bullmq/index.ts +++ b/projects/app/src/service/common/bullmq/index.ts @@ -5,6 +5,7 @@ import { initAppDeleteWorker } from '@fastgpt/service/core/app/delete'; import { initTeamDeleteWorker } from '@fastgpt/service/support/user/team/delete'; import { initCollectionUpdateWorker } from '@fastgpt/service/core/dataset/collection/mq'; import { initWechatPollWorker } from '@fastgpt/service/support/outLink/wechat/mq'; +import { initAgentSkillCreateWorker } from '@fastgpt/service/core/agentSkills/creation'; const logger = getLogger(LogCategories.INFRA.QUEUE); @@ -16,6 +17,7 @@ export const initBullMQWorkers = () => { initAppDeleteWorker(), initTeamDeleteWorker(), initCollectionUpdateWorker(), + initAgentSkillCreateWorker(), initWechatPollWorker() ]); };