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:
Junyan Qin (Chin)
2025-11-06 00:07:57 +08:00
committed by GitHub
12 changed files with 467 additions and 30 deletions

View File

@@ -4,6 +4,8 @@ import base64
import quart import quart
import re import re
import httpx import httpx
import uuid
import os
from .....core import taskmgr from .....core import taskmgr
from .. import group from .. import group
@@ -269,3 +271,39 @@ class PluginsRouterGroup(group.RouterGroup):
) )
return self.success(data={'task_id': wrapper.id}) 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)}')

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]: async def ping(self) -> dict[str, Any]:
"""Ping the runtime""" """Ping the runtime"""
return await self.call_action( return await self.call_action(

View File

@@ -63,7 +63,7 @@ dependencies = [
"langchain-text-splitters>=0.0.1", "langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24", "chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.1.7", "langbot-plugin==0.1.8",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10", "tboxsdk>=0.0.10",

View File

@@ -11,18 +11,23 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
export default function DynamicFormComponent({ 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 previousInitialValues = useRef(initialValues);
// 根据 itemConfigList 动态生成 zod schema // 根据 itemConfigList 动态生成 zod schema
const formSchema = z.object( const formSchema = z.object(
itemConfigList.reduce( itemConfigList.reduce(
@@ -97,9 +102,24 @@ export default function DynamicFormComponent({
}); });
// 当 initialValues 变化时更新表单值 // 当 initialValues 变化时更新表单值
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
useEffect(() => { useEffect(() => {
console.log('initialValues', initialValues); 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( const mergedValues = itemConfigList.reduce(
(acc, item) => { (acc, item) => {
@@ -112,6 +132,8 @@ export default function DynamicFormComponent({
Object.entries(mergedValues).forEach(([key, value]) => { Object.entries(mergedValues).forEach(([key, value]) => {
form.setValue(key as keyof FormValues, value); form.setValue(key as keyof FormValues, value);
}); });
previousInitialValues.current = initialValues;
} }
}, [initialValues, form, itemConfigList]); }, [initialValues, form, itemConfigList]);
@@ -149,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">

View File

@@ -1,6 +1,7 @@
import { import {
DynamicFormItemType, DynamicFormItemType,
IDynamicFormItemSchema, IDynamicFormItemSchema,
IFileConfig,
} from '@/app/infra/entities/form/dynamic'; } from '@/app/infra/entities/form/dynamic';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import {
@@ -27,19 +28,53 @@ import {
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
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[]>([]);
const [uploading, setUploading] = useState<boolean>(false);
const { t } = useTranslation(); 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(() => { useEffect(() => {
if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) { if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) {
httpClient httpClient
@@ -80,6 +115,9 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.STRING: case DynamicFormItemType.STRING:
return <Input {...field} />; return <Input {...field} />;
case DynamicFormItemType.TEXT:
return <Textarea {...field} className="min-h-[120px]" />;
case DynamicFormItemType.BOOLEAN: case DynamicFormItemType.BOOLEAN:
return <Switch checked={field.value} onCheckedChange={field.onChange} />; return <Switch checked={field.value} onCheckedChange={field.onChange} />;
@@ -366,6 +404,185 @@ export default function DynamicFormItemComponent({
</div> </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: default:
return <Input {...field} />; 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 { ApiRespPluginConfig } from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin'; import { Plugin } from '@/app/infra/entities/plugin';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
@@ -24,6 +24,9 @@ export default function PluginForm({
const [pluginInfo, setPluginInfo] = useState<Plugin>(); const [pluginInfo, setPluginInfo] = useState<Plugin>();
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 uploadedFileKeys = useRef<Set<string>>(new Set());
const initialFileKeys = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
// 获取插件信息 // 获取插件信息
@@ -33,28 +36,103 @@ export default function PluginForm({
// 获取插件配置 // 获取插件配置
httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => { httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => {
setPluginConfig(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]); }, [pluginAuthor, pluginName]);
const handleSubmit = async (values: object) => { const handleSubmit = async () => {
setIsLoading(true); setIsLoading(true);
const isDebugPlugin = pluginInfo?.debug; const isDebugPlugin = pluginInfo?.debug;
httpClient
.updatePluginConfig(pluginAuthor, pluginName, values) try {
.then(() => { // 保存配置
toast.success( await httpClient.updatePluginConfig(
isDebugPlugin pluginAuthor,
? t('plugins.saveConfigSuccessDebugPlugin') pluginName,
: t('plugins.saveConfigSuccessNormal'), currentFormValues.current,
); );
onFormSubmit(1000);
}) // 提取最终保存的配置中的所有文件 key
.catch((error) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
toast.error(t('plugins.saveConfigError') + error.message); const extractFileKeys = (obj: any): string[] => {
}) const keys: string[] = [];
.finally(() => { if (obj && typeof obj === 'object') {
setIsLoading(false); 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) { if (!pluginInfo || !pluginConfig) {
@@ -95,14 +173,12 @@ export default function PluginForm({
itemConfigList={pluginInfo.manifest.manifest.spec.config} itemConfigList={pluginInfo.manifest.manifest.spec.config}
initialValues={pluginConfig.config as Record<string, object>} initialValues={pluginConfig.config as Record<string, object>}
onSubmit={(values) => { onSubmit={(values) => {
let config = pluginConfig.config; // 只保存表单值的引用,不触发状态更新
config = { currentFormValues.current = values;
...config, }}
...values, onFileUploaded={(fileKey) => {
}; // 追踪上传的文件
setPluginConfig({ uploadedFileKeys.current.add(fileKey);
config: config,
});
}} }}
/> />
)} )}
@@ -117,7 +193,7 @@ export default function PluginForm({
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button
type="submit" type="submit"
onClick={() => handleSubmit(pluginConfig.config)} onClick={() => handleSubmit()}
disabled={isSaving} disabled={isSaving}
> >
{isSaving ? t('plugins.saving') : t('plugins.saveConfig')} {isSaving ? t('plugins.saving') : t('plugins.saveConfig')}

View File

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

View File

@@ -442,6 +442,26 @@ export class BackendClient extends BaseHttpClient {
return this.put(`/api/v1/plugins/${author}/${name}/config`, config); 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 { 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;

View File

@@ -242,6 +242,14 @@ const enUS = {
saveConfigSuccessDebugPlugin: saveConfigSuccessDebugPlugin:
'Configuration saved successfully, please manually restart the plugin', 'Configuration saved successfully, please manually restart the plugin',
saveConfigError: 'Configuration save failed: ', 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', installFromGithub: 'From GitHub',
enterRepoUrl: 'Enter GitHub repository URL', enterRepoUrl: 'Enter GitHub repository URL',
repoUrlPlaceholder: 'e.g., https://github.com/owner/repo', repoUrlPlaceholder: 'e.g., https://github.com/owner/repo',

View File

@@ -242,6 +242,14 @@ const jaJP = {
saveConfigSuccessDebugPlugin: saveConfigSuccessDebugPlugin:
'設定を保存しました。手動でプラグインを再起動してください', '設定を保存しました。手動でプラグインを再起動してください',
saveConfigError: '設定の保存に失敗しました:', saveConfigError: '設定の保存に失敗しました:',
fileUpload: {
tooLarge: 'ファイルサイズが 10MB の制限を超えています',
success: 'ファイルのアップロードに成功しました',
failed: 'ファイルのアップロードに失敗しました',
uploading: 'アップロード中...',
chooseFile: 'ファイルを選択',
addFile: 'ファイルを追加',
},
installFromGithub: 'GitHubから', installFromGithub: 'GitHubから',
enterRepoUrl: 'GitHubリポジトリのURLを入力してください', enterRepoUrl: 'GitHubリポジトリのURLを入力してください',
repoUrlPlaceholder: '例: https://github.com/owner/repo', repoUrlPlaceholder: '例: https://github.com/owner/repo',

View File

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

View File

@@ -229,6 +229,14 @@ const zhHant = {
saveConfigSuccessNormal: '儲存配置成功', saveConfigSuccessNormal: '儲存配置成功',
saveConfigSuccessDebugPlugin: '儲存配置成功,請手動重啟插件', saveConfigSuccessDebugPlugin: '儲存配置成功,請手動重啟插件',
saveConfigError: '儲存配置失敗:', saveConfigError: '儲存配置失敗:',
fileUpload: {
tooLarge: '檔案大小超過 10MB 限制',
success: '檔案上傳成功',
failed: '檔案上傳失敗',
uploading: '上傳中...',
chooseFile: '選擇檔案',
addFile: '新增檔案',
},
enterRepoUrl: '請輸入 GitHub 倉庫地址', enterRepoUrl: '請輸入 GitHub 倉庫地址',
repoUrlPlaceholder: '例如: https://github.com/owner/repo', repoUrlPlaceholder: '例如: https://github.com/owner/repo',
fetchingReleases: '正在獲取 Release 列表...', fetchingReleases: '正在獲取 Release 列表...',