feat: add supports for install plugin from GitHub repo releases

Add GitHub release installation for plugins
This commit is contained in:
Copilot
2025-11-04 21:09:14 +08:00
committed by GitHub
parent 9ac8b1a6fd
commit 7699ba3cae
8 changed files with 712 additions and 88 deletions

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
import base64 import base64
import quart import quart
import re
import httpx
from .....core import taskmgr from .....core import taskmgr
from .. import group from .. import group
@@ -48,7 +50,9 @@ class PluginsRouterGroup(group.RouterGroup):
delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true' delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true'
ctx = taskmgr.TaskContext.new() ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task( wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_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', kind='plugin-operation',
name=f'plugin-remove-{plugin_name}', name=f'plugin-remove-{plugin_name}',
label=f'Removing plugin {plugin_name}', label=f'Removing plugin {plugin_name}',
@@ -90,23 +94,145 @@ class PluginsRouterGroup(group.RouterGroup):
return quart.Response(icon_data, mimetype=mime_type) 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) @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str: async def _() -> str:
"""Install plugin from GitHub release asset"""
data = await quart.request.json 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() 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( 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', kind='plugin-operation',
name='plugin-install-github', 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, context=ctx,
) )
return self.success(data={'task_id': wrapper.id}) 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: async def _() -> str:
data = await quart.request.json data = await quart.request.json

View File

@@ -6,19 +6,24 @@ from typing import Any
import typing import typing
import os import os
import sys import sys
import httpx
from async_lru import alru_cache from async_lru import alru_cache
from ..core import app from ..core import app
from . import handler from . import handler
from ..utils import platform 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.runtime.io.controllers.ws import client as ws_client_controller
from langbot_plugin.api.entities import events from langbot_plugin.api.entities import events
from langbot_plugin.api.entities import context from langbot_plugin.api.entities import context
import langbot_plugin.runtime.io.connection as base_connection import langbot_plugin.runtime.io.connection as base_connection
from langbot_plugin.api.definition.components.manifest import ComponentManifest 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 langbot_plugin.runtime.plugin.mgr import PluginInstallSource
from ..core import taskmgr from ..core import taskmgr
@@ -71,7 +76,9 @@ class PluginRuntimeConnector:
return return
async def new_connection_callback(connection: base_connection.Connection): 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(): if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
self.ap.logger.error('Disconnected from plugin runtime, trying to reconnect...') self.ap.logger.error('Disconnected from plugin runtime, trying to reconnect...')
await self.runtime_disconnect_callback(self) await self.runtime_disconnect_callback(self)
@@ -98,7 +105,8 @@ class PluginRuntimeConnector:
) )
async def make_connection_failed_callback( async def make_connection_failed_callback(
ctrl: ws_client_controller.WebSocketClientController, exc: Exception = None ctrl: ws_client_controller.WebSocketClientController,
exc: Exception = None,
) -> None: ) -> None:
if exc is not None: if exc is not None:
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}): {exc}') 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 install_info['plugin_file_key'] = file_key
del install_info['plugin_file'] del install_info['plugin_file']
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime') 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): async for ret in self.handler.install_plugin(install_source.value, install_info):
current_action = ret.get('current_action', None) current_action = ret.get('current_action', None)
@@ -163,7 +190,10 @@ class PluginRuntimeConnector:
task_context.trace(trace) task_context.trace(trace)
async def upgrade_plugin( 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]: ) -> dict[str, Any]:
async for ret in self.handler.upgrade_plugin(plugin_author, plugin_name): async for ret in self.handler.upgrade_plugin(plugin_author, plugin_name):
current_action = ret.get('current_action', None) current_action = ret.get('current_action', None)

View File

