mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-21 09:47:13 +00:00
feat: plugin config file auto clean
This commit is contained in:
@@ -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 && (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user