feat: plugin config file auto clean

This commit is contained in:
Junyan Qin
2025-11-06 00:02:25 +08:00
parent d44df75e5c
commit b529d07479
5 changed files with 127 additions and 16 deletions
+13
View File
@@ -294,3 +294,16 @@ class PluginsRouterGroup(group.RouterGroup):
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes) await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
return self.success(data={'file_key': file_key}) return self.success(data={'file_key': file_key})
@self.route('/config-files/<file_key>', 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)}')
@@ -18,10 +18,12 @@ export default function DynamicFormComponent({
itemConfigList, itemConfigList,
onSubmit, onSubmit,
initialValues, initialValues,
onFileUploaded,
}: { }: {
itemConfigList: IDynamicFormItemSchema[]; itemConfigList: IDynamicFormItemSchema[];
onSubmit?: (val: object) => unknown; onSubmit?: (val: object) => unknown;
initialValues?: Record<string, object>; initialValues?: Record<string, object>;
onFileUploaded?: (fileKey: string) => void;
}) { }) {
const isInitialMount = useRef(true); const isInitialMount = useRef(true);
const previousInitialValues = useRef(initialValues); const previousInitialValues = useRef(initialValues);
@@ -169,7 +171,11 @@ export default function DynamicFormComponent({
{config.required && <span className="text-red-500">*</span>} {config.required && <span className="text-red-500">*</span>}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<DynamicFormItemComponent config={config} field={field} /> <DynamicFormItemComponent
config={config}
field={field}
onFileUploaded={onFileUploaded}
/>
</FormControl> </FormControl>
{config.description && ( {config.description && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -33,10 +33,12 @@ import { Card, CardContent } from '@/components/ui/card';
export default function DynamicFormItemComponent({ export default function DynamicFormItemComponent({
config, config,
field, field,
onFileUploaded,
}: { }: {
config: IDynamicFormItemSchema; config: IDynamicFormItemSchema;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
field: ControllerRenderProps<any, any>; field: ControllerRenderProps<any, any>;
onFileUploaded?: (fileKey: string) => void;
}) { }) {
const [llmModels, setLlmModels] = useState<LLMModel[]>([]); const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]); const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
@@ -55,6 +57,10 @@ export default function DynamicFormItemComponent({
setUploading(true); setUploading(true);
const response = await httpClient.uploadPluginConfigFile(file); const response = await httpClient.uploadPluginConfigFile(file);
toast.success(t('plugins.fileUpload.success')); toast.success(t('plugins.fileUpload.success'));
// 通知父组件文件已上传
onFileUploaded?.(response.file_key);
return { return {
file_key: response.file_key, file_key: response.file_key,
mimetype: file.type, mimetype: file.type,
@@ -8,6 +8,7 @@ import { toast } from 'sonner';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList'; import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
import { IFileConfig } from '@/app/infra/entities/form/dynamic';
export default function PluginForm({ export default function PluginForm({
pluginAuthor, pluginAuthor,
@@ -25,6 +26,8 @@ export default function PluginForm({
const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>(); const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>();
const [isSaving, setIsLoading] = useState(false); const [isSaving, setIsLoading] = useState(false);
const currentFormValues = useRef<object>({}); const currentFormValues = useRef<object>({});
const uploadedFileKeys = useRef<Set<string>>(new Set());
const initialFileKeys = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
// 获取插件信息 // 获取插件信息
@@ -34,28 +37,101 @@ export default function PluginForm({
// 获取插件配置 // 获取插件配置
httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => { httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => {
setPluginConfig(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]); }, [pluginAuthor, pluginName]);
const handleSubmit = async () => { const handleSubmit = async () => {
setIsLoading(true); setIsLoading(true);
const isDebugPlugin = pluginInfo?.debug; const isDebugPlugin = pluginInfo?.debug;
httpClient
.updatePluginConfig(pluginAuthor, pluginName, currentFormValues.current) try {
.then(() => { // 保存配置
toast.success( await httpClient.updatePluginConfig(
isDebugPlugin pluginAuthor,
? t('plugins.saveConfigSuccessDebugPlugin') pluginName,
: t('plugins.saveConfigSuccessNormal'), currentFormValues.current,
); );
onFormSubmit(1000);
}) // 提取最终保存的配置中的所有文件 key
.catch((error) => { const extractFileKeys = (obj: any): string[] => {
toast.error(t('plugins.saveConfigError') + error.message); const keys: string[] = [];
}) if (obj && typeof obj === 'object') {
.finally(() => { if ('file_key' in obj && typeof obj.file_key === 'string') {
setIsLoading(false); 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) { if (!pluginInfo || !pluginConfig) {
@@ -99,6 +175,10 @@ export default function PluginForm({
// 只保存表单值的引用,不触发状态更新 // 只保存表单值的引用,不触发状态更新
currentFormValues.current = values; currentFormValues.current = values;
}} }}
onFileUploaded={(fileKey) => {
// 追踪上传的文件
uploadedFileKeys.current.add(fileKey);
}}
/> />
)} )}
{pluginInfo.manifest.manifest.spec.config.length === 0 && ( {pluginInfo.manifest.manifest.spec.config.length === 0 && (
+6
View File
@@ -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 { public getPluginIconURL(author: string, name: string): string {
if (this.instance.defaults.baseURL === '/') { if (this.instance.defaults.baseURL === '/') {
const url = window.location.href; const url = window.location.href;