diff --git a/pkg/api/http/controller/groups/plugins.py b/pkg/api/http/controller/groups/plugins.py index 4ae03042..7825c251 100644 --- a/pkg/api/http/controller/groups/plugins.py +++ b/pkg/api/http/controller/groups/plugins.py @@ -29,17 +29,17 @@ class PluginsRouterGroup(group.RouterGroup): return self.success() @self.route( - '///update', + '///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, ) diff --git a/pkg/plugin/connector.py b/pkg/plugin/connector.py index 92c26c28..8c2a91ad 100644 --- a/pkg/plugin/connector.py +++ b/pkg/plugin/connector.py @@ -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() diff --git a/pkg/plugin/context.py b/pkg/plugin/context.py deleted file mode 100644 index a95660c1..00000000 --- a/pkg/plugin/context.py +++ /dev/null @@ -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=. - - 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 diff --git a/pkg/plugin/errors.py b/pkg/plugin/errors.py deleted file mode 100644 index 8da223db..00000000 --- a/pkg/plugin/errors.py +++ /dev/null @@ -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}') diff --git a/pkg/plugin/handler.py b/pkg/plugin/handler.py index 5a8f9d9a..4095ea86 100644 --- a/pkg/plugin/handler.py +++ b/pkg/plugin/handler.py @@ -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( diff --git a/pkg/plugin/host.py b/pkg/plugin/host.py deleted file mode 100644 index 0adb0078..00000000 --- a/pkg/plugin/host.py +++ /dev/null @@ -1,9 +0,0 @@ -# 此模块已过时 -# 请从 pkg.plugin.context 引入 BasePlugin, EventContext 和 APIHost -# 最早将于 v3.4 移除此模块 - -from .events import * - - -def emit(*args, **kwargs): - print('插件调用了已弃用的函数 pkg.plugin.host.emit()') diff --git a/pkg/plugin/installer.py b/pkg/plugin/installer.py deleted file mode 100644 index 159967dc..00000000 --- a/pkg/plugin/installer.py +++ /dev/null @@ -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 diff --git a/pkg/plugin/installers/__init__.py b/pkg/plugin/installers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pkg/plugin/installers/github.py b/pkg/plugin/installers/github.py deleted file mode 100644 index df247219..00000000 --- a/pkg/plugin/installers/github.py +++ /dev/null @@ -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('插件无源码信息,无法更新') diff --git a/pkg/plugin/loader.py b/pkg/plugin/loader.py deleted file mode 100644 index 191d8bc1..00000000 --- a/pkg/plugin/loader.py +++ /dev/null @@ -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 diff --git a/pkg/plugin/loaders/__init__.py b/pkg/plugin/loaders/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pkg/plugin/loaders/classic.py b/pkg/plugin/loaders/classic.py deleted file mode 100644 index 98a625d4..00000000 --- a/pkg/plugin/loaders/classic.py +++ /dev/null @@ -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=[''])) diff --git a/pkg/plugin/loaders/manifest.py b/pkg/plugin/loaders/manifest.py deleted file mode 100644 index 91b15f02..00000000 --- a/pkg/plugin/loaders/manifest.py +++ /dev/null @@ -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() diff --git a/pkg/plugin/models.py b/pkg/plugin/models.py deleted file mode 100644 index 1b3a9b3f..00000000 --- a/pkg/plugin/models.py +++ /dev/null @@ -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 diff --git a/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx index 81428e36..0f13618f 100644 --- a/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/plugin-installed/PluginInstalledComponent.tsx @@ -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( const [selectedPlugin, setSelectedPlugin] = useState( null, ); - const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); - const [pluginRemoveStatus, setPluginRemoveStatus] = - useState(PluginRemoveStatus.WAIT_INPUT); - const [pluginRemoveError, setPluginRemoveError] = useState( - null, - ); - const [pluginToDelete, setPluginToDelete] = useState( - null, + const [showOperationModal, setShowOperationModal] = useState(false); + const [operationType, setOperationType] = useState( + PluginOperationType.DELETE, ); + const [targetPlugin, setTargetPlugin] = useState(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( } 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 ( <> { if (!open) { - setShowDeleteConfirmModal(false); - setPluginRemoveStatus(PluginRemoveStatus.WAIT_INPUT); - setPluginToDelete(null); + setShowOperationModal(false); + setTargetPlugin(null); + asyncTask.reset(); } }} > - {t('plugins.deleteConfirm')} + + {operationType === PluginOperationType.DELETE + ? t('plugins.deleteConfirm') + : t('plugins.updateConfirm')} + - {pluginRemoveStatus === PluginRemoveStatus.WAIT_INPUT && ( + {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
- {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 ?? '', + })}
)} - {pluginRemoveStatus === PluginRemoveStatus.REMOVING && ( -
{t('plugins.deleting')}
- )} - {pluginRemoveStatus === PluginRemoveStatus.ERROR && ( + {asyncTask.status === AsyncTaskStatus.RUNNING && (
- {t('plugins.deleteError')} -
{pluginRemoveError}
+ {operationType === PluginOperationType.DELETE + ? t('plugins.deleting') + : t('plugins.updating')} +
+ )} + {asyncTask.status === AsyncTaskStatus.ERROR && ( +
+ {operationType === PluginOperationType.DELETE + ? t('plugins.deleteError') + : t('plugins.updateError')} +
{asyncTask.error}
)}
- {pluginRemoveStatus === PluginRemoveStatus.WAIT_INPUT && ( + {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && ( )} - {pluginRemoveStatus === PluginRemoveStatus.WAIT_INPUT && ( + {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && ( )} - {pluginRemoveStatus === PluginRemoveStatus.REMOVING && ( - )} - {pluginRemoveStatus === PluginRemoveStatus.ERROR && ( + {asyncTask.status === AsyncTaskStatus.ERROR && ( + {/**upgrade */} + {cardVO.install_source === 'marketplace' && ( + { + e.stopPropagation(); + onUpgradeClick(cardVO); + setDropdownOpen(false); + }} + > + + {t('plugins.update')} + + )} { - onDeleteClick(cardVO); e.stopPropagation(); + onDeleteClick(cardVO); + setDropdownOpen(false); }} > diff --git a/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx b/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx index 909e87c2..82c1a5e8 100644 --- a/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx +++ b/web/src/app/home/plugins/plugin-installed/plugin-form/PluginForm.tsx @@ -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
{t('plugins.loading')}
; + return ( +
+ {t('plugins.loading')} +
+ ); } return ( diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index d077abf6..675a5094 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -240,13 +240,6 @@ export class BackendClient extends BaseHttpClient { return this.put('/api/v1/plugins/reorder', { plugins }); } - public updatePlugin( - author: string, - name: string, - ): Promise { - return this.post(`/api/v1/plugins/${author}/${name}/update`); - } - public installPluginFromGithub( source: string, ): Promise { @@ -278,6 +271,13 @@ export class BackendClient extends BaseHttpClient { return this.delete(`/api/v1/plugins/${author}/${name}`); } + public upgradePlugin( + author: string, + name: string, + ): Promise { + return this.post(`/api/v1/plugins/${author}/${name}/upgrade`); + } + // ============ System API ============ public getSystemInfo(): Promise { return this.get('/api/v1/system/info'); diff --git a/web/src/hooks/useAsyncTask.ts b/web/src/hooks/useAsyncTask.ts new file mode 100644 index 00000000..085c3b97 --- /dev/null +++ b/web/src/hooks/useAsyncTask.ts @@ -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.WAIT_INPUT, + ); + const [error, setError] = useState(null); + const intervalRef = useRef(null); + const alreadySuccessRef = useRef(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, + }; +} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 071930a0..c0d74898 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -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...', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 93d48dc1..596ce4eb 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -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: 'プラグインを検索...', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 066a277e..2c85d45d 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -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: '搜索插件...',