mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-10 21:10:59 +00:00
feat: plugin deletion and upgrade
This commit is contained in:
@@ -29,17 +29,17 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return self.success()
|
return self.success()
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/<author>/<plugin_name>/update',
|
'/<author>/<plugin_name>/upgrade',
|
||||||
methods=['POST'],
|
methods=['POST'],
|
||||||
auth_type=group.AuthType.USER_TOKEN,
|
auth_type=group.AuthType.USER_TOKEN,
|
||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str) -> str:
|
async def _(author: str, plugin_name: str) -> str:
|
||||||
ctx = taskmgr.TaskContext.new()
|
ctx = taskmgr.TaskContext.new()
|
||||||
wrapper = self.ap.task_mgr.create_user_task(
|
wrapper = self.ap.task_mgr.create_user_task(
|
||||||
self.ap.plugin_mgr.update_plugin(plugin_name, task_context=ctx),
|
self.ap.plugin_connector.upgrade_plugin(author, plugin_name, task_context=ctx),
|
||||||
kind='plugin-operation',
|
kind='plugin-operation',
|
||||||
name=f'plugin-update-{plugin_name}',
|
name=f'plugin-upgrade-{plugin_name}',
|
||||||
label=f'更新插件 {plugin_name}',
|
label=f'Upgrading plugin {plugin_name}',
|
||||||
context=ctx,
|
context=ctx,
|
||||||
)
|
)
|
||||||
return self.success(data={'task_id': wrapper.id})
|
return self.success(data={'task_id': wrapper.id})
|
||||||
@@ -58,10 +58,10 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
elif quart.request.method == 'DELETE':
|
elif quart.request.method == 'DELETE':
|
||||||
ctx = taskmgr.TaskContext.new()
|
ctx = taskmgr.TaskContext.new()
|
||||||
wrapper = self.ap.task_mgr.create_user_task(
|
wrapper = self.ap.task_mgr.create_user_task(
|
||||||
self.ap.plugin_mgr.uninstall_plugin(plugin_name, task_context=ctx),
|
self.ap.plugin_connector.delete_plugin(author, plugin_name, task_context=ctx),
|
||||||
kind='plugin-operation',
|
kind='plugin-operation',
|
||||||
name=f'plugin-remove-{plugin_name}',
|
name=f'plugin-remove-{plugin_name}',
|
||||||
label=f'删除插件 {plugin_name}',
|
label=f'Removing plugin {plugin_name}',
|
||||||
context=ctx,
|
context=ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,34 @@ class PluginRuntimeConnector:
|
|||||||
if task_context is not None:
|
if task_context is not None:
|
||||||
task_context.trace(trace)
|
task_context.trace(trace)
|
||||||
|
|
||||||
|
async def upgrade_plugin(
|
||||||
|
self, plugin_author: str, plugin_name: str, task_context: taskmgr.TaskContext | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
async for ret in self.handler.upgrade_plugin(plugin_author, plugin_name):
|
||||||
|
current_action = ret.get('current_action', None)
|
||||||
|
if current_action is not None:
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action(current_action)
|
||||||
|
|
||||||
|
trace = ret.get('trace', None)
|
||||||
|
if trace is not None:
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.trace(trace)
|
||||||
|
|
||||||
|
async def delete_plugin(
|
||||||
|
self, plugin_author: str, plugin_name: str, task_context: taskmgr.TaskContext | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
async for ret in self.handler.delete_plugin(plugin_author, plugin_name):
|
||||||
|
current_action = ret.get('current_action', None)
|
||||||
|
if current_action is not None:
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action(current_action)
|
||||||
|
|
||||||
|
trace = ret.get('trace', None)
|
||||||
|
if trace is not None:
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.trace(trace)
|
||||||
|
|
||||||
async def list_plugins(self) -> list[dict[str, Any]]:
|
async def list_plugins(self) -> list[dict[str, Any]]:
|
||||||
return await self.handler.list_plugins()
|
return await self.handler.list_plugins()
|
||||||
|
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import abc
|
|
||||||
|
|
||||||
from ..core import app
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
|
||||||
import langbot_plugin.api.entities.events as events
|
|
||||||
|
|
||||||
|
|
||||||
def register(
|
|
||||||
name: str, description: str, version: str, author: str
|
|
||||||
) -> typing.Callable[[typing.Type[BasePlugin]], typing.Type[BasePlugin]]:
|
|
||||||
"""注册插件类
|
|
||||||
|
|
||||||
使用示例:
|
|
||||||
|
|
||||||
@register(
|
|
||||||
name="插件名称",
|
|
||||||
description="插件描述",
|
|
||||||
version="插件版本",
|
|
||||||
author="插件作者"
|
|
||||||
)
|
|
||||||
class MyPlugin(BasePlugin):
|
|
||||||
pass
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def handler(
|
|
||||||
event: typing.Type[events.BaseEventModel],
|
|
||||||
) -> typing.Callable[[typing.Callable], typing.Callable]:
|
|
||||||
"""注册事件监听器
|
|
||||||
|
|
||||||
使用示例:
|
|
||||||
|
|
||||||
class MyPlugin(BasePlugin):
|
|
||||||
|
|
||||||
@handler(NormalMessageResponded)
|
|
||||||
async def on_normal_message_responded(self, ctx: EventContext):
|
|
||||||
pass
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def llm_func(
|
|
||||||
name: str = None,
|
|
||||||
) -> typing.Callable:
|
|
||||||
"""注册内容函数
|
|
||||||
|
|
||||||
使用示例:
|
|
||||||
|
|
||||||
class MyPlugin(BasePlugin):
|
|
||||||
|
|
||||||
@llm_func("access_the_web_page")
|
|
||||||
async def _(self, query, url: str, brief_len: int):
|
|
||||||
\"""Call this function to search about the question before you answer any questions.
|
|
||||||
- Do not search through google.com at any time.
|
|
||||||
- If you need to search somthing, visit https://www.sogou.com/web?query=<something>.
|
|
||||||
- If user ask you to open a url (start with http:// or https://), visit it directly.
|
|
||||||
- Summary the plain content result by yourself, DO NOT directly output anything in the result you got.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url(str): url to visit
|
|
||||||
brief_len(int): max length of the plain text content, recommend 1024-4096, prefer 4096
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: plain text content of the web page or error message(starts with 'error:')
|
|
||||||
\"""
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class BasePlugin(metaclass=abc.ABCMeta):
|
|
||||||
"""插件基类"""
|
|
||||||
|
|
||||||
host: APIHost
|
|
||||||
"""API宿主"""
|
|
||||||
|
|
||||||
ap: app.Application
|
|
||||||
"""应用程序对象"""
|
|
||||||
|
|
||||||
config: dict
|
|
||||||
"""插件配置"""
|
|
||||||
|
|
||||||
def __init__(self, host: APIHost):
|
|
||||||
"""初始化阶段被调用"""
|
|
||||||
self.host = host
|
|
||||||
self.config = {}
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
"""初始化阶段被调用"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def destroy(self):
|
|
||||||
"""释放/禁用插件时被调用"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def __del__(self):
|
|
||||||
"""释放/禁用插件时被调用"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class APIHost:
|
|
||||||
"""LangBot API 宿主"""
|
|
||||||
|
|
||||||
ap: app.Application
|
|
||||||
|
|
||||||
def __init__(self, ap: app.Application):
|
|
||||||
self.ap = ap
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ========== 插件可调用的 API(主程序API) ==========
|
|
||||||
|
|
||||||
def get_platform_adapters(self) -> list[abstract_platform_adapter.AbstractMessagePlatformAdapter]:
|
|
||||||
"""获取已启用的消息平台适配器列表
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[platform.adapter.MessageSourceAdapter]: 已启用的消息平台适配器列表
|
|
||||||
"""
|
|
||||||
return self.ap.platform_mgr.get_running_adapters()
|
|
||||||
|
|
||||||
async def send_active_message(
|
|
||||||
self,
|
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
|
||||||
target_type: str,
|
|
||||||
target_id: str,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
):
|
|
||||||
"""发送主动消息
|
|
||||||
|
|
||||||
Args:
|
|
||||||
adapter (platform.adapter.MessageSourceAdapter): 消息平台适配器对象,调用 host.get_platform_adapters() 获取并取用其中某个
|
|
||||||
target_type (str): 目标类型,`person`或`group`
|
|
||||||
target_id (str): 目标ID
|
|
||||||
message (platform.types.MessageChain): 消息链
|
|
||||||
"""
|
|
||||||
await adapter.send_message(
|
|
||||||
target_type=target_type,
|
|
||||||
target_id=target_id,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
def require_ver(
|
|
||||||
self,
|
|
||||||
ge: str,
|
|
||||||
le: str = 'v999.999.999',
|
|
||||||
) -> bool:
|
|
||||||
"""插件版本要求装饰器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ge (str): 最低版本要求
|
|
||||||
le (str, optional): 最高版本要求
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 是否满足要求, False时为无法获取版本号,True时为满足要求,报错为不满足要求
|
|
||||||
"""
|
|
||||||
langbot_version = ''
|
|
||||||
|
|
||||||
try:
|
|
||||||
langbot_version = self.ap.ver_mgr.get_current_version() # 从updater模块获取版本号
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.ap.ver_mgr.compare_version_str(langbot_version, ge) < 0 or (
|
|
||||||
self.ap.ver_mgr.compare_version_str(langbot_version, le) > 0
|
|
||||||
):
|
|
||||||
raise Exception(
|
|
||||||
'LangBot 版本不满足要求,某些功能(可能是由插件提供的)无法正常使用。(要求版本:{}-{},但当前版本:{})'.format(
|
|
||||||
ge, le, langbot_version
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class EventContext:
|
|
||||||
"""事件上下文, 保存此次事件运行的信息"""
|
|
||||||
|
|
||||||
eid = 0
|
|
||||||
"""事件编号"""
|
|
||||||
|
|
||||||
host: APIHost = None
|
|
||||||
"""API宿主"""
|
|
||||||
|
|
||||||
event: events.BaseEventModel = None
|
|
||||||
"""此次事件的对象,具体类型为handler注册时指定监听的类型,可查看events.py中的定义"""
|
|
||||||
|
|
||||||
__prevent_default__ = False
|
|
||||||
"""是否阻止默认行为"""
|
|
||||||
|
|
||||||
__prevent_postorder__ = False
|
|
||||||
"""是否阻止后续插件的执行"""
|
|
||||||
|
|
||||||
__return_value__ = {}
|
|
||||||
""" 返回值
|
|
||||||
示例:
|
|
||||||
{
|
|
||||||
"example": [
|
|
||||||
'value1',
|
|
||||||
'value2',
|
|
||||||
3,
|
|
||||||
4,
|
|
||||||
{
|
|
||||||
'key1': 'value1',
|
|
||||||
},
|
|
||||||
['value1', 'value2']
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ========== 插件可调用的 API ==========
|
|
||||||
|
|
||||||
def add_return(self, key: str, ret):
|
|
||||||
"""添加返回值"""
|
|
||||||
if key not in self.__return_value__:
|
|
||||||
self.__return_value__[key] = []
|
|
||||||
self.__return_value__[key].append(ret)
|
|
||||||
|
|
||||||
async def reply(self, message_chain: platform_message.MessageChain):
|
|
||||||
"""回复此次消息请求
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message_chain (platform.types.MessageChain): 源平台的消息链,若用户使用的不是源平台适配器,程序也能自动转换为目标平台消息链
|
|
||||||
"""
|
|
||||||
# TODO 添加 at_sender 和 quote_origin 参数
|
|
||||||
await self.event.query.adapter.reply_message(
|
|
||||||
message_source=self.event.query.message_event, message=message_chain
|
|
||||||
)
|
|
||||||
|
|
||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
|
||||||
"""主动发送消息
|
|
||||||
|
|
||||||
Args:
|
|
||||||
target_type (str): 目标类型,`person`或`group`
|
|
||||||
target_id (str): 目标ID
|
|
||||||
message (platform.types.MessageChain): 源平台的消息链,若用户使用的不是源平台适配器,程序也能自动转换为目标平台消息链
|
|
||||||
"""
|
|
||||||
await self.event.query.adapter.send_message(target_type=target_type, target_id=target_id, message=message)
|
|
||||||
|
|
||||||
def prevent_postorder(self):
|
|
||||||
"""阻止后续插件执行"""
|
|
||||||
self.__prevent_postorder__ = True
|
|
||||||
|
|
||||||
def prevent_default(self):
|
|
||||||
"""阻止默认行为"""
|
|
||||||
self.__prevent_default__ = True
|
|
||||||
|
|
||||||
# ========== 以下是内部保留方法,插件不应调用 ==========
|
|
||||||
|
|
||||||
def get_return(self, key: str) -> list:
|
|
||||||
"""获取key的所有返回值"""
|
|
||||||
if key in self.__return_value__:
|
|
||||||
return self.__return_value__[key]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_return_value(self, key: str):
|
|
||||||
"""获取key的首个返回值"""
|
|
||||||
if key in self.__return_value__:
|
|
||||||
return self.__return_value__[key][0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def is_prevented_default(self):
|
|
||||||
"""是否阻止默认行为"""
|
|
||||||
return self.__prevent_default__
|
|
||||||
|
|
||||||
def is_prevented_postorder(self):
|
|
||||||
"""是否阻止后序插件执行"""
|
|
||||||
return self.__prevent_postorder__
|
|
||||||
|
|
||||||
def __init__(self, host: APIHost, event: events.BaseEventModel):
|
|
||||||
self.eid = EventContext.eid
|
|
||||||
self.host = host
|
|
||||||
self.event = event
|
|
||||||
self.__prevent_default__ = False
|
|
||||||
self.__prevent_postorder__ = False
|
|
||||||
self.__return_value__ = {}
|
|
||||||
EventContext.eid += 1
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
class PluginSystemError(Exception):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
def __init__(self, message: str):
|
|
||||||
self.message = message
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.message
|
|
||||||
|
|
||||||
|
|
||||||
class PluginNotFoundError(PluginSystemError):
|
|
||||||
def __init__(self, message: str):
|
|
||||||
super().__init__(f'未找到插件: {message}')
|
|
||||||
|
|
||||||
|
|
||||||
class PluginInstallerError(PluginSystemError):
|
|
||||||
def __init__(self, message: str):
|
|
||||||
super().__init__(f'安装器操作错误: {message}')
|
|
||||||
@@ -437,6 +437,33 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
async for ret in gen:
|
async for ret in gen:
|
||||||
yield ret
|
yield ret
|
||||||
|
|
||||||
|
async def upgrade_plugin(self, plugin_author: str, plugin_name: str) -> typing.AsyncGenerator[dict[str, Any], None]:
|
||||||
|
"""Upgrade plugin"""
|
||||||
|
gen = self.call_action_generator(
|
||||||
|
LangBotToRuntimeAction.UPGRADE_PLUGIN,
|
||||||
|
{
|
||||||
|
'plugin_author': plugin_author,
|
||||||
|
'plugin_name': plugin_name,
|
||||||
|
},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
|
||||||
|
async for ret in gen:
|
||||||
|
yield ret
|
||||||
|
|
||||||
|
async def delete_plugin(self, plugin_author: str, plugin_name: str) -> typing.AsyncGenerator[dict[str, Any], None]:
|
||||||
|
"""Delete plugin"""
|
||||||
|
gen = self.call_action_generator(
|
||||||
|
LangBotToRuntimeAction.DELETE_PLUGIN,
|
||||||
|
{
|
||||||
|
'plugin_author': plugin_author,
|
||||||
|
'plugin_name': plugin_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async for ret in gen:
|
||||||
|
yield ret
|
||||||
|
|
||||||
async def list_plugins(self) -> list[dict[str, Any]]:
|
async def list_plugins(self) -> list[dict[str, Any]]:
|
||||||
"""List plugins"""
|
"""List plugins"""
|
||||||
result = await self.call_action(
|
result = await self.call_action(
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# 此模块已过时
|
|
||||||
# 请从 pkg.plugin.context 引入 BasePlugin, EventContext 和 APIHost
|
|
||||||
# 最早将于 v3.4 移除此模块
|
|
||||||
|
|
||||||
from .events import *
|
|
||||||
|
|
||||||
|
|
||||||
def emit(*args, **kwargs):
|
|
||||||
print('插件调用了已弃用的函数 pkg.plugin.host.emit()')
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import abc
|
|
||||||
|
|
||||||
from ..core import app, taskmgr
|
|
||||||
|
|
||||||
|
|
||||||
class PluginInstaller(metaclass=abc.ABCMeta):
|
|
||||||
"""插件安装器抽象类"""
|
|
||||||
|
|
||||||
ap: app.Application
|
|
||||||
|
|
||||||
def __init__(self, ap: app.Application):
|
|
||||||
self.ap = ap
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def install_plugin(
|
|
||||||
self,
|
|
||||||
plugin_source: str,
|
|
||||||
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
|
|
||||||
):
|
|
||||||
"""安装插件"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def uninstall_plugin(
|
|
||||||
self,
|
|
||||||
plugin_name: str,
|
|
||||||
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
|
|
||||||
):
|
|
||||||
"""卸载插件"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def update_plugin(
|
|
||||||
self,
|
|
||||||
plugin_name: str,
|
|
||||||
plugin_source: str = None,
|
|
||||||
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
|
|
||||||
):
|
|
||||||
"""更新插件"""
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import os
|
|
||||||
import zipfile
|
|
||||||
import ssl
|
|
||||||
import certifi
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
import aiofiles
|
|
||||||
import aiofiles.os as aiofiles_os
|
|
||||||
import aioshutil
|
|
||||||
|
|
||||||
from .. import installer, errors
|
|
||||||
from ...utils import pkgmgr
|
|
||||||
from ...core import taskmgr
|
|
||||||
|
|
||||||
|
|
||||||
class GitHubRepoInstaller(installer.PluginInstaller):
|
|
||||||
"""GitHub仓库插件安装器"""
|
|
||||||
|
|
||||||
def get_github_plugin_repo_label(self, repo_url: str) -> list[str]:
|
|
||||||
"""获取username, repo"""
|
|
||||||
repo = re.findall(
|
|
||||||
r'(?:https?://github\.com/|git@github\.com:)([^/]+/[^/]+?)(?:\.git|/|$)',
|
|
||||||
repo_url,
|
|
||||||
)
|
|
||||||
if len(repo) > 0:
|
|
||||||
return repo[0].split('/')
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def download_plugin_source_code(
|
|
||||||
self,
|
|
||||||
repo_url: str,
|
|
||||||
target_path: str,
|
|
||||||
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
|
|
||||||
) -> str:
|
|
||||||
"""下载插件源码(全异步)"""
|
|
||||||
repo = self.get_github_plugin_repo_label(repo_url)
|
|
||||||
if repo is None:
|
|
||||||
raise errors.PluginInstallerError('仅支持GitHub仓库地址')
|
|
||||||
|
|
||||||
target_path += repo[1]
|
|
||||||
self.ap.logger.debug('正在下载源码...')
|
|
||||||
task_context.trace('下载源码...', 'download-plugin-source-code')
|
|
||||||
|
|
||||||
zipball_url = f'https://api.github.com/repos/{"/".join(repo)}/zipball/HEAD'
|
|
||||||
zip_resp: bytes = None
|
|
||||||
|
|
||||||
# 创建自定义SSL上下文,使用certifi提供的根证书
|
|
||||||
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
|
||||||
async with session.get(
|
|
||||||
url=zipball_url,
|
|
||||||
timeout=aiohttp.ClientTimeout(total=300),
|
|
||||||
ssl=ssl_context, # 使用自定义SSL上下文来验证证书
|
|
||||||
) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
raise errors.PluginInstallerError(f'下载源码失败: {await resp.text()}')
|
|
||||||
zip_resp = await resp.read()
|
|
||||||
|
|
||||||
if await aiofiles_os.path.exists('temp/' + target_path):
|
|
||||||
await aioshutil.rmtree('temp/' + target_path)
|
|
||||||
|
|
||||||
if await aiofiles_os.path.exists(target_path):
|
|
||||||
await aioshutil.rmtree(target_path)
|
|
||||||
|
|
||||||
await aiofiles_os.makedirs('temp/' + target_path)
|
|
||||||
|
|
||||||
async with aiofiles.open('temp/' + target_path + '/source.zip', 'wb') as f:
|
|
||||||
await f.write(zip_resp)
|
|
||||||
|
|
||||||
self.ap.logger.debug('解压中...')
|
|
||||||
task_context.trace('解压中...', 'unzip-plugin-source-code')
|
|
||||||
|
|
||||||
with zipfile.ZipFile('temp/' + target_path + '/source.zip', 'r') as zip_ref:
|
|
||||||
zip_ref.extractall('temp/' + target_path)
|
|
||||||
await aiofiles_os.remove('temp/' + target_path + '/source.zip')
|
|
||||||
|
|
||||||
import glob
|
|
||||||
|
|
||||||
unzip_dir = glob.glob('temp/' + target_path + '/*')[0]
|
|
||||||
await aioshutil.copytree(unzip_dir, target_path + '/')
|
|
||||||
await aioshutil.rmtree(unzip_dir)
|
|
||||||
|
|
||||||
self.ap.logger.debug('源码下载完成。')
|
|
||||||
return repo[1]
|
|
||||||
|
|
||||||
async def install_requirements(self, path: str):
|
|
||||||
if os.path.exists(path + '/requirements.txt'):
|
|
||||||
pkgmgr.install_requirements(path + '/requirements.txt')
|
|
||||||
|
|
||||||
async def install_plugin(
|
|
||||||
self,
|
|
||||||
plugin_source: str,
|
|
||||||
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
|
|
||||||
):
|
|
||||||
"""安装插件"""
|
|
||||||
task_context.trace('下载插件源码...', 'install-plugin')
|
|
||||||
repo_label = await self.download_plugin_source_code(plugin_source, 'plugins/', task_context)
|
|
||||||
task_context.trace('安装插件依赖...', 'install-plugin')
|
|
||||||
await self.install_requirements('plugins/' + repo_label)
|
|
||||||
task_context.trace('完成.', 'install-plugin')
|
|
||||||
|
|
||||||
# Caution: in the v4.0, plugin without manifest will not be able to be updated
|
|
||||||
# await self.ap.plugin_mgr.setting.record_installed_plugin_source(
|
|
||||||
# "plugins/" + repo_label + '/', plugin_source
|
|
||||||
# )
|
|
||||||
|
|
||||||
async def uninstall_plugin(
|
|
||||||
self,
|
|
||||||
plugin_name: str,
|
|
||||||
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
|
|
||||||
):
|
|
||||||
"""卸载插件"""
|
|
||||||
plugin_container = self.ap.plugin_mgr.get_plugin_by_name(plugin_name)
|
|
||||||
if plugin_container is None:
|
|
||||||
raise errors.PluginInstallerError('插件不存在或未成功加载')
|
|
||||||
else:
|
|
||||||
task_context.trace('删除插件目录...', 'uninstall-plugin')
|
|
||||||
await aioshutil.rmtree(plugin_container.pkg_path)
|
|
||||||
task_context.trace('完成, 重新加载以生效.', 'uninstall-plugin')
|
|
||||||
|
|
||||||
async def update_plugin(
|
|
||||||
self,
|
|
||||||
plugin_name: str,
|
|
||||||
plugin_source: str = None,
|
|
||||||
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
|
|
||||||
):
|
|
||||||
"""更新插件"""
|
|
||||||
task_context.trace('更新插件...', 'update-plugin')
|
|
||||||
plugin_container = self.ap.plugin_mgr.get_plugin_by_name(plugin_name)
|
|
||||||
if plugin_container is None:
|
|
||||||
raise errors.PluginInstallerError('插件不存在或未成功加载')
|
|
||||||
else:
|
|
||||||
if plugin_container.plugin_repository:
|
|
||||||
plugin_source = plugin_container.plugin_repository
|
|
||||||
task_context.trace('转交安装任务.', 'update-plugin')
|
|
||||||
await self.install_plugin(plugin_source, task_context)
|
|
||||||
else:
|
|
||||||
raise errors.PluginInstallerError('插件无源码信息,无法更新')
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import abc
|
|
||||||
|
|
||||||
from ..core import app
|
|
||||||
from . import context
|
|
||||||
|
|
||||||
|
|
||||||
class PluginLoader(metaclass=abc.ABCMeta):
|
|
||||||
"""插件加载器抽象类"""
|
|
||||||
|
|
||||||
ap: app.Application
|
|
||||||
|
|
||||||
plugins: list[context.RuntimeContainer]
|
|
||||||
|
|
||||||
def __init__(self, ap: app.Application):
|
|
||||||
self.ap = ap
|
|
||||||
self.plugins = []
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
async def load_plugins(self):
|
|
||||||
pass
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import pkgutil
|
|
||||||
import importlib
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
from .. import loader, context, models
|
|
||||||
from langbot_plugin.api.entities.builtin.resource import tool as resource_tool
|
|
||||||
from ...utils import funcschema
|
|
||||||
from ...discover import engine as discover_engine
|
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
||||||
import langbot_plugin.api.entities.events as events
|
|
||||||
|
|
||||||
|
|
||||||
class PluginLoader(loader.PluginLoader):
|
|
||||||
"""加载 plugins/ 目录下的插件"""
|
|
||||||
|
|
||||||
_current_pkg_path = ''
|
|
||||||
|
|
||||||
_current_module_path = ''
|
|
||||||
|
|
||||||
_current_container: context.RuntimeContainer = None
|
|
||||||
|
|
||||||
plugins: list[context.RuntimeContainer] = []
|
|
||||||
|
|
||||||
def __init__(self, ap):
|
|
||||||
self.ap = ap
|
|
||||||
self.plugins = []
|
|
||||||
self._current_pkg_path = ''
|
|
||||||
self._current_module_path = ''
|
|
||||||
self._current_container = None
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
"""初始化"""
|
|
||||||
|
|
||||||
def register(
|
|
||||||
self, name: str, description: str, version: str, author: str
|
|
||||||
) -> typing.Callable[[typing.Type[context.BasePlugin]], typing.Type[context.BasePlugin]]:
|
|
||||||
self.ap.logger.debug(f'注册插件 {name} {version} by {author}')
|
|
||||||
container = context.RuntimeContainer(
|
|
||||||
plugin_name=name,
|
|
||||||
plugin_label=discover_engine.I18nString(en_US=name, zh_Hans=name),
|
|
||||||
plugin_description=discover_engine.I18nString(en_US=description, zh_Hans=description),
|
|
||||||
plugin_version=version,
|
|
||||||
plugin_author=author,
|
|
||||||
plugin_repository='',
|
|
||||||
pkg_path=self._current_pkg_path,
|
|
||||||
main_file=self._current_module_path,
|
|
||||||
event_handlers={},
|
|
||||||
tools=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
self._current_container = container
|
|
||||||
|
|
||||||
def wrapper(cls: context.BasePlugin) -> typing.Type[context.BasePlugin]:
|
|
||||||
container.plugin_class = cls
|
|
||||||
return cls
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
# 过时
|
|
||||||
# 最早将于 v3.4 版本移除
|
|
||||||
def on(self, event: typing.Type[events.BaseEventModel]) -> typing.Callable[[typing.Callable], typing.Callable]:
|
|
||||||
"""注册过时的事件处理器"""
|
|
||||||
self.ap.logger.debug(f'注册事件处理器 {event.__name__}')
|
|
||||||
|
|
||||||
def wrapper(func: typing.Callable) -> typing.Callable:
|
|
||||||
async def handler(plugin: context.BasePlugin, ctx: context.EventContext) -> None:
|
|
||||||
args = {
|
|
||||||
'host': ctx.host,
|
|
||||||
'event': ctx,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 把 ctx.event 所有的属性都放到 args 里
|
|
||||||
# for k, v in ctx.event.dict().items():
|
|
||||||
# args[k] = v
|
|
||||||
for attr_name in ctx.event.__dict__.keys():
|
|
||||||
args[attr_name] = getattr(ctx.event, attr_name)
|
|
||||||
|
|
||||||
func(plugin, **args)
|
|
||||||
|
|
||||||
self._current_container.event_handlers[event] = handler
|
|
||||||
|
|
||||||
return func
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
# 过时
|
|
||||||
# 最早将于 v3.4 版本移除
|
|
||||||
def func(
|
|
||||||
self,
|
|
||||||
name: str = None,
|
|
||||||
) -> typing.Callable:
|
|
||||||
"""注册过时的内容函数"""
|
|
||||||
self.ap.logger.debug(f'注册内容函数 {name}')
|
|
||||||
|
|
||||||
def wrapper(func: typing.Callable) -> typing.Callable:
|
|
||||||
function_schema = funcschema.get_func_schema(func)
|
|
||||||
function_name = self._current_container.plugin_name + '-' + (func.__name__ if name is None else name)
|
|
||||||
|
|
||||||
async def handler(plugin: context.BasePlugin, query: pipeline_query.Query, *args, **kwargs):
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
llm_function = resource_tool.LLMTool(
|
|
||||||
name=function_name,
|
|
||||||
human_desc='',
|
|
||||||
description=function_schema['description'],
|
|
||||||
parameters=function_schema['parameters'],
|
|
||||||
func=handler,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._current_container.tools.append(llm_function)
|
|
||||||
|
|
||||||
return func
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
def handler(self, event: typing.Type[events.BaseEventModel]) -> typing.Callable[[typing.Callable], typing.Callable]:
|
|
||||||
"""注册事件处理器"""
|
|
||||||
self.ap.logger.debug(f'注册事件处理器 {event.__name__}')
|
|
||||||
|
|
||||||
def wrapper(func: typing.Callable) -> typing.Callable:
|
|
||||||
if (
|
|
||||||
self._current_container is None
|
|
||||||
): # None indicates this plugin is registered through manifest, so ignore it here
|
|
||||||
return func
|
|
||||||
|
|
||||||
self._current_container.event_handlers[event] = func
|
|
||||||
|
|
||||||
return func
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
def llm_func(
|
|
||||||
self,
|
|
||||||
name: str = None,
|
|
||||||
) -> typing.Callable:
|
|
||||||
"""注册内容函数"""
|
|
||||||
self.ap.logger.debug(f'注册内容函数 {name}')
|
|
||||||
|
|
||||||
def wrapper(func: typing.Callable) -> typing.Callable:
|
|
||||||
if (
|
|
||||||
self._current_container is None
|
|
||||||
): # None indicates this plugin is registered through manifest, so ignore it here
|
|
||||||
return func
|
|
||||||
|
|
||||||
function_schema = funcschema.get_func_schema(func)
|
|
||||||
function_name = self._current_container.plugin_name + '-' + (func.__name__ if name is None else name)
|
|
||||||
|
|
||||||
llm_function = resource_tool.LLMTool(
|
|
||||||
name=function_name,
|
|
||||||
human_desc='',
|
|
||||||
description=function_schema['description'],
|
|
||||||
parameters=function_schema['parameters'],
|
|
||||||
func=func,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._current_container.tools.append(llm_function)
|
|
||||||
|
|
||||||
return func
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
async def _walk_plugin_path(self, module, prefix='', path_prefix=''):
|
|
||||||
"""遍历插件路径"""
|
|
||||||
for item in pkgutil.iter_modules(module.__path__):
|
|
||||||
if item.ispkg:
|
|
||||||
await self._walk_plugin_path(
|
|
||||||
__import__(module.__name__ + '.' + item.name, fromlist=['']),
|
|
||||||
prefix + item.name + '.',
|
|
||||||
path_prefix + item.name + '/',
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
self._current_pkg_path = 'plugins/' + path_prefix
|
|
||||||
self._current_module_path = 'plugins/' + path_prefix + item.name + '.py'
|
|
||||||
|
|
||||||
self._current_container = None
|
|
||||||
|
|
||||||
importlib.import_module(module.__name__ + '.' + item.name)
|
|
||||||
|
|
||||||
if self._current_container is not None:
|
|
||||||
self.plugins.append(self._current_container)
|
|
||||||
self.ap.logger.debug(f'插件 {self._current_container} 已加载')
|
|
||||||
except Exception:
|
|
||||||
self.ap.logger.error(f'加载插件模块 {prefix + item.name} 时发生错误')
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
async def load_plugins(self):
|
|
||||||
"""加载插件"""
|
|
||||||
setattr(models, 'register', self.register)
|
|
||||||
setattr(models, 'on', self.on)
|
|
||||||
setattr(models, 'func', self.func)
|
|
||||||
|
|
||||||
setattr(context, 'register', self.register)
|
|
||||||
setattr(context, 'handler', self.handler)
|
|
||||||
setattr(context, 'llm_func', self.llm_func)
|
|
||||||
await self._walk_plugin_path(__import__('plugins', fromlist=['']))
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import os
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
from ...core import app
|
|
||||||
from .. import context
|
|
||||||
from .. import loader
|
|
||||||
from ...utils import funcschema
|
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
|
||||||
import langbot_plugin.api.entities.events as events
|
|
||||||
|
|
||||||
|
|
||||||
class PluginManifestLoader(loader.PluginLoader):
|
|
||||||
"""通过插件清单发现插件"""
|
|
||||||
|
|
||||||
_current_container: context.RuntimeContainer = None
|
|
||||||
|
|
||||||
def __init__(self, ap: app.Application):
|
|
||||||
super().__init__(ap)
|
|
||||||
|
|
||||||
def handler(self, event: typing.Type[events.BaseEventModel]) -> typing.Callable[[typing.Callable], typing.Callable]:
|
|
||||||
"""注册事件处理器"""
|
|
||||||
self.ap.logger.debug(f'注册事件处理器 {event.__name__}')
|
|
||||||
|
|
||||||
def wrapper(func: typing.Callable) -> typing.Callable:
|
|
||||||
self._current_container.event_handlers[event] = func
|
|
||||||
|
|
||||||
return func
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
def llm_func(
|
|
||||||
self,
|
|
||||||
name: str = None,
|
|
||||||
) -> typing.Callable:
|
|
||||||
"""注册内容函数"""
|
|
||||||
self.ap.logger.debug(f'注册内容函数 {name}')
|
|
||||||
|
|
||||||
def wrapper(func: typing.Callable) -> typing.Callable:
|
|
||||||
function_schema = funcschema.get_func_schema(func)
|
|
||||||
function_name = self._current_container.plugin_name + '-' + (func.__name__ if name is None else name)
|
|
||||||
|
|
||||||
llm_function = resource_tool.LLMTool(
|
|
||||||
name=function_name,
|
|
||||||
human_desc='',
|
|
||||||
description=function_schema['description'],
|
|
||||||
parameters=function_schema['parameters'],
|
|
||||||
func=func,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._current_container.tools.append(llm_function)
|
|
||||||
|
|
||||||
return func
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
async def load_plugins(self):
|
|
||||||
"""加载插件"""
|
|
||||||
setattr(context, 'handler', self.handler)
|
|
||||||
setattr(context, 'llm_func', self.llm_func)
|
|
||||||
|
|
||||||
plugin_manifests = self.ap.discover.get_components_by_kind('Plugin')
|
|
||||||
|
|
||||||
for plugin_manifest in plugin_manifests:
|
|
||||||
try:
|
|
||||||
config_schema = plugin_manifest.spec['config'] if 'config' in plugin_manifest.spec else []
|
|
||||||
|
|
||||||
current_plugin_container = context.RuntimeContainer(
|
|
||||||
plugin_name=plugin_manifest.metadata.name,
|
|
||||||
plugin_label=plugin_manifest.metadata.label,
|
|
||||||
plugin_description=plugin_manifest.metadata.description,
|
|
||||||
plugin_version=plugin_manifest.metadata.version,
|
|
||||||
plugin_author=plugin_manifest.metadata.author,
|
|
||||||
plugin_repository=plugin_manifest.metadata.repository,
|
|
||||||
main_file=os.path.join(plugin_manifest.rel_dir, plugin_manifest.execution.python.path),
|
|
||||||
pkg_path=plugin_manifest.rel_dir,
|
|
||||||
config_schema=config_schema,
|
|
||||||
event_handlers={},
|
|
||||||
tools=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
self._current_container = current_plugin_container
|
|
||||||
|
|
||||||
# extract the plugin class
|
|
||||||
# this step will load the plugin module,
|
|
||||||
# so the event handlers and tools will be registered
|
|
||||||
plugin_class = plugin_manifest.get_python_component_class()
|
|
||||||
current_plugin_container.plugin_class = plugin_class
|
|
||||||
|
|
||||||
# TODO load component extensions
|
|
||||||
|
|
||||||
self.plugins.append(current_plugin_container)
|
|
||||||
except Exception:
|
|
||||||
self.ap.logger.error(f'加载插件 {plugin_manifest.metadata.name} 时发生错误')
|
|
||||||
traceback.print_exc()
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# 此模块已过时,请引入 pkg.plugin.context 中的 register, handler 和 llm_func 来注册插件、事件处理函数和内容函数
|
|
||||||
# 各个事件模型请从 pkg.plugin.events 引入
|
|
||||||
# 最早将于 v3.4 移除此模块
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from .context import BasePlugin as Plugin
|
|
||||||
from .events import *
|
|
||||||
import langbot_plugin.api.entities.events as events
|
|
||||||
|
|
||||||
|
|
||||||
def register(
|
|
||||||
name: str, description: str, version: str, author
|
|
||||||
) -> typing.Callable[[typing.Type[Plugin]], typing.Type[Plugin]]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def on(
|
|
||||||
event: typing.Type[events.BaseEventModel],
|
|
||||||
) -> typing.Callable[[typing.Callable], typing.Callable]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def func(
|
|
||||||
name: str = None,
|
|
||||||
) -> typing.Callable:
|
|
||||||
pass
|
|
||||||
@@ -18,15 +18,15 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||||
|
|
||||||
export interface PluginInstalledComponentRef {
|
export interface PluginInstalledComponentRef {
|
||||||
refreshPluginList: () => void;
|
refreshPluginList: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PluginRemoveStatus {
|
enum PluginOperationType {
|
||||||
WAIT_INPUT = 'WAIT_INPUT',
|
DELETE = 'DELETE',
|
||||||
REMOVING = 'REMOVING',
|
UPDATE = 'UPDATE',
|
||||||
ERROR = 'ERROR',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react/display-name
|
// eslint-disable-next-line react/display-name
|
||||||
@@ -38,15 +38,26 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
|||||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
|
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
|
const [showOperationModal, setShowOperationModal] = useState(false);
|
||||||
const [pluginRemoveStatus, setPluginRemoveStatus] =
|
const [operationType, setOperationType] = useState<PluginOperationType>(
|
||||||
useState<PluginRemoveStatus>(PluginRemoveStatus.WAIT_INPUT);
|
PluginOperationType.DELETE,
|
||||||
const [pluginRemoveError, setPluginRemoveError] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [pluginToDelete, setPluginToDelete] = useState<PluginCardVO | null>(
|
|
||||||
null,
|
|
||||||
);
|
);
|
||||||
|
const [targetPlugin, setTargetPlugin] = useState<PluginCardVO | null>(null);
|
||||||
|
|
||||||
|
const asyncTask = useAsyncTask({
|
||||||
|
onSuccess: () => {
|
||||||
|
const successMessage =
|
||||||
|
operationType === PluginOperationType.DELETE
|
||||||
|
? t('plugins.deleteSuccess')
|
||||||
|
: t('plugins.updateSuccess');
|
||||||
|
toast.success(successMessage);
|
||||||
|
setShowOperationModal(false);
|
||||||
|
getPluginList();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
// Error is already handled in the hook state
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initData();
|
initData();
|
||||||
@@ -94,115 +105,139 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePluginDelete(plugin: PluginCardVO) {
|
function handlePluginDelete(plugin: PluginCardVO) {
|
||||||
setPluginToDelete(plugin);
|
setTargetPlugin(plugin);
|
||||||
setShowDeleteConfirmModal(true);
|
setOperationType(PluginOperationType.DELETE);
|
||||||
setPluginRemoveStatus(PluginRemoveStatus.WAIT_INPUT);
|
setShowOperationModal(true);
|
||||||
|
asyncTask.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
function deletePlugin() {
|
function handlePluginUpdate(plugin: PluginCardVO) {
|
||||||
setPluginRemoveStatus(PluginRemoveStatus.REMOVING);
|
setTargetPlugin(plugin);
|
||||||
httpClient
|
setOperationType(PluginOperationType.UPDATE);
|
||||||
.removePlugin(pluginToDelete!.author, pluginToDelete!.name)
|
setShowOperationModal(true);
|
||||||
|
asyncTask.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeOperation() {
|
||||||
|
if (!targetPlugin) return;
|
||||||
|
|
||||||
|
const apiCall =
|
||||||
|
operationType === PluginOperationType.DELETE
|
||||||
|
? httpClient.removePlugin(targetPlugin.author, targetPlugin.name)
|
||||||
|
: httpClient.upgradePlugin(targetPlugin.author, targetPlugin.name);
|
||||||
|
|
||||||
|
apiCall
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const taskId = res.task_id;
|
asyncTask.startTask(res.task_id);
|
||||||
|
|
||||||
let alreadySuccess = false;
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
httpClient.getAsyncTask(taskId).then((res) => {
|
|
||||||
if (res.runtime.done) {
|
|
||||||
clearInterval(interval);
|
|
||||||
if (res.runtime.exception) {
|
|
||||||
setPluginRemoveError(res.runtime.exception);
|
|
||||||
setPluginRemoveStatus(PluginRemoveStatus.ERROR);
|
|
||||||
} else {
|
|
||||||
// success
|
|
||||||
if (!alreadySuccess) {
|
|
||||||
toast.success('插件删除成功');
|
|
||||||
alreadySuccess = true;
|
|
||||||
}
|
|
||||||
setPluginRemoveStatus(PluginRemoveStatus.WAIT_INPUT);
|
|
||||||
setShowDeleteConfirmModal(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setPluginRemoveError(error.message);
|
const errorMessage =
|
||||||
setPluginRemoveStatus(PluginRemoveStatus.ERROR);
|
operationType === PluginOperationType.DELETE
|
||||||
|
? t('plugins.deleteError') + error.message
|
||||||
|
: t('plugins.updateError') + error.message;
|
||||||
|
toast.error(errorMessage);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={showDeleteConfirmModal}
|
open={showOperationModal}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setShowDeleteConfirmModal(false);
|
setShowOperationModal(false);
|
||||||
setPluginRemoveStatus(PluginRemoveStatus.WAIT_INPUT);
|
setTargetPlugin(null);
|
||||||
setPluginToDelete(null);
|
asyncTask.reset();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('plugins.deleteConfirm')}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{operationType === PluginOperationType.DELETE
|
||||||
|
? t('plugins.deleteConfirm')
|
||||||
|
: t('plugins.updateConfirm')}
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{pluginRemoveStatus === PluginRemoveStatus.WAIT_INPUT && (
|
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||||
<div>
|
<div>
|
||||||
{t('plugins.confirmDeletePlugin', {
|
{operationType === PluginOperationType.DELETE
|
||||||
author: pluginToDelete?.author ?? '',
|
? t('plugins.confirmDeletePlugin', {
|
||||||
name: pluginToDelete?.name ?? '',
|
author: targetPlugin?.author ?? '',
|
||||||
})}
|
name: targetPlugin?.name ?? '',
|
||||||
|
})
|
||||||
|
: t('plugins.confirmUpdatePlugin', {
|
||||||
|
author: targetPlugin?.author ?? '',
|
||||||
|
name: targetPlugin?.name ?? '',
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{pluginRemoveStatus === PluginRemoveStatus.REMOVING && (
|
{asyncTask.status === AsyncTaskStatus.RUNNING && (
|
||||||
<div>{t('plugins.deleting')}</div>
|
|
||||||
)}
|
|
||||||
{pluginRemoveStatus === PluginRemoveStatus.ERROR && (
|
|
||||||
<div>
|
<div>
|
||||||
{t('plugins.deleteError')}
|
{operationType === PluginOperationType.DELETE
|
||||||
<div className="text-red-500">{pluginRemoveError}</div>
|
? t('plugins.deleting')
|
||||||
|
: t('plugins.updating')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{asyncTask.status === AsyncTaskStatus.ERROR && (
|
||||||
|
<div>
|
||||||
|
{operationType === PluginOperationType.DELETE
|
||||||
|
? t('plugins.deleteError')
|
||||||
|
: t('plugins.updateError')}
|
||||||
|
<div className="text-red-500">{asyncTask.error}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{pluginRemoveStatus === PluginRemoveStatus.WAIT_INPUT && (
|
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowDeleteConfirmModal(false);
|
setShowOperationModal(false);
|
||||||
setPluginRemoveStatus(PluginRemoveStatus.WAIT_INPUT);
|
setTargetPlugin(null);
|
||||||
setPluginToDelete(null);
|
asyncTask.reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('plugins.cancel')}
|
{t('plugins.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{pluginRemoveStatus === PluginRemoveStatus.WAIT_INPUT && (
|
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant={
|
||||||
|
operationType === PluginOperationType.DELETE
|
||||||
|
? 'destructive'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
deletePlugin();
|
executeOperation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('plugins.confirmDelete')}
|
{operationType === PluginOperationType.DELETE
|
||||||
|
? t('plugins.confirmDelete')
|
||||||
|
: t('plugins.confirmUpdate')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{pluginRemoveStatus === PluginRemoveStatus.REMOVING && (
|
{asyncTask.status === AsyncTaskStatus.RUNNING && (
|
||||||
<Button variant="destructive" disabled>
|
<Button
|
||||||
{t('plugins.deleting')}
|
variant={
|
||||||
|
operationType === PluginOperationType.DELETE
|
||||||
|
? 'destructive'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
{operationType === PluginOperationType.DELETE
|
||||||
|
? t('plugins.deleting')
|
||||||
|
: t('plugins.updating')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{pluginRemoveStatus === PluginRemoveStatus.ERROR && (
|
{asyncTask.status === AsyncTaskStatus.ERROR && (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowDeleteConfirmModal(false);
|
setShowOperationModal(false);
|
||||||
setPluginRemoveStatus(PluginRemoveStatus.WAIT_INPUT);
|
asyncTask.reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('plugins.close')}
|
{t('plugins.close')}
|
||||||
@@ -256,6 +291,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
|||||||
cardVO={vo}
|
cardVO={vo}
|
||||||
onCardClick={() => handlePluginClick(vo)}
|
onCardClick={() => handlePluginClick(vo)}
|
||||||
onDeleteClick={() => handlePluginDelete(vo)}
|
onDeleteClick={() => handlePluginDelete(vo)}
|
||||||
|
onUpgradeClick={() => handlePluginUpdate(vo)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
Trash,
|
Trash,
|
||||||
|
ArrowUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||||
import { PluginComponent } from '@/app/infra/entities/plugin';
|
import { PluginComponent } from '@/app/infra/entities/plugin';
|
||||||
@@ -22,17 +23,9 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
enum PluginRemoveStatus {
|
|
||||||
WAIT_INPUT = 'WAIT_INPUT',
|
|
||||||
REMOVING = 'REMOVING',
|
|
||||||
ERROR = 'ERROR',
|
|
||||||
}
|
|
||||||
|
|
||||||
function getComponentList(components: PluginComponent[], t: TFunction) {
|
function getComponentList(components: PluginComponent[], t: TFunction) {
|
||||||
const componentKindCount: Record<string, number> = {};
|
const componentKindCount: Record<string, number> = {};
|
||||||
|
|
||||||
@@ -80,14 +73,17 @@ export default function PluginCardComponent({
|
|||||||
cardVO,
|
cardVO,
|
||||||
onCardClick,
|
onCardClick,
|
||||||
onDeleteClick,
|
onDeleteClick,
|
||||||
|
onUpgradeClick,
|
||||||
}: {
|
}: {
|
||||||
cardVO: PluginCardVO;
|
cardVO: PluginCardVO;
|
||||||
onCardClick: () => void;
|
onCardClick: () => void;
|
||||||
onDeleteClick: (cardVO: PluginCardVO) => void;
|
onDeleteClick: (cardVO: PluginCardVO) => void;
|
||||||
|
onUpgradeClick: (cardVO: PluginCardVO) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [enabled, setEnabled] = useState(cardVO.enabled);
|
const [enabled, setEnabled] = useState(cardVO.enabled);
|
||||||
const [switchEnable, setSwitchEnable] = useState(true);
|
const [switchEnable, setSwitchEnable] = useState(true);
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
|
||||||
function handleEnable(e: React.MouseEvent) {
|
function handleEnable(e: React.MouseEvent) {
|
||||||
e.stopPropagation(); // 阻止事件冒泡
|
e.stopPropagation(); // 阻止事件冒泡
|
||||||
@@ -212,18 +208,33 @@ export default function PluginCardComponent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<DropdownMenu>
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
<Ellipsis className="w-4 h-4" />
|
<Ellipsis className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
|
{/**upgrade */}
|
||||||
|
{cardVO.install_source === 'marketplace' && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUpgradeClick(cardVO);
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowUp className="w-4 h-4" />
|
||||||
|
<span>{t('plugins.update')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
|
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
onDeleteClick(cardVO);
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
onDeleteClick(cardVO);
|
||||||
|
setDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash className="w-4 h-4" />
|
<Trash className="w-4 h-4" />
|
||||||
|
|||||||
@@ -4,14 +4,6 @@ import { Plugin } from '@/app/infra/entities/plugin';
|
|||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -60,7 +52,11 @@ export default function PluginForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!pluginInfo || !pluginConfig) {
|
if (!pluginInfo || !pluginConfig) {
|
||||||
return <div>{t('plugins.loading')}</div>;
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full mb-[2rem]">
|
||||||
|
{t('plugins.loading')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -240,13 +240,6 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.put('/api/v1/plugins/reorder', { plugins });
|
return this.put('/api/v1/plugins/reorder', { plugins });
|
||||||
}
|
}
|
||||||
|
|
||||||
public updatePlugin(
|
|
||||||
author: string,
|
|
||||||
name: string,
|
|
||||||
): Promise<AsyncTaskCreatedResp> {
|
|
||||||
return this.post(`/api/v1/plugins/${author}/${name}/update`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public installPluginFromGithub(
|
public installPluginFromGithub(
|
||||||
source: string,
|
source: string,
|
||||||
): Promise<AsyncTaskCreatedResp> {
|
): Promise<AsyncTaskCreatedResp> {
|
||||||
@@ -278,6 +271,13 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.delete(`/api/v1/plugins/${author}/${name}`);
|
return this.delete(`/api/v1/plugins/${author}/${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public upgradePlugin(
|
||||||
|
author: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<AsyncTaskCreatedResp> {
|
||||||
|
return this.post(`/api/v1/plugins/${author}/${name}/upgrade`);
|
||||||
|
}
|
||||||
|
|
||||||
// ============ System API ============
|
// ============ System API ============
|
||||||
public getSystemInfo(): Promise<ApiRespSystemInfo> {
|
public getSystemInfo(): Promise<ApiRespSystemInfo> {
|
||||||
return this.get('/api/v1/system/info');
|
return this.get('/api/v1/system/info');
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import { AsyncTask } from '@/app/infra/entities/api';
|
||||||
|
|
||||||
|
export enum AsyncTaskStatus {
|
||||||
|
WAIT_INPUT = 'WAIT_INPUT',
|
||||||
|
RUNNING = 'RUNNING',
|
||||||
|
SUCCESS = 'SUCCESS',
|
||||||
|
ERROR = 'ERROR',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAsyncTaskOptions {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
pollInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAsyncTaskResult {
|
||||||
|
status: AsyncTaskStatus;
|
||||||
|
error: string | null;
|
||||||
|
startTask: (taskId: number) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAsyncTask(
|
||||||
|
options: UseAsyncTaskOptions = {},
|
||||||
|
): UseAsyncTaskResult {
|
||||||
|
const { onSuccess, onError, pollInterval = 1000 } = options;
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<AsyncTaskStatus>(
|
||||||
|
AsyncTaskStatus.WAIT_INPUT,
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const alreadySuccessRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
const clearPollingInterval = () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
clearPollingInterval();
|
||||||
|
setStatus(AsyncTaskStatus.WAIT_INPUT);
|
||||||
|
setError(null);
|
||||||
|
alreadySuccessRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTask = (taskId: number) => {
|
||||||
|
setStatus(AsyncTaskStatus.RUNNING);
|
||||||
|
setError(null);
|
||||||
|
alreadySuccessRef.current = false;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
httpClient
|
||||||
|
.getAsyncTask(taskId)
|
||||||
|
.then((res: AsyncTask) => {
|
||||||
|
if (res.runtime.done) {
|
||||||
|
clearPollingInterval();
|
||||||
|
if (res.runtime.exception) {
|
||||||
|
setError(res.runtime.exception);
|
||||||
|
setStatus(AsyncTaskStatus.ERROR);
|
||||||
|
onError?.(res.runtime.exception);
|
||||||
|
} else {
|
||||||
|
if (!alreadySuccessRef.current) {
|
||||||
|
alreadySuccessRef.current = true;
|
||||||
|
setStatus(AsyncTaskStatus.SUCCESS);
|
||||||
|
onSuccess?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
clearPollingInterval();
|
||||||
|
const errorMessage = error.message || 'Unknown error';
|
||||||
|
setError(errorMessage);
|
||||||
|
setStatus(AsyncTaskStatus.ERROR);
|
||||||
|
onError?.(errorMessage);
|
||||||
|
});
|
||||||
|
}, pollInterval);
|
||||||
|
|
||||||
|
intervalRef.current = interval;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearPollingInterval();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
startTask,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -175,6 +175,7 @@ const enUS = {
|
|||||||
deleteError: 'Delete failed: ',
|
deleteError: 'Delete failed: ',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
deleteConfirm: 'Delete Confirmation',
|
deleteConfirm: 'Delete Confirmation',
|
||||||
|
deleteSuccess: 'Delete successful',
|
||||||
modifyFailed: 'Modify failed: ',
|
modifyFailed: 'Modify failed: ',
|
||||||
eventCount: 'Events: {{count}}',
|
eventCount: 'Events: {{count}}',
|
||||||
toolCount: 'Tools: {{count}}',
|
toolCount: 'Tools: {{count}}',
|
||||||
@@ -193,6 +194,17 @@ const enUS = {
|
|||||||
fromGithub: 'From GitHub',
|
fromGithub: 'From GitHub',
|
||||||
fromLocal: 'From Local',
|
fromLocal: 'From Local',
|
||||||
fromMarketplace: 'From Marketplace',
|
fromMarketplace: 'From Marketplace',
|
||||||
|
componentsList: 'Components: ',
|
||||||
|
noComponents: 'No components',
|
||||||
|
delete: 'Delete Plugin',
|
||||||
|
update: 'Update Plugin',
|
||||||
|
updateConfirm: 'Update Confirmation',
|
||||||
|
confirmUpdatePlugin:
|
||||||
|
'Are you sure you want to update the plugin ({{author}}/{{name}})?',
|
||||||
|
confirmUpdate: 'Confirm Update',
|
||||||
|
updating: 'Updating...',
|
||||||
|
updateSuccess: 'Plugin updated successfully',
|
||||||
|
updateError: 'Update failed: ',
|
||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: 'Search plugins...',
|
searchPlaceholder: 'Search plugins...',
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ const jaJP = {
|
|||||||
deleteError: '削除に失敗しました:',
|
deleteError: '削除に失敗しました:',
|
||||||
close: '閉じる',
|
close: '閉じる',
|
||||||
deleteConfirm: '削除の確認',
|
deleteConfirm: '削除の確認',
|
||||||
|
deleteSuccess: '削除に成功しました',
|
||||||
modifyFailed: '変更に失敗しました:',
|
modifyFailed: '変更に失敗しました:',
|
||||||
eventCount: 'イベント:{{count}}',
|
eventCount: 'イベント:{{count}}',
|
||||||
toolCount: 'ツール:{{count}}',
|
toolCount: 'ツール:{{count}}',
|
||||||
@@ -193,6 +194,17 @@ const jaJP = {
|
|||||||
fromGithub: 'GitHubから',
|
fromGithub: 'GitHubから',
|
||||||
fromLocal: 'ローカルから',
|
fromLocal: 'ローカルから',
|
||||||
fromMarketplace: 'プラグインマーケットから',
|
fromMarketplace: 'プラグインマーケットから',
|
||||||
|
componentsList: '部品:',
|
||||||
|
noComponents: '部品がありません',
|
||||||
|
delete: 'プラグインを削除',
|
||||||
|
update: 'プラグインを更新',
|
||||||
|
updateConfirm: '更新の確認',
|
||||||
|
confirmUpdatePlugin:
|
||||||
|
'プラグイン「{{author}}/{{name}}」を更新してもよろしいですか?',
|
||||||
|
confirmUpdate: '更新を確認',
|
||||||
|
updating: '更新中...',
|
||||||
|
updateSuccess: 'プラグインの更新に成功しました',
|
||||||
|
updateError: '更新に失敗しました:',
|
||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: 'プラグインを検索...',
|
searchPlaceholder: 'プラグインを検索...',
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ const zhHans = {
|
|||||||
deleteError: '删除失败:',
|
deleteError: '删除失败:',
|
||||||
close: '关闭',
|
close: '关闭',
|
||||||
deleteConfirm: '删除确认',
|
deleteConfirm: '删除确认',
|
||||||
|
deleteSuccess: '删除成功',
|
||||||
modifyFailed: '修改失败:',
|
modifyFailed: '修改失败:',
|
||||||
eventCount: '事件:{{count}}',
|
eventCount: '事件:{{count}}',
|
||||||
toolCount: '工具:{{count}}',
|
toolCount: '工具:{{count}}',
|
||||||
@@ -190,7 +191,14 @@ const zhHans = {
|
|||||||
fromMarketplace: '来自市场',
|
fromMarketplace: '来自市场',
|
||||||
componentsList: '组件: ',
|
componentsList: '组件: ',
|
||||||
noComponents: '无组件',
|
noComponents: '无组件',
|
||||||
delete: '删除',
|
delete: '删除插件',
|
||||||
|
update: '更新插件',
|
||||||
|
updateConfirm: '更新确认',
|
||||||
|
confirmUpdatePlugin: '你确定要更新插件({{author}}/{{name}})吗?',
|
||||||
|
confirmUpdate: '确认更新',
|
||||||
|
updating: '更新中...',
|
||||||
|
updateSuccess: '插件更新成功',
|
||||||
|
updateError: '更新失败:',
|
||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: '搜索插件...',
|
searchPlaceholder: '搜索插件...',
|
||||||
|
|||||||
Reference in New Issue
Block a user