From 7699ba3caed49a1a0cec581e95f687edf0d702f7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 21:09:14 +0800 Subject: [PATCH] feat: add supports for install plugin from GitHub repo releases Add GitHub release installation for plugins --- pkg/api/http/controller/groups/plugins.py | 136 +++++- pkg/plugin/connector.py | 42 +- web/src/app/home/plugins/page.tsx | 494 ++++++++++++++++++---- web/src/app/infra/http/BackendClient.ts | 47 +- web/src/i18n/locales/en-US.ts | 20 + web/src/i18n/locales/ja-JP.ts | 20 + web/src/i18n/locales/zh-Hans.ts | 20 + web/src/i18n/locales/zh-Hant.ts | 21 +- 8 files changed, 712 insertions(+), 88 deletions(-) diff --git a/pkg/api/http/controller/groups/plugins.py b/pkg/api/http/controller/groups/plugins.py index 4a3f723e..56d05b53 100644 --- a/pkg/api/http/controller/groups/plugins.py +++ b/pkg/api/http/controller/groups/plugins.py @@ -2,6 +2,8 @@ from __future__ import annotations import base64 import quart +import re +import httpx from .....core import taskmgr from .. import group @@ -48,7 +50,9 @@ class PluginsRouterGroup(group.RouterGroup): delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true' ctx = taskmgr.TaskContext.new() wrapper = self.ap.task_mgr.create_user_task( - self.ap.plugin_connector.delete_plugin(author, plugin_name, delete_data=delete_data, task_context=ctx), + self.ap.plugin_connector.delete_plugin( + author, plugin_name, delete_data=delete_data, task_context=ctx + ), kind='plugin-operation', name=f'plugin-remove-{plugin_name}', label=f'Removing plugin {plugin_name}', @@ -90,23 +94,145 @@ class PluginsRouterGroup(group.RouterGroup): return quart.Response(icon_data, mimetype=mime_type) + @self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """Get releases from a GitHub repository URL""" + data = await quart.request.json + repo_url = data.get('repo_url', '') + + # Parse GitHub repository URL to extract owner and repo + # Supports: https://github.com/owner/repo or github.com/owner/repo + pattern = r'github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$' + match = re.search(pattern, repo_url) + + if not match: + return self.http_status(400, -1, 'Invalid GitHub repository URL') + + owner, repo = match.groups() + + try: + # Fetch releases from GitHub API + url = f'https://api.github.com/repos/{owner}/{repo}/releases' + async with httpx.AsyncClient( + trust_env=True, + follow_redirects=True, + timeout=10, + ) as client: + response = await client.get(url) + response.raise_for_status() + releases = response.json() + + # Format releases data for frontend + formatted_releases = [] + for release in releases: + formatted_releases.append( + { + 'id': release['id'], + 'tag_name': release['tag_name'], + 'name': release['name'], + 'published_at': release['published_at'], + 'prerelease': release['prerelease'], + 'draft': release['draft'], + } + ) + + return self.success(data={'releases': formatted_releases, 'owner': owner, 'repo': repo}) + except httpx.RequestError as e: + return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}') + + @self.route( + '/github/release-assets', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN, + ) + async def _() -> str: + """Get assets from a specific GitHub release""" + data = await quart.request.json + owner = data.get('owner', '') + repo = data.get('repo', '') + release_id = data.get('release_id', '') + + if not all([owner, repo, release_id]): + return self.http_status(400, -1, 'Missing required parameters') + + try: + # Fetch release assets from GitHub API + url = f'https://api.github.com/repos/{owner}/{repo}/releases/{release_id}' + async with httpx.AsyncClient( + trust_env=True, + follow_redirects=True, + timeout=10, + ) as client: + response = await client.get( + url, + ) + response.raise_for_status() + release = response.json() + + # Format assets data for frontend + formatted_assets = [] + for asset in release.get('assets', []): + formatted_assets.append( + { + 'id': asset['id'], + 'name': asset['name'], + 'size': asset['size'], + 'download_url': asset['browser_download_url'], + 'content_type': asset['content_type'], + } + ) + + # add zipball as a downloadable asset + # formatted_assets.append( + # { + # "id": 0, + # "name": "Source code (zip)", + # "size": -1, + # "download_url": release["zipball_url"], + # "content_type": "application/zip", + # } + # ) + + return self.success(data={'assets': formatted_assets}) + except httpx.RequestError as e: + return self.http_status(500, -1, f'Failed to fetch release assets: {str(e)}') + @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: + """Install plugin from GitHub release asset""" data = await quart.request.json + asset_url = data.get('asset_url', '') + owner = data.get('owner', '') + repo = data.get('repo', '') + release_tag = data.get('release_tag', '') + + if not asset_url: + return self.http_status(400, -1, 'Missing asset_url parameter') ctx = taskmgr.TaskContext.new() - short_source_str = data['source'][-8:] + install_info = { + 'asset_url': asset_url, + 'owner': owner, + 'repo': repo, + 'release_tag': release_tag, + 'github_url': f'https://github.com/{owner}/{repo}', + } + wrapper = self.ap.task_mgr.create_user_task( - self.ap.plugin_mgr.install_plugin(data['source'], task_context=ctx), + self.ap.plugin_connector.install_plugin(PluginInstallSource.GITHUB, install_info, task_context=ctx), kind='plugin-operation', name='plugin-install-github', - label=f'Installing plugin from github ...{short_source_str}', + label=f'Installing plugin from GitHub {owner}/{repo}@{release_tag}', context=ctx, ) return self.success(data={'task_id': wrapper.id}) - @self.route('/install/marketplace', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + @self.route( + '/install/marketplace', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN, + ) async def _() -> str: data = await quart.request.json diff --git a/pkg/plugin/connector.py b/pkg/plugin/connector.py index 4b5809fe..45aeb8a8 100644 --- a/pkg/plugin/connector.py +++ b/pkg/plugin/connector.py @@ -6,19 +6,24 @@ from typing import Any import typing import os import sys - +import httpx from async_lru import alru_cache from ..core import app from . import handler from ..utils import platform -from langbot_plugin.runtime.io.controllers.stdio import client as stdio_client_controller +from langbot_plugin.runtime.io.controllers.stdio import ( + client as stdio_client_controller, +) from langbot_plugin.runtime.io.controllers.ws import client as ws_client_controller from langbot_plugin.api.entities import events from langbot_plugin.api.entities import context import langbot_plugin.runtime.io.connection as base_connection from langbot_plugin.api.definition.components.manifest import ComponentManifest -from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors +from langbot_plugin.api.entities.builtin.command import ( + context as command_context, + errors as command_errors, +) from langbot_plugin.runtime.plugin.mgr import PluginInstallSource from ..core import taskmgr @@ -71,7 +76,9 @@ class PluginRuntimeConnector: return async def new_connection_callback(connection: base_connection.Connection): - async def disconnect_callback(rchandler: handler.RuntimeConnectionHandler) -> bool: + async def disconnect_callback( + rchandler: handler.RuntimeConnectionHandler, + ) -> bool: if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime(): self.ap.logger.error('Disconnected from plugin runtime, trying to reconnect...') await self.runtime_disconnect_callback(self) @@ -98,7 +105,8 @@ class PluginRuntimeConnector: ) async def make_connection_failed_callback( - ctrl: ws_client_controller.WebSocketClientController, exc: Exception = None + ctrl: ws_client_controller.WebSocketClientController, + exc: Exception = None, ) -> None: if exc is not None: self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}): {exc}') @@ -150,6 +158,25 @@ class PluginRuntimeConnector: install_info['plugin_file_key'] = file_key del install_info['plugin_file'] self.ap.logger.info(f'Transfered file {file_key} to plugin runtime') + elif install_source == PluginInstallSource.GITHUB: + # download and transfer file + try: + async with httpx.AsyncClient( + trust_env=True, + follow_redirects=True, + timeout=20, + ) as client: + response = await client.get( + install_info['asset_url'], + ) + response.raise_for_status() + file_bytes = response.content + file_key = await self.handler.send_file(file_bytes, 'lbpkg') + install_info['plugin_file_key'] = file_key + self.ap.logger.info(f'Transfered file {file_key} to plugin runtime') + except Exception as e: + self.ap.logger.error(f'Failed to download file from GitHub: {e}') + raise Exception(f'Failed to download file from GitHub: {e}') async for ret in self.handler.install_plugin(install_source.value, install_info): current_action = ret.get('current_action', None) @@ -163,7 +190,10 @@ class PluginRuntimeConnector: task_context.trace(trace) async def upgrade_plugin( - self, plugin_author: str, plugin_name: str, task_context: taskmgr.TaskContext | None = None + 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) diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index fa9d0980..37139aee 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -9,6 +9,12 @@ import MCPDeleteConfirmDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPDe import styles from './plugins.module.css'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card'; import { PlusIcon, ChevronDownIcon, @@ -16,6 +22,8 @@ import { StoreIcon, Download, Power, + Github, + ChevronLeft, } from 'lucide-react'; import { DropdownMenu, @@ -41,11 +49,30 @@ import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; enum PluginInstallStatus { WAIT_INPUT = 'wait_input', + SELECT_RELEASE = 'select_release', + SELECT_ASSET = 'select_asset', ASK_CONFIRM = 'ask_confirm', INSTALLING = 'installing', ERROR = 'error', } +interface GithubRelease { + id: number; + tag_name: string; + name: string; + published_at: string; + prerelease: boolean; + draft: boolean; +} + +interface GithubAsset { + id: number; + name: string; + size: number; + download_url: string; + content_type: string; +} + export default function PluginConfigPage() { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState('installed'); @@ -57,6 +84,16 @@ export default function PluginConfigPage() { useState(PluginInstallStatus.WAIT_INPUT); const [installError, setInstallError] = useState(null); const [githubURL, setGithubURL] = useState(''); + const [githubReleases, setGithubReleases] = useState([]); + const [selectedRelease, setSelectedRelease] = useState( + null, + ); + const [githubAssets, setGithubAssets] = useState([]); + const [selectedAsset, setSelectedAsset] = useState(null); + const [githubOwner, setGithubOwner] = useState(''); + const [githubRepo, setGithubRepo] = useState(''); + const [fetchingReleases, setFetchingReleases] = useState(false); + const [fetchingAssets, setFetchingAssets] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const [pluginSystemStatus, setPluginSystemStatus] = useState(null); @@ -86,6 +123,14 @@ export default function PluginConfigPage() { fetchPluginSystemStatus(); }, [t]); + function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; + } + function watchTask(taskId: number) { let alreadySuccess = false; @@ -101,7 +146,7 @@ export default function PluginConfigPage() { toast.success(t('plugins.installSuccess')); alreadySuccess = true; } - setGithubURL(''); + resetGithubState(); setModalOpen(false); pluginInstalledRef.current?.refreshPluginList(); } @@ -112,52 +157,143 @@ export default function PluginConfigPage() { const pluginInstalledRef = useRef(null); - function handleModalConfirm() { - installPlugin(installSource, installInfo as Record); + function resetGithubState() { + setGithubURL(''); + setGithubReleases([]); + setSelectedRelease(null); + setGithubAssets([]); + setSelectedAsset(null); + setGithubOwner(''); + setGithubRepo(''); + setFetchingReleases(false); + setFetchingAssets(false); } - const installPlugin = useCallback( - (installSource: string, installInfo: Record) => { - setPluginInstallStatus(PluginInstallStatus.INSTALLING); - if (installSource === 'github') { - httpClient - .installPluginFromGithub((installInfo as { url: string }).url) - .then((resp) => { - const taskId = resp.task_id; - watchTask(taskId); - }) - .catch((err) => { - console.log('error when install plugin:', err); - setInstallError(err.message); - setPluginInstallStatus(PluginInstallStatus.ERROR); - }); - } else if (installSource === 'local') { - httpClient - .installPluginFromLocal((installInfo as { file: File }).file) - .then((resp) => { - const taskId = resp.task_id; - watchTask(taskId); - }) - .catch((err) => { - console.log('error when install plugin:', err); - setInstallError(err.message); - setPluginInstallStatus(PluginInstallStatus.ERROR); - }); - } else if (installSource === 'marketplace') { - httpClient - .installPluginFromMarketplace( - (installInfo as { plugin_author: string }).plugin_author, - (installInfo as { plugin_name: string }).plugin_name, - (installInfo as { plugin_version: string }).plugin_version, - ) - .then((resp) => { - const taskId = resp.task_id; - watchTask(taskId); - }); + async function fetchGithubReleases() { + if (!githubURL.trim()) { + toast.error(t('plugins.enterRepoUrl')); + return; + } + + setFetchingReleases(true); + setInstallError(null); + + try { + const result = await httpClient.getGithubReleases(githubURL); + setGithubReleases(result.releases); + setGithubOwner(result.owner); + setGithubRepo(result.repo); + + if (result.releases.length === 0) { + toast.warning(t('plugins.noReleasesFound')); + } else { + setPluginInstallStatus(PluginInstallStatus.SELECT_RELEASE); } - }, - [watchTask], - ); + } catch (error: unknown) { + console.error('Failed to fetch GitHub releases:', error); + const errorMessage = + error instanceof Error ? error.message : String(error); + setInstallError(errorMessage || t('plugins.fetchReleasesError')); + setPluginInstallStatus(PluginInstallStatus.ERROR); + } finally { + setFetchingReleases(false); + } + } + + async function handleReleaseSelect(release: GithubRelease) { + setSelectedRelease(release); + setFetchingAssets(true); + setInstallError(null); + + try { + const result = await httpClient.getGithubReleaseAssets( + githubOwner, + githubRepo, + release.id, + ); + setGithubAssets(result.assets); + + if (result.assets.length === 0) { + toast.warning(t('plugins.noAssetsFound')); + } else { + setPluginInstallStatus(PluginInstallStatus.SELECT_ASSET); + } + } catch (error: unknown) { + console.error('Failed to fetch GitHub release assets:', error); + const errorMessage = + error instanceof Error ? error.message : String(error); + setInstallError(errorMessage || t('plugins.fetchAssetsError')); + setPluginInstallStatus(PluginInstallStatus.ERROR); + } finally { + setFetchingAssets(false); + } + } + + function handleAssetSelect(asset: GithubAsset) { + setSelectedAsset(asset); + setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM); + } + + function handleModalConfirm() { + if (installSource === 'github' && selectedAsset && selectedRelease) { + installPlugin('github', { + asset_url: selectedAsset.download_url, + owner: githubOwner, + repo: githubRepo, + release_tag: selectedRelease.tag_name, + }); + } else { + installPlugin(installSource, installInfo as Record); // eslint-disable-line @typescript-eslint/no-explicit-any + } + } + + function installPlugin( + installSource: string, + installInfo: Record, // eslint-disable-line @typescript-eslint/no-explicit-any + ) { + setPluginInstallStatus(PluginInstallStatus.INSTALLING); + if (installSource === 'github') { + httpClient + .installPluginFromGithub( + installInfo.asset_url, + installInfo.owner, + installInfo.repo, + installInfo.release_tag, + ) + .then((resp) => { + const taskId = resp.task_id; + watchTask(taskId); + }) + .catch((err) => { + console.log('error when install plugin:', err); + setInstallError(err.message); + setPluginInstallStatus(PluginInstallStatus.ERROR); + }); + } else if (installSource === 'local') { + httpClient + .installPluginFromLocal(installInfo.file) + .then((resp) => { + const taskId = resp.task_id; + watchTask(taskId); + }) + .catch((err) => { + console.log('error when install plugin:', err); + setInstallError(err.message); + setPluginInstallStatus(PluginInstallStatus.ERROR); + }); + } else if (installSource === 'marketplace') { + httpClient + .installPluginFromMarketplace( + installInfo.plugin_author, + installInfo.plugin_name, + installInfo.plugin_version, + ) + .then((resp) => { + const taskId = resp.task_id; + watchTask(taskId); + }); + } + } const validateFileType = (file: File): boolean => { const allowedExtensions = ['.lbpkg', '.zip']; @@ -353,10 +489,6 @@ export default function PluginConfigPage() { ) : ( <> - - - {t('plugins.uploadLocal')} - {systemInfo.enable_marketplace && ( { @@ -367,6 +499,22 @@ export default function PluginConfigPage() { {t('plugins.marketplace')} )} + + + {t('plugins.uploadLocal')} + + { + setInstallSource('github'); + setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT); + setInstallError(null); + resetGithubState(); + setModalOpen(true); + }} + > + + {t('plugins.installFromGithub')} + )} @@ -402,49 +550,247 @@ export default function PluginConfigPage() { - - + { + setModalOpen(open); + if (!open) { + resetGithubState(); + setInstallError(null); + } + }} + > + - + {installSource === 'github' ? ( + + ) : ( + + )} {t('plugins.installPlugin')} - {pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && ( -
-

