From b529d074795660bad1dd1c2ad9da03a78585982d Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 6 Nov 2025 00:02:25 +0800 Subject: [PATCH] feat: plugin config file auto clean --- pkg/api/http/controller/groups/plugins.py | 13 +++ .../dynamic-form/DynamicFormComponent.tsx | 8 +- .../dynamic-form/DynamicFormItemComponent.tsx | 6 + .../plugin-form/PluginForm.tsx | 110 +++++++++++++++--- web/src/app/infra/http/BackendClient.ts | 6 + 5 files changed, 127 insertions(+), 16 deletions(-) diff --git a/pkg/api/http/controller/groups/plugins.py b/pkg/api/http/controller/groups/plugins.py index f8a01118..4a25f6d0 100644 --- a/pkg/api/http/controller/groups/plugins.py +++ b/pkg/api/http/controller/groups/plugins.py @@ -294,3 +294,16 @@ class PluginsRouterGroup(group.RouterGroup): await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes) return self.success(data={'file_key': file_key}) + + @self.route('/config-files/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN) + async def _(file_key: str) -> str: + """Delete a plugin configuration file""" + # Only allow deletion of files with plugin_config_ prefix for security + if not file_key.startswith('plugin_config_'): + return self.http_status(400, -1, 'invalid file key') + + try: + await self.ap.storage_mgr.storage_provider.delete(file_key) + return self.success(data={'deleted': True}) + except Exception as e: + return self.http_status(500, -1, f'failed to delete file: {str(e)}') diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 36344622..6e3cb08e 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -18,10 +18,12 @@ export default function DynamicFormComponent({ itemConfigList, onSubmit, initialValues, + onFileUploaded, }: { itemConfigList: IDynamicFormItemSchema[]; onSubmit?: (val: object) => unknown; initialValues?: Record; + onFileUploaded?: (fileKey: string) => void; }) { const isInitialMount = useRef(true); const previousInitialValues = useRef(initialValues); @@ -169,7 +171,11 @@ export default function DynamicFormComponent({ {config.required && *} - + {config.description && (

diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index 1f09f93d..4f53323d 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -33,10 +33,12 @@ import { Card, CardContent } from '@/components/ui/card'; export default function DynamicFormItemComponent({ config, field, + onFileUploaded, }: { config: IDynamicFormItemSchema; // eslint-disable-next-line @typescript-eslint/no-explicit-any field: ControllerRenderProps; + onFileUploaded?: (fileKey: string) => void; }) { const [llmModels, setLlmModels] = useState([]); const [knowledgeBases, setKnowledgeBases] = useState([]); @@ -55,6 +57,10 @@ export default function DynamicFormItemComponent({ setUploading(true); const response = await httpClient.uploadPluginConfigFile(file); toast.success(t('plugins.fileUpload.success')); + + // 通知父组件文件已上传 + onFileUploaded?.(response.file_key); + return { file_key: response.file_key, mimetype: file.type, diff --git a/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx b/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx index b9e14928..e6fc5862 100644 --- a/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx @@ -8,6 +8,7 @@ import { toast } from 'sonner'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { useTranslation } from 'react-i18next'; import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList'; +import { IFileConfig } from '@/app/infra/entities/form/dynamic'; export default function PluginForm({ pluginAuthor, @@ -25,6 +26,8 @@ export default function PluginForm({ const [pluginConfig, setPluginConfig] = useState(); const [isSaving, setIsLoading] = useState(false); const currentFormValues = useRef({}); + const uploadedFileKeys = useRef>(new Set()); + const initialFileKeys = useRef>(new Set()); useEffect(() => { // 获取插件信息 @@ -34,28 +37,101 @@ export default function PluginForm({ // 获取插件配置 httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => { setPluginConfig(res); + + // 提取初始配置中的所有文件 key + const extractFileKeys = (obj: any): string[] => { + const keys: string[] = []; + if (obj && typeof obj === 'object') { + if ('file_key' in obj && typeof obj.file_key === 'string') { + keys.push(obj.file_key); + } + for (const value of Object.values(obj)) { + if (Array.isArray(value)) { + value.forEach((item) => keys.push(...extractFileKeys(item))); + } else if (typeof value === 'object' && value !== null) { + keys.push(...extractFileKeys(value)); + } + } + } + return keys; + }; + + const fileKeys = extractFileKeys(res.config); + initialFileKeys.current = new Set(fileKeys); }); }, [pluginAuthor, pluginName]); const handleSubmit = async () => { setIsLoading(true); const isDebugPlugin = pluginInfo?.debug; - httpClient - .updatePluginConfig(pluginAuthor, pluginName, currentFormValues.current) - .then(() => { - toast.success( - isDebugPlugin - ? t('plugins.saveConfigSuccessDebugPlugin') - : t('plugins.saveConfigSuccessNormal'), - ); - onFormSubmit(1000); - }) - .catch((error) => { - toast.error(t('plugins.saveConfigError') + error.message); - }) - .finally(() => { - setIsLoading(false); + + try { + // 保存配置 + await httpClient.updatePluginConfig( + pluginAuthor, + pluginName, + currentFormValues.current, + ); + + // 提取最终保存的配置中的所有文件 key + const extractFileKeys = (obj: any): string[] => { + const keys: string[] = []; + if (obj && typeof obj === 'object') { + if ('file_key' in obj && typeof obj.file_key === 'string') { + keys.push(obj.file_key); + } + for (const value of Object.values(obj)) { + if (Array.isArray(value)) { + value.forEach((item) => keys.push(...extractFileKeys(item))); + } else if (typeof value === 'object' && value !== null) { + keys.push(...extractFileKeys(value)); + } + } + } + return keys; + }; + + const finalFileKeys = new Set(extractFileKeys(currentFormValues.current)); + + // 计算需要删除的文件: + // 1. 在编辑期间上传的,但最终未保存的文件 + // 2. 初始配置中有的,但最终配置中没有的文件(被删除的文件) + const filesToDelete: string[] = []; + + // 上传了但未使用的文件 + uploadedFileKeys.current.forEach((key) => { + if (!finalFileKeys.has(key)) { + filesToDelete.push(key); + } }); + + // 初始有但最终没有的文件(被删除的) + initialFileKeys.current.forEach((key) => { + if (!finalFileKeys.has(key)) { + filesToDelete.push(key); + } + }); + + // 删除不需要的文件 + const deletePromises = filesToDelete.map((fileKey) => + httpClient.deletePluginConfigFile(fileKey).catch((err) => { + console.warn(`Failed to delete file ${fileKey}:`, err); + }), + ); + + await Promise.all(deletePromises); + + toast.success( + isDebugPlugin + ? t('plugins.saveConfigSuccessDebugPlugin') + : t('plugins.saveConfigSuccessNormal'), + ); + onFormSubmit(1000); + } catch (error) { + toast.error(t('plugins.saveConfigError') + (error as Error).message); + } finally { + setIsLoading(false); + } }; if (!pluginInfo || !pluginConfig) { @@ -99,6 +175,10 @@ export default function PluginForm({ // 只保存表单值的引用,不触发状态更新 currentFormValues.current = values; }} + onFileUploaded={(fileKey) => { + // 追踪上传的文件 + uploadedFileKeys.current.add(fileKey); + }} /> )} {pluginInfo.manifest.manifest.spec.config.length === 0 && ( diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 0d70367d..0921052d 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -456,6 +456,12 @@ export class BackendClient extends BaseHttpClient { }); } + public deletePluginConfigFile( + fileKey: string, + ): Promise<{ deleted: boolean }> { + return this.delete(`/api/v1/plugins/config-files/${fileKey}`); + } + public getPluginIconURL(author: string, name: string): string { if (this.instance.defaults.baseURL === '/') { const url = window.location.href;