mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat: plugin deletion and upgrade
This commit is contained in:
@@ -29,17 +29,17 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
return self.success()
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/update',
|
||||
'/<author>/<plugin_name>/upgrade',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> str:
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
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',
|
||||
name=f'plugin-update-{plugin_name}',
|
||||
label=f'更新插件 {plugin_name}',
|
||||
name=f'plugin-upgrade-{plugin_name}',
|
||||
label=f'Upgrading plugin {plugin_name}',
|
||||
context=ctx,
|
||||
)
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
@@ -58,10 +58,10 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
elif quart.request.method == 'DELETE':
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
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',
|
||||
name=f'plugin-remove-{plugin_name}',
|
||||
label=f'删除插件 {plugin_name}',
|
||||
label=f'Removing plugin {plugin_name}',
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
|
||||
@@ -116,6 +116,34 @@ class PluginRuntimeConnector:
|
||||
if task_context is not None:
|
||||
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]]:
|
||||
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:
|
||||
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]]:
|
||||
"""List plugins"""
|
||||
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 { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { toast } from 'sonner';
|
||||
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||
|
||||
export interface PluginInstalledComponentRef {
|
||||
refreshPluginList: () => void;
|
||||
}
|
||||
|
||||
enum PluginRemoveStatus {
|
||||
WAIT_INPUT = 'WAIT_INPUT',
|
||||
REMOVING = 'REMOVING',
|
||||
ERROR = 'ERROR',
|
||||
enum PluginOperationType {
|
||||
DELETE = 'DELETE',
|
||||
UPDATE = 'UPDATE',
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
@@ -38,15 +38,26 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
|
||||
null,
|
||||
);
|
||||
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
|
||||
const [pluginRemoveStatus, setPluginRemoveStatus] =
|
||||
useState<PluginRemoveStatus>(PluginRemoveStatus.WAIT_INPUT);
|
||||
const [pluginRemoveError, setPluginRemoveError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [pluginToDelete, setPluginToDelete] = useState<PluginCardVO | null>(
|
||||
null,
|
||||
const [showOperationModal, setShowOperationModal] = useState(false);
|
||||
const [operationType, setOperationType] = useState<PluginOperationType>(
|
||||
PluginOperationType.DELETE,
|
||||
);
|
||||
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(() => {
|
||||
initData();
|
||||
@@ -94,115 +105,139 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
}
|
||||
|
||||
function handlePluginDelete(plugin: PluginCardVO) {
|
||||
setPluginToDelete(plugin);
|
||||
setShowDeleteConfirmModal(true);
|
||||
setPluginRemoveStatus(PluginRemoveStatus.WAIT_INPUT);
|
||||
setTargetPlugin(plugin);
|
||||
setOperationType(PluginOperationType.DELETE);
|
||||
setShowOperationModal(true);
|
||||
asyncTask.reset();
|
||||
}
|
||||
|
||||
function deletePlugin() {
|
||||
setPluginRemoveStatus(PluginRemoveStatus.REMOVING);
|
||||
httpClient
|
||||
.removePlugin(pluginToDelete!.author, pluginToDelete!.name)
|
||||
function handlePluginUpdate(plugin: PluginCardVO) {
|
||||
setTargetPlugin(plugin);
|
||||
setOperationType(PluginOperationType.UPDATE);
|
||||
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) => {
|
||||
const taskId = 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);
|
||||
asyncTask.startTask(res.task_id);
|
||||
})
|
||||
.catch((error) => {
|
||||
setPluginRemoveError(error.message);
|
||||
setPluginRemoveStatus(PluginRemoveStatus.ERROR);
|
||||
const errorMessage =
|
||||
operationType === PluginOperationType.DELETE
|
||||
? t('plugins.deleteError') + error.message
|
||||
: t('plugins.updateError') + error.message;
|
||||
toast.error(errorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={showDeleteConfirmModal}
|
||||
open={showOperationModal}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setShowDeleteConfirmModal(false);
|
||||
setPluginRemoveStatus(PluginRemoveStatus.WAIT_INPUT);
|
||||
setPluginToDelete(null);
|
||||
setShowOperationModal(false);
|
||||
setTargetPlugin(null);
|
||||
asyncTask.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('plugins.deleteConfirm')}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{operationType === PluginOperationType.DELETE
|
||||
? t('plugins.deleteConfirm')
|
||||
: t('plugins.updateConfirm')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
{pluginRemoveStatus === PluginRemoveStatus.WAIT_INPUT && (
|
||||
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||
<div>
|
||||
{t('plugins.confirmDeletePlugin', {
|
||||
author: pluginToDelete?.author ?? '',
|
||||
name: pluginToDelete?.name ?? '',
|
||||
})}
|
||||
{operationType === PluginOperationType.DELETE
|
||||
? t('plugins.confirmDeletePlugin', {
|
||||
author: targetPlugin?.author ?? '',
|
||||
name: targetPlugin?.name ?? '',
|
||||
})
|
||||
: t('plugins.confirmUpdatePlugin', {
|
||||
author: targetPlugin?.author ?? '',
|
||||
name: targetPlugin?.name ?? '',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{pluginRemoveStatus === PluginRemoveStatus.REMOVING && (
|
||||
<div>{t('plugins.deleting')}</div>
|
||||
)}
|
||||
{pluginRemoveStatus === PluginRemoveStatus.ERROR && (
|
||||
{asyncTask.status === AsyncTaskStatus.RUNNING && (
|
||||
<div>
|
||||
{t('plugins.deleteError')}
|
||||
<div className="text-red-500">{pluginRemoveError}</div>
|
||||
{operationType === PluginOperationType.DELETE
|
||||
? 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>
|
||||
)}
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
{pluginRemoveStatus === PluginRemoveStatus.WAIT_INPUT && (
|
||||
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirmModal(false);
|
||||
setPluginRemoveStatus(PluginRemoveStatus.WAIT_INPUT);
|
||||
setPluginToDelete(null);
|
||||
setShowOperationModal(false);
|
||||
setTargetPlugin(null);
|
||||
asyncTask.reset();
|
||||
}}
|
||||
>
|
||||
{t('plugins.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{pluginRemoveStatus === PluginRemoveStatus.WAIT_INPUT && (
|
||||
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant={
|
||||
operationType === PluginOperationType.DELETE
|
||||
? 'destructive'
|
||||
: 'default'
|
||||
}
|
||||
onClick={() => {
|
||||
deletePlugin();
|
||||
executeOperation();
|
||||
}}
|
||||
>
|
||||
{t('plugins.confirmDelete')}
|
||||
{operationType === PluginOperationType.DELETE
|
||||
? t('plugins.confirmDelete')
|
||||
: t('plugins.confirmUpdate')}
|
||||
</Button>
|
||||
)}
|
||||
{pluginRemoveStatus === PluginRemoveStatus.REMOVING && (
|
||||
<Button variant="destructive" disabled>
|
||||
{t('plugins.deleting')}
|
||||
{asyncTask.status === AsyncTaskStatus.RUNNING && (
|
||||
<Button
|
||||
variant={
|
||||
operationType === PluginOperationType.DELETE
|
||||
? 'destructive'
|
||||
: 'default'
|
||||
}
|
||||
disabled
|
||||
>
|
||||
{operationType === PluginOperationType.DELETE
|
||||
? t('plugins.deleting')
|
||||
: t('plugins.updating')}
|
||||
</Button>
|
||||
)}
|
||||
{pluginRemoveStatus === PluginRemoveStatus.ERROR && (
|
||||
{asyncTask.status === AsyncTaskStatus.ERROR && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirmModal(false);
|
||||
setPluginRemoveStatus(PluginRemoveStatus.WAIT_INPUT);
|
||||
setShowOperationModal(false);
|
||||
asyncTask.reset();
|
||||
}}
|
||||
>
|
||||
{t('plugins.close')}
|
||||
@@ -256,6 +291,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
cardVO={vo}
|
||||
onCardClick={() => handlePluginClick(vo)}
|
||||
onDeleteClick={() => handlePluginDelete(vo)}
|
||||
onUpgradeClick={() => handlePluginUpdate(vo)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ExternalLink,
|
||||
Ellipsis,
|
||||
Trash,
|
||||
ArrowUp,
|
||||
} from 'lucide-react';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { PluginComponent } from '@/app/infra/entities/plugin';
|
||||
@@ -22,17 +23,9 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
enum PluginRemoveStatus {
|
||||
WAIT_INPUT = 'WAIT_INPUT',
|
||||
REMOVING = 'REMOVING',
|
||||
ERROR = 'ERROR',
|
||||
}
|
||||
|
||||
function getComponentList(components: PluginComponent[], t: TFunction) {
|
||||
const componentKindCount: Record<string, number> = {};
|
||||
|
||||
@@ -80,14 +73,17 @@ export default function PluginCardComponent({
|
||||
cardVO,
|
||||
onCardClick,
|
||||
onDeleteClick,
|
||||
onUpgradeClick,
|
||||
}: {
|
||||
cardVO: PluginCardVO;
|
||||
onCardClick: () => void;
|
||||
onDeleteClick: (cardVO: PluginCardVO) => void;
|
||||
onUpgradeClick: (cardVO: PluginCardVO) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = useState(cardVO.enabled);
|
||||
const [switchEnable, setSwitchEnable] = useState(true);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
function handleEnable(e: React.MouseEvent) {
|
||||
e.stopPropagation(); // 阻止事件冒泡
|
||||
@@ -212,18 +208,33 @@ export default function PluginCardComponent({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost">
|
||||
<Ellipsis className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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
|
||||
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
|
||||
onClick={(e) => {
|
||||
onDeleteClick(cardVO);
|
||||
e.stopPropagation();
|
||||
onDeleteClick(cardVO);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<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 DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -60,7 +52,11 @@ export default function PluginForm({
|
||||
};
|
||||
|
||||
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 (
|
||||
|
||||
@@ -240,13 +240,6 @@ export class BackendClient extends BaseHttpClient {
|
||||
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(
|
||||
source: string,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
@@ -278,6 +271,13 @@ export class BackendClient extends BaseHttpClient {
|
||||
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 ============
|
||||
public getSystemInfo(): Promise<ApiRespSystemInfo> {
|
||||
return this.get('/api/v1/system/info');
|
||||
|
||||
99
web/src/hooks/useAsyncTask.ts
Normal file
99
web/src/hooks/useAsyncTask.ts
Normal file
@@ -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: ',
|
||||
close: 'Close',
|
||||
deleteConfirm: 'Delete Confirmation',
|
||||
deleteSuccess: 'Delete successful',
|
||||
modifyFailed: 'Modify failed: ',
|
||||
eventCount: 'Events: {{count}}',
|
||||
toolCount: 'Tools: {{count}}',
|
||||
@@ -193,6 +194,17 @@ const enUS = {
|
||||
fromGithub: 'From GitHub',
|
||||
fromLocal: 'From Local',
|
||||
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: {
|
||||
searchPlaceholder: 'Search plugins...',
|
||||
|
||||
@@ -175,6 +175,7 @@ const jaJP = {
|
||||
deleteError: '削除に失敗しました:',
|
||||
close: '閉じる',
|
||||
deleteConfirm: '削除の確認',
|
||||
deleteSuccess: '削除に成功しました',
|
||||
modifyFailed: '変更に失敗しました:',
|
||||
eventCount: 'イベント:{{count}}',
|
||||
toolCount: 'ツール:{{count}}',
|
||||
@@ -193,6 +194,17 @@ const jaJP = {
|
||||
fromGithub: 'GitHubから',
|
||||
fromLocal: 'ローカルから',
|
||||
fromMarketplace: 'プラグインマーケットから',
|
||||
componentsList: '部品:',
|
||||
noComponents: '部品がありません',
|
||||
delete: 'プラグインを削除',
|
||||
update: 'プラグインを更新',
|
||||
updateConfirm: '更新の確認',
|
||||
confirmUpdatePlugin:
|
||||
'プラグイン「{{author}}/{{name}}」を更新してもよろしいですか?',
|
||||
confirmUpdate: '更新を確認',
|
||||
updating: '更新中...',
|
||||
updateSuccess: 'プラグインの更新に成功しました',
|
||||
updateError: '更新に失敗しました:',
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'プラグインを検索...',
|
||||
|
||||
@@ -171,6 +171,7 @@ const zhHans = {
|
||||
deleteError: '删除失败:',
|
||||
close: '关闭',
|
||||
deleteConfirm: '删除确认',
|
||||
deleteSuccess: '删除成功',
|
||||
modifyFailed: '修改失败:',
|
||||
eventCount: '事件:{{count}}',
|
||||
toolCount: '工具:{{count}}',
|
||||
@@ -190,7 +191,14 @@ const zhHans = {
|
||||
fromMarketplace: '来自市场',
|
||||
componentsList: '组件: ',
|
||||
noComponents: '无组件',
|
||||
delete: '删除',
|
||||
delete: '删除插件',
|
||||
update: '更新插件',
|
||||
updateConfirm: '更新确认',
|
||||
confirmUpdatePlugin: '你确定要更新插件({{author}}/{{name}})吗?',
|
||||
confirmUpdate: '确认更新',
|
||||
updating: '更新中...',
|
||||
updateSuccess: '插件更新成功',
|
||||
updateError: '更新失败:',
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: '搜索插件...',
|
||||
|
||||
Reference in New Issue
Block a user