mirror of
https://github.com/langbot-app/LangBot.git
synced 2025-11-25 11:29:39 +08:00
feat: add file array[file] and text type plugin config fields (#1750)
* feat: add and type plugin config fields * chore: add hant and jp i18n * feat: plugin config file auto clean * chore: bump langbot-plugin to 0.1.8 * chore: fix linter errors
This commit is contained in:
@@ -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,39 @@ 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})
|
||||
|
||||
@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)}')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -63,7 +63,7 @@ dependencies = [
|
||||
"langchain-text-splitters>=0.0.1",
|
||||
"chromadb>=0.4.24",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"langbot-plugin==0.1.7",
|
||||
"langbot-plugin==0.1.8",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"tboxsdk>=0.0.10",
|
||||
|
||||
@@ -11,18 +11,23 @@ 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({
|
||||
itemConfigList,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
onFileUploaded,
|
||||
}: {
|
||||
itemConfigList: IDynamicFormItemSchema[];
|
||||
onSubmit?: (val: object) => unknown;
|
||||
initialValues?: Record<string, object>;
|
||||
onFileUploaded?: (fileKey: string) => void;
|
||||
}) {
|
||||
const isInitialMount = useRef(true);
|
||||
const previousInitialValues = useRef(initialValues);
|
||||
|
||||
// 根据 itemConfigList 动态生成 zod schema
|
||||
const formSchema = z.object(
|
||||
itemConfigList.reduce(
|
||||
@@ -97,9 +102,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 +132,8 @@ export default function DynamicFormComponent({
|
||||
Object.entries(mergedValues).forEach(([key, value]) => {
|
||||
form.setValue(key as keyof FormValues, value);
|
||||
});
|
||||
|
||||
previousInitialValues.current = initialValues;
|
||||
}
|
||||
}, [initialValues, form, itemConfigList]);
|
||||
|
||||
@@ -149,7 +171,11 @@ export default function DynamicFormComponent({
|
||||
{config.required && <span className="text-red-500">*</span>}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<DynamicFormItemComponent config={config} field={field} />
|
||||
<DynamicFormItemComponent
|
||||
config={config}
|
||||
field={field}
|
||||
onFileUploaded={onFileUploaded}
|
||||
/>
|
||||
</FormControl>
|
||||
{config.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
DynamicFormItemType,
|
||||
IDynamicFormItemSchema,
|
||||
IFileConfig,
|
||||
} from '@/app/infra/entities/form/dynamic';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@@ -27,19 +28,53 @@ 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,
|
||||
field,
|
||||
onFileUploaded,
|
||||
}: {
|
||||
config: IDynamicFormItemSchema;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
field: ControllerRenderProps<any, any>;
|
||||
onFileUploaded?: (fileKey: string) => void;
|
||||
}) {
|
||||
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'));
|
||||
|
||||
// 通知父组件文件已上传
|
||||
onFileUploaded?.(response.file_key);
|
||||
|
||||
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 +115,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 +404,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} />;
|
||||
}
|
||||
|
||||
@@ -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,9 @@ export default function PluginForm({
|
||||
const [pluginInfo, setPluginInfo] = useState<Plugin>();
|
||||
const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>();
|
||||
const [isSaving, setIsLoading] = useState(false);
|
||||
const currentFormValues = useRef<object>({});
|
||||
const uploadedFileKeys = useRef<Set<string>>(new Set());
|
||||
const initialFileKeys = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// 获取插件信息
|
||||
@@ -33,28 +36,103 @@ export default function PluginForm({
|
||||
// 获取插件配置
|
||||
httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => {
|
||||
setPluginConfig(res);
|
||||
|
||||
// 提取初始配置中的所有文件 key
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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 (values: object) => {
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
const isDebugPlugin = pluginInfo?.debug;
|
||||
httpClient
|
||||
.updatePluginConfig(pluginAuthor, pluginName, values)
|
||||
.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
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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) {
|
||||
@@ -95,14 +173,12 @@ 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;
|
||||
}}
|
||||
onFileUploaded={(fileKey) => {
|
||||
// 追踪上传的文件
|
||||
uploadedFileKeys.current.add(fileKey);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -117,7 +193,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')}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -442,6 +442,26 @@ 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 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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -242,6 +242,14 @@ const jaJP = {
|
||||
saveConfigSuccessDebugPlugin:
|
||||
'設定を保存しました。手動でプラグインを再起動してください',
|
||||
saveConfigError: '設定の保存に失敗しました:',
|
||||
fileUpload: {
|
||||
tooLarge: 'ファイルサイズが 10MB の制限を超えています',
|
||||
success: 'ファイルのアップロードに成功しました',
|
||||
failed: 'ファイルのアップロードに失敗しました',
|
||||
uploading: 'アップロード中...',
|
||||
chooseFile: 'ファイルを選択',
|
||||
addFile: 'ファイルを追加',
|
||||
},
|
||||
installFromGithub: 'GitHubから',
|
||||
enterRepoUrl: 'GitHubリポジトリのURLを入力してください',
|
||||
repoUrlPlaceholder: '例: https://github.com/owner/repo',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -229,6 +229,14 @@ const zhHant = {
|
||||
saveConfigSuccessNormal: '儲存配置成功',
|
||||
saveConfigSuccessDebugPlugin: '儲存配置成功,請手動重啟插件',
|
||||
saveConfigError: '儲存配置失敗:',
|
||||
fileUpload: {
|
||||
tooLarge: '檔案大小超過 10MB 限制',
|
||||
success: '檔案上傳成功',
|
||||
failed: '檔案上傳失敗',
|
||||
uploading: '上傳中...',
|
||||
chooseFile: '選擇檔案',
|
||||
addFile: '新增檔案',
|
||||
},
|
||||
enterRepoUrl: '請輸入 GitHub 倉庫地址',
|
||||
repoUrlPlaceholder: '例如: https://github.com/owner/repo',
|
||||
fetchingReleases: '正在獲取 Release 列表...',
|
||||
|
||||
Reference in New Issue
Block a user