mirror of
https://github.com/langbot-app/LangBot.git
synced 2025-11-25 03:15:06 +08:00
feat: add supports for install plugin from GitHub repo releases
Add GitHub release installation for plugins
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>(PluginInstallStatus.WAIT_INPUT);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
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 [pluginSystemStatus, setPluginSystemStatus] =
|
||||
useState<ApiRespPluginSystemStatus | null>(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<PluginInstalledComponentRef>(null);
|
||||
|
||||
function handleModalConfirm() {
|
||||
installPlugin(installSource, installInfo as Record<string, unknown>);
|
||||
function resetGithubState() {
|
||||
setGithubURL('');
|
||||
setGithubReleases([]);
|
||||
setSelectedRelease(null);
|
||||
setGithubAssets([]);
|
||||
setSelectedAsset(null);
|
||||
setGithubOwner('');
|
||||
setGithubRepo('');
|
||||
setFetchingReleases(false);
|
||||
setFetchingAssets(false);
|
||||
}
|
||||
|
||||
const installPlugin = useCallback(
|
||||
(installSource: string, installInfo: Record<string, unknown>) => {
|
||||
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<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 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 && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -367,6 +499,22 @@ export default function PluginConfigPage() {
|
||||
{t('plugins.marketplace')}
|
||||
</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>
|
||||
@@ -402,49 +550,247 @@ export default function PluginConfigPage() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="w-[500px] p-6 bg-white dark:bg-[#1a1a1e]">
|
||||
<Dialog
|
||||
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>
|
||||
<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>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.onlySupportGithub')}</p>
|
||||
<Input
|
||||
placeholder={t('plugins.enterGithubLink')}
|
||||
value={githubURL}
|
||||
onChange={(e) => setGithubURL(e.target.value)}
|
||||
className="mb-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{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 Flow */}
|
||||
{installSource === 'github' &&
|
||||
pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.enterRepoUrl')}</p>
|
||||
<Input
|
||||
placeholder={t('plugins.repoUrlPlaceholder')}
|
||||
value={githubURL}
|
||||
onChange={(e) => setGithubURL(e.target.value)}
|
||||
className="mb-4"
|
||||
/>
|
||||
{fetchingReleases && (
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('plugins.fetchingReleases')}
|
||||
</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 && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.installing')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.installFailed')}</p>
|
||||
<p className="mb-2 text-red-500">{installError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{(pluginInstallStatus === PluginInstallStatus.WAIT_INPUT ||
|
||||
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM) && (
|
||||
{pluginInstallStatus === PluginInstallStatus.WAIT_INPUT &&
|
||||
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)}>
|
||||
{t('common.cancel')}
|
||||
|
||||
@@ -454,9 +454,52 @@ export class BackendClient extends BaseHttpClient {
|
||||
}
|
||||
|
||||
public installPluginFromGithub(
|
||||
source: string,
|
||||
assetUrl: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
releaseTag: string,
|
||||
): 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> {
|
||||
|
||||
@@ -242,6 +242,26 @@ const enUS = {
|
||||
saveConfigSuccessDebugPlugin:
|
||||
'Configuration saved successfully, please manually restart the plugin',
|
||||
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: {
|
||||
searchPlaceholder: 'Search plugins...',
|
||||
|
||||
@@ -242,6 +242,26 @@ const jaJP = {
|
||||
saveConfigSuccessDebugPlugin:
|
||||
'設定を保存しました。手動でプラグインを再起動してください',
|
||||
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: {
|
||||
searchPlaceholder: 'プラグインを検索...',
|
||||
|
||||
@@ -230,6 +230,26 @@ const zhHans = {
|
||||
saveConfigSuccessNormal: '保存配置成功',
|
||||
saveConfigSuccessDebugPlugin: '保存配置成功,请手动重启插件',
|
||||
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: {
|
||||
searchPlaceholder: '搜索插件...',
|
||||
|
||||
@@ -156,7 +156,7 @@ const zhHant = {
|
||||
marketplace: 'Marketplace',
|
||||
arrange: '編排',
|
||||
install: '安裝',
|
||||
installFromGithub: '從 GitHub 安裝外掛',
|
||||
installFromGithub: '來自 GitHub',
|
||||
onlySupportGithub: '目前僅支援從 GitHub 安裝',
|
||||
enterGithubLink: '請輸入外掛的Github連結',
|
||||
installing: '正在安裝外掛...',
|
||||
@@ -229,6 +229,25 @@ const zhHant = {
|
||||
saveConfigSuccessNormal: '儲存配置成功',
|
||||
saveConfigSuccessDebugPlugin: '儲存配置成功,請手動重啟插件',
|
||||
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: {
|
||||
searchPlaceholder: '搜尋插件...',
|
||||
|
||||
Reference in New Issue
Block a user