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

@@ -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')}

View File

@@ -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> {

View File

@@ -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...',

View File

@@ -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: 'プラグインを検索...',

View File

@@ -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: '搜索插件...',

View File

@@ -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: '搜尋插件...',