@@ -9,6 +9,12 @@ import MCPDeleteConfirmDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPDe
import styles from './plugins.module.css'; import styles from './plugins.module.css';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import { import {
PlusIcon, PlusIcon,
ChevronDownIcon, ChevronDownIcon,
@@ -16,6 +22,8 @@ import {
StoreIcon, StoreIcon,
Download, Download,
Power, Power,
Github,
ChevronLeft,
} from 'lucide-react'; } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
@@ -41,11 +49,30 @@ import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
enum PluginInstallStatus { enum PluginInstallStatus {
WAIT_INPUT = 'wait_input', WAIT_INPUT = 'wait_input',
SELECT_RELEASE = 'select_release',
SELECT_ASSET = 'select_asset',
ASK_CONFIRM = 'ask_confirm', ASK_CONFIRM = 'ask_confirm',
INSTALLING = 'installing', INSTALLING = 'installing',
ERROR = 'error', 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() { export default function PluginConfigPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('installed'); const [activeTab, setActiveTab] = useState('installed');
@@ -57,6 +84,16 @@ export default function PluginConfigPage() {
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT); useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
const [installError, setInstallError] = useState<string | null>(null); const [installError, setInstallError] = useState<string | null>(null);
const [githubURL, setGithubURL] = useState(''); const [githubURL, setGithubURL] = useState('');
const [githubReleases, setGithubReleases] = useState<GithubRelease[]>([]);
const [selectedRelease, setSelectedRelease] = useState<GithubRelease | null>(
null,
);
const [githubAssets, setGithubAssets] = useState<GithubAsset[]>([]);
const [selectedAsset, setSelectedAsset] = useState<GithubAsset | null>(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 [isDragOver, setIsDragOver] = useState(false);
const [pluginSystemStatus, setPluginSystemStatus] = const [pluginSystemStatus, setPluginSystemStatus] =
useState<ApiRespPluginSystemStatus | null>(null); useState<ApiRespPluginSystemStatus | null>(null);
@@ -86,6 +123,14 @@ export default function PluginConfigPage() {
fetchPluginSystemStatus(); fetchPluginSystemStatus();
}, [t]); }, [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) { function watchTask(taskId: number) {
let alreadySuccess = false; let alreadySuccess = false;
@@ -101,7 +146,7 @@ export default function PluginConfigPage() {
toast.success(t('plugins.installSuccess')); toast.success(t('plugins.installSuccess'));
alreadySuccess = true; alreadySuccess = true;
} }
setGithubURL(''); resetGithubState();
setModalOpen(false); setModalOpen(false);
pluginInstalledRef.current?.refreshPluginList(); pluginInstalledRef.current?.refreshPluginList();
} }
@@ -112,52 +157,143 @@ export default function PluginConfigPage() {
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null); const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
function handleModalConfirm() { function resetGithubState() {
installPlugin(installSource, installInfo as Record<string, unknown>); setGithubURL('');
setGithubReleases([]);
setSelectedRelease(null);
setGithubAssets([]);
setSelectedAsset(null);
setGithubOwner('');
setGithubRepo('');
setFetchingReleases(false);
setFetchingAssets(false);
} }
const installPlugin = useCallback( async function fetchGithubReleases() {
(installSource: string, installInfo: Record<string, unknown>) => { if (!githubURL.trim()) {
setPluginInstallStatus(PluginInstallStatus.INSTALLING); toast.error(t('plugins.enterRepoUrl'));
if (installSource === 'github') { return;
httpClient }
.installPluginFromGithub((installInfo as { url: string }).url)
.then((resp) => { setFetchingReleases(true);
const taskId = resp.task_id; setInstallError(null);
watchTask(taskId);
}) try {
.catch((err) => { const result = await httpClient.getGithubReleases(githubURL);
console.log('error when install plugin:', err); setGithubReleases(result.releases);
setInstallError(err.message); setGithubOwner(result.owner);
setPluginInstallStatus(PluginInstallStatus.ERROR); setGithubRepo(result.repo);
});
} else if (installSource === 'local') { if (result.releases.length === 0) {
httpClient toast.warning(t('plugins.noReleasesFound'));
.installPluginFromLocal((installInfo as { file: File }).file) } else {
.then((resp) => { setPluginInstallStatus(PluginInstallStatus.SELECT_RELEASE);
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);
});
} }
}, } catch (error: unknown) {
[watchTask], 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<string, any>); // eslint-disable-line @typescript-eslint/no-explicit-any
}
}
function installPlugin(
installSource: string,
installInfo: Record<string, any>, // 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 validateFileType = (file: File): boolean => {
const allowedExtensions = ['.lbpkg', '.zip']; const allowedExtensions = ['.lbpkg', '.zip'];
@@ -353,10 +489,6 @@ export default function PluginConfigPage() {
</> </>
) : ( ) : (
<> <>
<DropdownMenuItem onClick={handleFileSelect}>
<UploadIcon className="w-4 h-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
{systemInfo.enable_marketplace && ( {systemInfo.enable_marketplace && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
@@ -367,6 +499,22 @@ export default function PluginConfigPage() {
{t('plugins.marketplace')} {t('plugins.marketplace')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem onClick={handleFileSelect}>
<UploadIcon className="w-4 h-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setInstallSource('github');
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
setInstallError(null);
resetGithubState();
setModalOpen(true);
}}
>
<Github className="w-4 h-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</> </>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
@@ -402,49 +550,247 @@ export default function PluginConfigPage() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<Dialog open={modalOpen} onOpenChange={setModalOpen}> <Dialog
<DialogContent className="w-[500px] p-6 bg-white dark:bg-[#1a1a1e]"> open={modalOpen}
onOpenChange={(open) => {
setModalOpen(open);
if (!open) {
resetGithubState();
setInstallError(null);
}
}}
>
<DialogContent className="w-[500px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-4"> <DialogTitle className="flex items-center gap-4">
<Download className="size-6" /> {installSource === 'github' ? (
<Github className="size-6" />
) : (
<Download className="size-6" />
)}
<span>{t('plugins.installPlugin')}</span> <span>{t('plugins.installPlugin')}</span>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
{pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
<div className="mt-4"> {/* GitHub Install Flow */}
<p className="mb-2">{t('plugins.onlySupportGithub')}</p> {installSource === 'github' &&
<Input pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
placeholder={t('plugins.enterGithubLink')} <div className="mt-4">
value={githubURL} <p className="mb-2">{t('plugins.enterRepoUrl')}</p>
onChange={(e) => setGithubURL(e.target.value)} <Input
className="mb-4" placeholder={t('plugins.repoUrlPlaceholder')}
/> value={githubURL}
</div> onChange={(e) => setGithubURL(e.target.value)}
)} className="mb-4"
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( />
<div className="mt-4"> {fetchingReleases && (
<p className="mb-2"> <p className="text-sm text-gray-500">
{t('plugins.askConfirm', { {t('plugins.fetchingReleases')}
name: installInfo.plugin_name, </p>
version: installInfo.plugin_version, )}
})} </div>
</p> )}
</div>
)} {installSource === 'github' &&
pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && (
<div className="mt-4">
<div className="flex items-center justify-between mb-4">
<p className="font-medium">{t('plugins.selectRelease')}</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
setGithubReleases([]);
}}
>
<ChevronLeft className="w-4 h-4 mr-1" />
{t('plugins.backToRepoUrl')}
</Button>
</div>
<div className="max-h-[400px] overflow-y-auto space-y-2 pb-2">
{githubReleases.map((release) => (
<Card
key={release.id}
className="cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-4"
onClick={() => handleReleaseSelect(release)}
>
<CardHeader className="flex flex-row items-start justify-between px-3 space-y-0">
<div className="flex-1">
<CardTitle className="text-sm">
{release.name || release.tag_name}
</CardTitle>
<CardDescription className="text-xs mt-1">
{t('plugins.releaseTag', { tag: release.tag_name })}{' '}
{' '}
{t('plugins.publishedAt', {
date: new Date(
release.published_at,
).toLocaleDateString(),
})}
</CardDescription>
</div>
{release.prerelease && (
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded ml-2 shrink-0">
{t('plugins.prerelease')}
</span>
)}
</CardHeader>
</Card>
))}
</div>
{fetchingAssets && (
<p className="text-sm text-gray-500 mt-4">
{t('plugins.loading')}
</p>
)}
</div>
)}
{installSource === 'github' &&
pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && (
<div className="mt-4">
<div className="flex items-center justify-between mb-4">
<p className="font-medium">{t('plugins.selectAsset')}</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPluginInstallStatus(
PluginInstallStatus.SELECT_RELEASE,
);
setGithubAssets([]);
setSelectedAsset(null);
}}
>
<ChevronLeft className="w-4 h-4 mr-1" />
{t('plugins.backToReleases')}
</Button>
</div>
{selectedRelease && (
<div className="mb-4 p-2 bg-gray-50 dark:bg-gray-900 rounded">
<div className="text-sm font-medium">
{selectedRelease.name || selectedRelease.tag_name}
</div>
<div className="text-xs text-gray-500">
{selectedRelease.tag_name}
</div>
</div>
)}
<div className="max-h-[400px] overflow-y-auto space-y-2 pb-2">
{githubAssets.map((asset) => (
<Card
key={asset.id}
className="cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-3"
onClick={() => handleAssetSelect(asset)}
>
<CardHeader className="px-3">
<CardTitle className="text-sm">{asset.name}</CardTitle>
<CardDescription className="text-xs">
{t('plugins.assetSize', {
size: formatFileSize(asset.size),
})}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
)}
{/* Marketplace Install Confirm */}
{installSource === 'marketplace' &&
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div className="mt-4">
<p className="mb-2">
{t('plugins.askConfirm', {
name: installInfo.plugin_name,
version: installInfo.plugin_version,
})}
</p>
</div>
)}
{/* GitHub Install Confirm */}
{installSource === 'github' &&
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div className="mt-4">
<div className="flex items-center justify-between mb-4">
<p className="font-medium">{t('plugins.confirmInstall')}</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPluginInstallStatus(PluginInstallStatus.SELECT_ASSET);
setSelectedAsset(null);
}}
>
<ChevronLeft className="w-4 h-4 mr-1" />
{t('plugins.backToAssets')}
</Button>
</div>
{selectedRelease && selectedAsset && (
<div className="p-3 bg-gray-50 dark:bg-gray-900 rounded space-y-2">
<div>
<span className="text-sm font-medium">Repository: </span>
<span className="text-sm">
{githubOwner}/{githubRepo}
</span>
</div>
<div>
<span className="text-sm font-medium">Release: </span>
<span className="text-sm">
{selectedRelease.tag_name}
</span>
</div>
<div>
<span className="text-sm font-medium">File: </span>
<span className="text-sm">{selectedAsset.name}</span>
</div>
</div>
)}
</div>
)}
{/* Installing State */}
{pluginInstallStatus === PluginInstallStatus.INSTALLING && ( {pluginInstallStatus === PluginInstallStatus.INSTALLING && (
<div className="mt-4"> <div className="mt-4">
<p className="mb-2">{t('plugins.installing')}</p> <p className="mb-2">{t('plugins.installing')}</p>
</div> </div>
)} )}
{/* Error State */}
{pluginInstallStatus === PluginInstallStatus.ERROR && ( {pluginInstallStatus === PluginInstallStatus.ERROR && (
<div className="mt-4"> <div className="mt-4">
<p className="mb-2">{t('plugins.installFailed')}</p> <p className="mb-2">{t('plugins.installFailed')}</p>
<p className="mb-2 text-red-500">{installError}</p> <p className="mb-2 text-red-500">{installError}</p>
</div> </div>
)} )}
<DialogFooter> <DialogFooter>
{(pluginInstallStatus === PluginInstallStatus.WAIT_INPUT || {pluginInstallStatus === PluginInstallStatus.WAIT_INPUT &&
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM) && ( installSource === 'github' && (
<>
<Button
variant="outline"
onClick={() => {
setModalOpen(false);
resetGithubState();
}}
>
{t('common.cancel')}
</Button>
<Button
onClick={fetchGithubReleases}
disabled={!githubURL.trim() || fetchingReleases}
>
{fetchingReleases
? t('plugins.loading')
: t('common.confirm')}
</Button>
</>
)}
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<> <>
<Button variant="outline" onClick={() => setModalOpen(false)}> <Button variant="outline" onClick={() => setModalOpen(false)}>
{t('common.cancel')} {t('common.cancel')}

View File

@@ -454,9 +454,52 @@ export class BackendClient extends BaseHttpClient {
} }
public installPluginFromGithub( public installPluginFromGithub(
source: string, assetUrl: string,
owner: string,
repo: string,
releaseTag: string,
): Promise<AsyncTaskCreatedResp> { ): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/plugins/install/github', { source }); return this.post('/api/v1/plugins/install/github', {
asset_url: assetUrl,
owner,
repo,
release_tag: releaseTag,
});
}
public getGithubReleases(repoUrl: string): Promise<{
releases: Array<{
id: number;
tag_name: string;
name: string;
published_at: string;
prerelease: boolean;
draft: boolean;
}>;
owner: string;
repo: string;
}> {
return this.post('/api/v1/plugins/github/releases', { repo_url: repoUrl });
}
public getGithubReleaseAssets(
owner: string,
repo: string,
releaseId: number,
): Promise<{
assets: Array<{
id: number;
name: string;
size: number;
download_url: string;
content_type: string;
}>;
}> {
return this.post('/api/v1/plugins/github/release-assets', {
owner,
repo,
release_id: releaseId,
});
} }
public installPluginFromLocal(file: File): Promise<AsyncTaskCreatedResp> { public installPluginFromLocal(file: File): Promise<AsyncTaskCreatedResp> {

View File

@@ -242,6 +242,26 @@ const enUS = {
saveConfigSuccessDebugPlugin: saveConfigSuccessDebugPlugin:
'Configuration saved successfully, please manually restart the plugin', 'Configuration saved successfully, please manually restart the plugin',
saveConfigError: 'Configuration save failed: ', saveConfigError: 'Configuration save failed: ',
installFromGithub: 'From GitHub',
enterRepoUrl: 'Enter GitHub repository URL',
repoUrlPlaceholder: 'e.g., https://github.com/owner/repo',
fetchingReleases: 'Fetching releases...',
selectRelease: 'Select Release',
noReleasesFound: 'No releases found',
fetchReleasesError: 'Failed to fetch releases: ',
selectAsset: 'Select file to install',
noAssetsFound: 'No .lbpkg files available in this release',
fetchAssetsError: 'Failed to fetch assets: ',
backToReleases: 'Back to releases',
backToRepoUrl: 'Back to repository URL',
backToAssets: 'Back to assets',
releaseTag: 'Tag: {{tag}}',
releaseName: 'Name: {{name}}',
publishedAt: 'Published at: {{date}}',
prerelease: 'Pre-release',
assetSize: 'Size: {{size}}',
confirmInstall: 'Confirm Install',
installFromGithubDesc: 'Install plugin from GitHub Release',
}, },
market: { market: {
searchPlaceholder: 'Search plugins...', searchPlaceholder: 'Search plugins...',

View File

@@ -242,6 +242,26 @@ const jaJP = {
saveConfigSuccessDebugPlugin: saveConfigSuccessDebugPlugin:
'設定を保存しました。手動でプラグインを再起動してください', '設定を保存しました。手動でプラグインを再起動してください',
saveConfigError: '設定の保存に失敗しました:', saveConfigError: '設定の保存に失敗しました:',
installFromGithub: 'GitHubから',
enterRepoUrl: 'GitHubリポジトリのURLを入力してください',
repoUrlPlaceholder: '例: https://github.com/owner/repo',
fetchingReleases: 'リリース一覧を取得中...',
selectRelease: 'リリースを選択',
noReleasesFound: 'リリースが見つかりません',
fetchReleasesError: 'リリース一覧の取得に失敗しました:',
selectAsset: 'インストールするファイルを選択',
noAssetsFound: 'このリリースには利用可能な .lbpkg ファイルがありません',
fetchAssetsError: 'ファイル一覧の取得に失敗しました:',
backToReleases: 'リリース一覧に戻る',
backToRepoUrl: 'リポジトリURLに戻る',
backToAssets: 'ファイル選択に戻る',
releaseTag: 'タグ: {{tag}}',
releaseName: '名前: {{name}}',
publishedAt: '公開日: {{date}}',
prerelease: 'プレリリース',
assetSize: 'サイズ: {{size}}',
confirmInstall: 'インストールを確認',
installFromGithubDesc: 'GitHubリリースからプラグインをインストール',
}, },
market: { market: {
searchPlaceholder: 'プラグインを検索...', searchPlaceholder: 'プラグインを検索...',

View File

@@ -230,6 +230,26 @@ const zhHans = {
saveConfigSuccessNormal: '保存配置成功', saveConfigSuccessNormal: '保存配置成功',
saveConfigSuccessDebugPlugin: '保存配置成功,请手动重启插件', saveConfigSuccessDebugPlugin: '保存配置成功,请手动重启插件',
saveConfigError: '保存配置失败:', saveConfigError: '保存配置失败:',
installFromGithub: '来自 GitHub',
enterRepoUrl: '请输入 GitHub 仓库地址',
repoUrlPlaceholder: '例如: https://github.com/owner/repo',
fetchingReleases: '正在获取 Release 列表...',
selectRelease: '选择 Release',
noReleasesFound: '未找到 Release',
fetchReleasesError: '获取 Release 列表失败:',
selectAsset: '选择要安装的文件',
noAssetsFound: '该 Release 没有可用的 .lbpkg 文件',
fetchAssetsError: '获取文件列表失败:',
backToReleases: '返回 Release 列表',
backToRepoUrl: '返回仓库地址',
backToAssets: '返回文件选择',
releaseTag: 'Tag: {{tag}}',
releaseName: '名称: {{name}}',
publishedAt: '发布于: {{date}}',
prerelease: '预发布',
assetSize: '大小: {{size}}',
confirmInstall: '确认安装',
installFromGithubDesc: '从 GitHub Release 安装插件',
}, },
market: { market: {
searchPlaceholder: '搜索插件...', searchPlaceholder: '搜索插件...',

View File

@@ -156,7 +156,7 @@ const zhHant = {
marketplace: 'Marketplace', marketplace: 'Marketplace',
arrange: '編排', arrange: '編排',
install: '安裝', install: '安裝',
installFromGithub: ' GitHub 安裝外掛', installFromGithub: '來自 GitHub',
onlySupportGithub: '目前僅支援從 GitHub 安裝', onlySupportGithub: '目前僅支援從 GitHub 安裝',
enterGithubLink: '請輸入外掛的Github連結', enterGithubLink: '請輸入外掛的Github連結',
installing: '正在安裝外掛...', installing: '正在安裝外掛...',
@@ -229,6 +229,25 @@ const zhHant = {
saveConfigSuccessNormal: '儲存配置成功', saveConfigSuccessNormal: '儲存配置成功',
saveConfigSuccessDebugPlugin: '儲存配置成功,請手動重啟插件', saveConfigSuccessDebugPlugin: '儲存配置成功,請手動重啟插件',
saveConfigError: '儲存配置失敗:', saveConfigError: '儲存配置失敗:',
enterRepoUrl: '請輸入 GitHub 倉庫地址',
repoUrlPlaceholder: '例如: https://github.com/owner/repo',
fetchingReleases: '正在獲取 Release 列表...',
selectRelease: '選擇 Release',
noReleasesFound: '未找到 Release',
fetchReleasesError: '獲取 Release 列表失敗:',
selectAsset: '選擇要安裝的文件',
noAssetsFound: '該 Release 沒有可用的 .lbpkg 文件',
fetchAssetsError: '獲取文件列表失敗:',
backToReleases: '返回 Release 列表',
backToRepoUrl: '返回倉庫地址',
backToAssets: '返回文件選擇',
releaseTag: 'Tag: {{tag}}',
releaseName: '名稱: {{name}}',
publishedAt: '發佈於: {{date}}',
prerelease: '預發佈',
assetSize: '大小: {{size}}',
confirmInstall: '確認安裝',
installFromGithubDesc: '從 GitHub Release 安裝插件',
}, },
market: { market: {
searchPlaceholder: '搜尋插件...', searchPlaceholder: '搜尋插件...',