feat: add and type plugin config fields

This commit is contained in:
Junyan Qin
2025-11-05 23:48:59 +08:00
parent 4a868afecd
commit b74e07b608
9 changed files with 323 additions and 14 deletions

View File

@@ -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})

View File

@@ -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(

View File

@@ -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<string, object>;
}) {
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]);

View File

@@ -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<LLMModel[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [uploading, setUploading] = useState<boolean>(false);
const { t } = useTranslation();
const handleFileUpload = async (file: File): Promise<IFileConfig | null> => {
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 <Input {...field} />;
case DynamicFormItemType.TEXT:
return <Textarea {...field} className="min-h-[120px]" />;
case DynamicFormItemType.BOOLEAN:
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
@@ -366,6 +398,185 @@ export default function DynamicFormItemComponent({
</div>
);
case DynamicFormItemType.FILE:
return (
<div className="space-y-2">
{field.value && (field.value as IFileConfig).file_key ? (
<Card className="py-3 max-w-full overflow-hidden bg-gray-900">
<CardContent className="flex items-center gap-3 p-0 px-4 min-w-0">
<div className="flex-1 min-w-0 overflow-hidden">
<div
className="text-sm font-medium truncate"
title={(field.value as IFileConfig).file_key}
>
{(field.value as IFileConfig).file_key}
</div>
<div className="text-xs text-muted-foreground truncate">
{(field.value as IFileConfig).mimetype}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="flex-shrink-0 h-8 w-8 p-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
field.onChange(null);
}}
title={t('common.delete')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4 text-destructive"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
</Button>
</CardContent>
</Card>
) : (
<div className="relative">
<input
type="file"
accept={config.accept}
disabled={uploading}
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
const fileConfig = await handleFileUpload(file);
if (fileConfig) {
field.onChange(fileConfig);
}
}
e.target.value = '';
}}
className="hidden"
id={`file-input-${config.name}`}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploading}
onClick={() =>
document.getElementById(`file-input-${config.name}`)?.click()
}
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg>
{uploading
? t('plugins.fileUpload.uploading')
: t('plugins.fileUpload.chooseFile')}
</Button>
</div>
)}
</div>
);
case DynamicFormItemType.FILE_ARRAY:
return (
<div className="space-y-2">
{(field.value as IFileConfig[])?.map(
(fileConfig: IFileConfig, index: number) => (
<Card
key={index}
className="py-3 max-w-full overflow-hidden bg-gray-900"
>
<CardContent className="flex items-center gap-3 p-0 px-4 min-w-0">
<div className="flex-1 min-w-0 overflow-hidden">
<div
className="text-sm font-medium truncate"
title={fileConfig.file_key}
>
{fileConfig.file_key}
</div>
<div className="text-xs text-muted-foreground truncate">
{fileConfig.mimetype}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="flex-shrink-0 h-8 w-8 p-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const newValue = (field.value as IFileConfig[]).filter(
(_: IFileConfig, i: number) => i !== index,
);
field.onChange(newValue);
}}
title={t('common.delete')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4 text-destructive"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
</Button>
</CardContent>
</Card>
),
)}
<div className="relative">
<input
type="file"
accept={config.accept}
disabled={uploading}
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
const fileConfig = await handleFileUpload(file);
if (fileConfig) {
field.onChange([...(field.value || []), fileConfig]);
}
}
e.target.value = '';
}}
className="hidden"
id={`file-array-input-${config.name}`}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploading}
onClick={() =>
document
.getElementById(`file-array-input-${config.name}`)
?.click()
}
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg>
{uploading
? t('plugins.fileUpload.uploading')
: t('plugins.fileUpload.addFile')}
</Button>
</div>
</div>
);
default:
return <Input {...field} />;
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { ApiRespPluginConfig } from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin';
import { httpClient } from '@/app/infra/http/HttpClient';
@@ -24,6 +24,7 @@ export default function PluginForm({
const [pluginInfo, setPluginInfo] = useState<Plugin>();
const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>();
const [isSaving, setIsLoading] = useState(false);
const currentFormValues = useRef<object>({});
useEffect(() => {
// 获取插件信息
@@ -36,11 +37,11 @@ export default function PluginForm({
});
}, [pluginAuthor, pluginName]);
const handleSubmit = async (values: object) => {
const handleSubmit = async () => {
setIsLoading(true);
const isDebugPlugin = pluginInfo?.debug;
httpClient
.updatePluginConfig(pluginAuthor, pluginName, values)
.updatePluginConfig(pluginAuthor, pluginName, currentFormValues.current)
.then(() => {
toast.success(
isDebugPlugin
@@ -95,14 +96,8 @@ export default function PluginForm({
itemConfigList={pluginInfo.manifest.manifest.spec.config}
initialValues={pluginConfig.config as Record<string, object>}
onSubmit={(values) => {
let config = pluginConfig.config;
config = {
...config,
...values,
};
setPluginConfig({
config: config,
});
// 只保存表单值的引用,不触发状态更新
currentFormValues.current = values;
}}
/>
)}
@@ -117,7 +112,7 @@ export default function PluginForm({
<div className="flex justify-end gap-2">
<Button
type="submit"
onClick={() => handleSubmit(pluginConfig.config)}
onClick={() => handleSubmit()}
disabled={isSaving}
>
{isSaving ? t('plugins.saving') : t('plugins.saveConfig')}

View File

@@ -9,6 +9,7 @@ export interface IDynamicFormItemSchema {
type: DynamicFormItemType;
description?: I18nObject;
options?: IDynamicFormItemOption[];
accept?: string; // For file type: accepted MIME types
}
export enum DynamicFormItemType {
@@ -16,7 +17,10 @@ export enum DynamicFormItemType {
FLOAT = 'float',
BOOLEAN = 'boolean',
STRING = 'string',
TEXT = 'text',
STRING_ARRAY = 'array[string]',
FILE = 'file',
FILE_ARRAY = 'array[file]',
SELECT = 'select',
LLM_MODEL_SELECTOR = 'llm-model-selector',
PROMPT_EDITOR = 'prompt-editor',
@@ -24,6 +28,11 @@ export enum DynamicFormItemType {
KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',
}
export interface IFileConfig {
file_key: string;
mimetype: string;
}
export interface IDynamicFormItemOption {
name: string;
label: I18nObject;

View File

@@ -442,6 +442,20 @@ export class BackendClient extends BaseHttpClient {
return this.put(`/api/v1/plugins/${author}/${name}/config`, config);
}
public uploadPluginConfigFile(file: File): Promise<{ file_key: string }> {
const formData = new FormData();
formData.append('file', file);
return this.request<{ file_key: string }>({
method: 'post',
url: '/api/v1/plugins/config-files',
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
public getPluginIconURL(author: string, name: string): string {
if (this.instance.defaults.baseURL === '/') {
const url = window.location.href;

View File

@@ -242,6 +242,14 @@ const enUS = {
saveConfigSuccessDebugPlugin:
'Configuration saved successfully, please manually restart the plugin',
saveConfigError: 'Configuration save failed: ',
fileUpload: {
tooLarge: 'File size exceeds 10MB limit',
success: 'File uploaded successfully',
failed: 'File upload failed',
uploading: 'Uploading...',
chooseFile: 'Choose File',
addFile: 'Add File',
},
installFromGithub: 'From GitHub',
enterRepoUrl: 'Enter GitHub repository URL',
repoUrlPlaceholder: 'e.g., https://github.com/owner/repo',

View File

@@ -230,6 +230,14 @@ const zhHans = {
saveConfigSuccessNormal: '保存配置成功',
saveConfigSuccessDebugPlugin: '保存配置成功,请手动重启插件',
saveConfigError: '保存配置失败:',
fileUpload: {
tooLarge: '文件大小超过 10MB 限制',
success: '文件上传成功',
failed: '文件上传失败',
uploading: '上传中...',
chooseFile: '选择文件',
addFile: '添加文件',
},
installFromGithub: '来自 GitHub',
enterRepoUrl: '请输入 GitHub 仓库地址',
repoUrlPlaceholder: '例如: https://github.com/owner/repo',