{t('plugins.onlySupportGithub')}

- setGithubURL(e.target.value)} - className="mb-4" - /> -
- )} - {pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( -
-

- {t('plugins.askConfirm', { - name: installInfo.plugin_name, - version: installInfo.plugin_version, - })} -

-
- )} + + {/* GitHub Install Flow */} + {installSource === 'github' && + pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && ( +
+

{t('plugins.enterRepoUrl')}

+ setGithubURL(e.target.value)} + className="mb-4" + /> + {fetchingReleases && ( +

+ {t('plugins.fetchingReleases')} +

+ )} +
+ )} + + {installSource === 'github' && + pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && ( +
+
+

{t('plugins.selectRelease')}

+ +
+
+ {githubReleases.map((release) => ( + handleReleaseSelect(release)} + > + +
+ + {release.name || release.tag_name} + + + {t('plugins.releaseTag', { tag: release.tag_name })}{' '} + •{' '} + {t('plugins.publishedAt', { + date: new Date( + release.published_at, + ).toLocaleDateString(), + })} + +
+ {release.prerelease && ( + + {t('plugins.prerelease')} + + )} +
+
+ ))} +
+ {fetchingAssets && ( +

+ {t('plugins.loading')} +

+ )} +
+ )} + + {installSource === 'github' && + pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && ( +
+
+

{t('plugins.selectAsset')}

+ +
+ {selectedRelease && ( +
+
+ {selectedRelease.name || selectedRelease.tag_name} +
+
+ {selectedRelease.tag_name} +
+
+ )} +
+ {githubAssets.map((asset) => ( + handleAssetSelect(asset)} + > + + {asset.name} + + {t('plugins.assetSize', { + size: formatFileSize(asset.size), + })} + + + + ))} +
+
+ )} + + {/* Marketplace Install Confirm */} + {installSource === 'marketplace' && + pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( +
+

+ {t('plugins.askConfirm', { + name: installInfo.plugin_name, + version: installInfo.plugin_version, + })} +

+
+ )} + + {/* GitHub Install Confirm */} + {installSource === 'github' && + pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( +
+
+

{t('plugins.confirmInstall')}

+ +
+ {selectedRelease && selectedAsset && ( +
+
+ Repository: + + {githubOwner}/{githubRepo} + +
+
+ Release: + + {selectedRelease.tag_name} + +
+
+ File: + {selectedAsset.name} +
+
+ )} +
+ )} + + {/* Installing State */} {pluginInstallStatus === PluginInstallStatus.INSTALLING && (

{t('plugins.installing')}

)} + + {/* Error State */} {pluginInstallStatus === PluginInstallStatus.ERROR && (

{t('plugins.installFailed')}

{installError}

)} + - {(pluginInstallStatus === PluginInstallStatus.WAIT_INPUT || - pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM) && ( + {pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && + installSource === 'github' && ( + <> + + + + )} + {pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( <>