From b74e07b6083029fa69c2c1f161116abaf6f94a2c Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Wed, 5 Nov 2025 23:48:59 +0800 Subject: [PATCH 1/5] feat: add and type plugin config fields --- pkg/api/http/controller/groups/plugins.py | 25 +++ pkg/plugin/handler.py | 19 ++ .../dynamic-form/DynamicFormComponent.tsx | 24 +- .../dynamic-form/DynamicFormItemComponent.tsx | 211 ++++++++++++++++++ .../plugin-form/PluginForm.tsx | 19 +- web/src/app/infra/entities/form/dynamic.ts | 9 + web/src/app/infra/http/BackendClient.ts | 14 ++ web/src/i18n/locales/en-US.ts | 8 + web/src/i18n/locales/zh-Hans.ts | 8 + 9 files changed, 323 insertions(+), 14 deletions(-) diff --git a/pkg/api/http/controller/groups/plugins.py b/pkg/api/http/controller/groups/plugins.py index 56d05b53..f8a01118 100644 --- a/pkg/api/http/controller/groups/plugins.py +++ b/pkg/api/http/controller/groups/plugins.py @@ -4,6 +4,8 @@ import base64 import quart import re import httpx +import uuid +import os from .....core import taskmgr from .. import group @@ -269,3 +271,26 @@ class PluginsRouterGroup(group.RouterGroup): ) return self.success(data={'task_id': wrapper.id}) + + @self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """Upload a file for plugin configuration""" + file = (await quart.request.files).get('file') + if file is None: + return self.http_status(400, -1, 'file is required') + + # Check file size (10MB limit) + MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB + file_bytes = file.read() + if len(file_bytes) > MAX_FILE_SIZE: + return self.http_status(400, -1, 'file size exceeds 10MB limit') + + # Generate unique file key with original extension + original_filename = file.filename + _, ext = os.path.splitext(original_filename) + file_key = f'plugin_config_{uuid.uuid4().hex}{ext}' + + # Save file using storage manager + await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes) + + return self.success(data={'file_key': file_key}) diff --git a/pkg/plugin/handler.py b/pkg/plugin/handler.py index da710f41..22d09d20 100644 --- a/pkg/plugin/handler.py +++ b/pkg/plugin/handler.py @@ -436,6 +436,25 @@ class RuntimeConnectionHandler(handler.Handler): }, ) + @self.action(RuntimeToLangBotAction.GET_CONFIG_FILE) + async def get_config_file(data: dict[str, Any]) -> handler.ActionResponse: + """Get a config file by file key""" + file_key = data['file_key'] + + try: + # Load file from storage + file_bytes = await self.ap.storage_mgr.storage_provider.load(file_key) + + return handler.ActionResponse.success( + data={ + 'file_base64': base64.b64encode(file_bytes).decode('utf-8'), + }, + ) + except Exception as e: + return handler.ActionResponse.error( + message=f'Failed to load config file {file_key}: {e}', + ) + async def ping(self) -> dict[str, Any]: """Ping the runtime""" return await self.call_action( diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 6d13e99c..36344622 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -11,7 +11,7 @@ import { FormMessage, } from '@/components/ui/form'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { extractI18nObject } from '@/i18n/I18nProvider'; export default function DynamicFormComponent({ @@ -23,6 +23,9 @@ export default function DynamicFormComponent({ onSubmit?: (val: object) => unknown; initialValues?: Record; }) { + const isInitialMount = useRef(true); + const previousInitialValues = useRef(initialValues); + // 根据 itemConfigList 动态生成 zod schema const formSchema = z.object( itemConfigList.reduce( @@ -97,9 +100,24 @@ export default function DynamicFormComponent({ }); // 当 initialValues 变化时更新表单值 + // 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单 useEffect(() => { console.log('initialValues', initialValues); - if (initialValues) { + + // 首次挂载时,使用 initialValues 初始化表单 + if (isInitialMount.current) { + isInitialMount.current = false; + previousInitialValues.current = initialValues; + return; + } + + // 检查 initialValues 是否真的发生了实质性变化 + // 使用 JSON.stringify 进行深度比较 + const hasRealChange = + JSON.stringify(previousInitialValues.current) !== + JSON.stringify(initialValues); + + if (initialValues && hasRealChange) { // 合并默认值和初始值 const mergedValues = itemConfigList.reduce( (acc, item) => { @@ -112,6 +130,8 @@ export default function DynamicFormComponent({ Object.entries(mergedValues).forEach(([key, value]) => { form.setValue(key as keyof FormValues, value); }); + + previousInitialValues.current = initialValues; } }, [initialValues, form, itemConfigList]); diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index 60062c22..1f09f93d 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -1,6 +1,7 @@ import { DynamicFormItemType, IDynamicFormItemSchema, + IFileConfig, } from '@/app/infra/entities/form/dynamic'; import { Input } from '@/components/ui/input'; import { @@ -27,6 +28,7 @@ import { import { useTranslation } from 'react-i18next'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent } from '@/components/ui/card'; export default function DynamicFormItemComponent({ config, @@ -38,8 +40,35 @@ export default function DynamicFormItemComponent({ }) { const [llmModels, setLlmModels] = useState([]); const [knowledgeBases, setKnowledgeBases] = useState([]); + const [uploading, setUploading] = useState(false); const { t } = useTranslation(); + const handleFileUpload = async (file: File): Promise => { + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + + if (file.size > MAX_FILE_SIZE) { + toast.error(t('plugins.fileUpload.tooLarge')); + return null; + } + + try { + setUploading(true); + const response = await httpClient.uploadPluginConfigFile(file); + toast.success(t('plugins.fileUpload.success')); + return { + file_key: response.file_key, + mimetype: file.type, + }; + } catch (error) { + toast.error( + t('plugins.fileUpload.failed') + ': ' + (error as Error).message, + ); + return null; + } finally { + setUploading(false); + } + }; + useEffect(() => { if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) { httpClient @@ -80,6 +109,9 @@ export default function DynamicFormItemComponent({ case DynamicFormItemType.STRING: return ; + case DynamicFormItemType.TEXT: + return