feat: polish extension import flow

This commit is contained in:
Junyan Qin
2026-05-18 23:32:56 +08:00
parent 9e62227104
commit 747ea069aa
27 changed files with 919 additions and 2481 deletions

View File

@@ -1,11 +1,14 @@
from __future__ import annotations
import base64
import io
import quart
import re
import httpx
import uuid
import os
import zipfile
import yaml
from urllib.parse import urlparse
import posixpath
@@ -42,6 +45,60 @@ def _normalize_plugin_asset_path(filepath: str) -> str | None:
@group.group_class('plugins', '/api/v1/plugins')
class PluginsRouterGroup(group.RouterGroup):
@staticmethod
def _normalize_archive_path(path: str) -> str:
normalized = str(path or '').replace('\\', '/').strip('/')
return posixpath.normpath(normalized) if normalized else ''
@classmethod
def _component_source_path(cls, entry) -> str:
if isinstance(entry, dict):
return cls._normalize_archive_path(entry.get('path') or '')
return cls._normalize_archive_path(str(entry or ''))
@classmethod
def _count_component_configs(cls, component_config, archive_names: list[str]) -> int:
normalized_names = [cls._normalize_archive_path(name) for name in archive_names]
component_files: set[str] = set()
if isinstance(component_config, list):
return len(component_config)
if not isinstance(component_config, dict):
return 1 if component_config else 0
for entry in component_config.get('fromFiles') or []:
source_path = cls._component_source_path(entry)
if source_path and source_path in normalized_names:
component_files.add(source_path)
for entry in component_config.get('fromDirs') or []:
source_dir = cls._component_source_path(entry).rstrip('/')
if not source_dir:
continue
prefix = f'{source_dir}/'
for archive_name in normalized_names:
if not archive_name.startswith(prefix):
continue
if archive_name.lower().endswith(('.yaml', '.yml')):
component_files.add(archive_name)
if component_files:
return len(component_files)
return 1 if any(key in component_config for key in ('path', 'name', 'kind')) else 0
@classmethod
def _count_plugin_components(cls, components, archive_names: list[str]) -> dict[str, int]:
if not isinstance(components, dict):
return {}
component_counts: dict[str, int] = {}
for kind, component_config in components.items():
count = cls._count_component_configs(component_config, archive_names)
if count > 0:
component_counts[str(kind)] = count
return component_counts
@staticmethod
def _parse_github_repo_url(repo_url: str) -> dict | None:
raw_url = str(repo_url or '').strip()
@@ -490,6 +547,62 @@ class PluginsRouterGroup(group.RouterGroup):
return self.success(data={'task_id': wrapper.id})
@self.route('/install/local/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
file = (await quart.request.files).get('file')
if file is None:
return self.http_status(400, -1, 'file is required')
file_bytes = file.read()
try:
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
names = [name for name in zf.namelist() if not name.endswith('/')]
manifest_name = next(
(
name
for name in names
if name.replace('\\', '/').strip('/').lower() in ('manifest.yaml', 'manifest.yml')
),
None,
)
if manifest_name is None:
return self.http_status(400, -1, 'manifest.yaml is required')
manifest = yaml.safe_load(zf.read(manifest_name).decode('utf-8')) or {}
requirements: list[str] = []
requirements_name = next(
(name for name in names if name.replace('\\', '/').strip('/').lower() == 'requirements.txt'),
None,
)
if requirements_name is not None:
requirements = [
line.strip()
for line in zf.read(requirements_name).decode('utf-8', errors='ignore').splitlines()
if line.strip() and not line.strip().startswith('#')
]
spec = manifest.get('spec') or {}
components = spec.get('components') or {}
component_counts = self._count_plugin_components(components, names)
component_types = list(component_counts.keys())
return self.success(
data={
'filename': file.filename or 'local plugin',
'size': len(file_bytes),
'manifest': manifest,
'metadata': manifest.get('metadata') or {},
'component_types': component_types,
'component_counts': component_counts,
'requirements': requirements,
'file_count': len(names),
}
)
except zipfile.BadZipFile:
return self.http_status(400, -1, 'invalid .lbpkg file')
except Exception as exc:
return self.http_status(500, -1, f'Failed to preview plugin package: {exc}')
@self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""Upload a file for plugin configuration"""

View File

@@ -22,8 +22,6 @@ import {
BookOpen,
FileArchive,
Loader2,
CheckCircle2,
XCircle,
CircleHelp,
} from 'lucide-react';
import { Input } from '@/components/ui/input';
@@ -33,7 +31,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -46,8 +44,8 @@ import type {
MCPFormDraft,
MCPFormHandle,
} from '@/app/home/mcp/components/mcp-form/MCPForm';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel';
import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel';
type PopoverView = 'menu' | 'mcp' | 'github';
@@ -151,6 +149,7 @@ export default function AddExtensionPage() {
function AddExtensionContent() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
const {
addTask,
@@ -170,11 +169,12 @@ function AddExtensionContent() {
const [popoverOpen, setPopoverOpen] = useState(false);
const [popoverView, setPopoverView] = useState<PopoverView>('menu');
const [isDragOver, setIsDragOver] = useState(false);
const [skillUploadState, setSkillUploadState] = useState<
'idle' | 'uploading' | 'done' | 'error'
>('idle');
const [skillUploadFileName, setSkillUploadFileName] = useState('');
const [skillUploadError, setSkillUploadError] = useState<string | null>(null);
const [skillUploadPreviewOpen, setSkillUploadPreviewOpen] = useState(false);
const [skillUploadPreviewFile, setSkillUploadPreviewFile] =
useState<File | null>(null);
const [pluginUploadPreviewOpen, setPluginUploadPreviewOpen] = useState(false);
const [pluginUploadPreviewFile, setPluginUploadPreviewFile] =
useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const mcpFormRef = useRef<MCPFormHandle>(null);
const [mcpTesting, setMcpTesting] = useState(false);
@@ -209,6 +209,21 @@ function AddExtensionContent() {
clearCompletedTasks();
}, [clearCompletedTasks]);
useEffect(() => {
if (searchParams.get('manual') !== '1') return;
setPopoverView('menu');
setPopoverOpen(true);
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.delete('manual');
return next;
},
{ replace: true },
);
}, [searchParams, setSearchParams]);
useEffect(() => {
const onComplete = (_taskId: number, success: boolean) => {
if (success) {
@@ -282,49 +297,20 @@ function AddExtensionContent() {
}
const extType = getExtensionTypeFromFile(file);
const fileName = file.name;
const fileSize = file.size;
setPopoverOpen(false);
// Clear any selected task to avoid showing stale dialogs
setSelectedTaskId(null);
if (extType === 'plugin') {
httpClient
.installPluginFromLocal(file)
.then((resp) => {
const taskId = resp.task_id;
const taskKey = `local-${taskId}`;
addTask({
taskId,
pluginName: fileName,
source: 'local',
extensionType: 'plugin',
fileSize,
});
setSelectedTaskId(taskKey);
})
.catch((err: { msg?: string }) => {
toast.error(t('plugins.installFailed') + (err.msg || ''));
});
setPluginUploadPreviewFile(file);
setPluginUploadPreviewOpen(true);
} else {
setSkillUploadFileName(fileName);
setSkillUploadState('uploading');
setSkillUploadError(null);
httpClient
.installSkillFromUpload(file)
.then(() => {
setSkillUploadState('done');
refreshPlugins();
refreshSkills();
})
.catch((err: { msg?: string }) => {
setSkillUploadState('error');
setSkillUploadError(err.msg || null);
});
setSkillUploadPreviewFile(file);
setSkillUploadPreviewOpen(true);
}
},
[t, addTask, setSelectedTaskId, refreshPlugins, refreshSkills],
[t, setSelectedTaskId],
);
const handleFileSelect = useCallback(() => {
@@ -1211,170 +1197,76 @@ function AddExtensionContent() {
</DialogContent>
</Dialog>
{/* Skill Upload Progress Dialog */}
{/* Plugin Upload Preview Dialog */}
<Dialog
open={skillUploadState !== 'idle'}
open={pluginUploadPreviewOpen}
onOpenChange={(open) => {
if (!open && skillUploadState !== 'uploading') {
setSkillUploadState('idle');
setSkillUploadError(null);
setPluginUploadPreviewOpen(open);
if (!open) {
setPluginUploadPreviewFile(null);
}
}}
>
<DialogContent
className="sm:max-w-lg w-[90vw] max-h-[80vh] p-4 sm:p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto overflow-x-hidden"
hideCloseButton={skillUploadState === 'uploading'}
>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-xl max-h-[85vh] overflow-y-auto p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="flex items-start gap-3">
<Download className="size-5 shrink-0 mt-0.5" />
<span className="break-words">
{t('plugins.installProgress.title', {
name: skillUploadFileName,
})}
</span>
<DialogTitle className="flex items-center gap-2">
<FileArchive className="size-5" />
<span>{t('plugins.localPreview.title')}</span>
</DialogTitle>
</DialogHeader>
{pluginUploadPreviewFile && (
<PluginLocalPreviewPanel
file={pluginUploadPreviewFile}
onCancel={() => {
setPluginUploadPreviewOpen(false);
setPluginUploadPreviewFile(null);
}}
onInstallStarted={() => {
setPluginUploadPreviewOpen(false);
setPluginUploadPreviewFile(null);
}}
/>
)}
</DialogContent>
</Dialog>
<div className="space-y-4">
{/* Overall progress bar */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<span
className={cn(
'text-sm font-medium shrink-0',
skillUploadState === 'done'
? 'text-green-700 dark:text-green-300'
: skillUploadState === 'error'
? 'text-red-700 dark:text-red-300'
: 'text-blue-700 dark:text-blue-300',
)}
>
{skillUploadState === 'done'
? t('plugins.installProgress.completed')
: skillUploadState === 'error'
? t('plugins.installProgress.failed')
: t('plugins.installProgress.overallProgress')}
</span>
<span
className={cn(
'text-sm font-medium',
skillUploadState === 'done'
? 'text-green-600 dark:text-green-400'
: skillUploadState === 'error'
? 'text-red-600 dark:text-red-400'
: 'text-blue-600 dark:text-blue-400',
)}
>
{skillUploadState === 'done'
? '100%'
: skillUploadState === 'error'
? '0%'
: '50%'}
</span>
</div>
<Progress
value={
skillUploadState === 'done'
? 100
: skillUploadState === 'error'
? 0
: 50
}
className={cn(
'h-2.5',
'[&>div]:bg-blue-500 dark:[&>div]:bg-blue-400',
'bg-blue-100 dark:bg-blue-900/30',
skillUploadState === 'done' &&
'[&>div]:bg-green-500 dark:[&>div]:bg-green-400 bg-green-100 dark:bg-green-900/30',
skillUploadState === 'error' &&
'[&>div]:bg-red-500 dark:[&>div]:bg-red-400 bg-red-100 dark:bg-red-900/30',
)}
/>
</div>
{/* Stage display */}
<div className="space-y-1.5">
{skillUploadState === 'uploading' && (
<div className="flex items-center gap-2 sm:gap-3 px-2 sm:px-3 py-2 sm:py-2.5 rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800">
<div className="flex items-center justify-center w-7 h-7 rounded-full shrink-0 bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
{t('plugins.installProgress.downloading')}
</span>
</div>
</div>
)}
{skillUploadState === 'done' && (
<div className="flex items-center gap-2 sm:gap-3 px-2 sm:px-3 py-2 sm:py-2.5 rounded-lg bg-green-50/50 dark:bg-green-950/15 border border-green-100 dark:border-green-900/50">
<div className="flex items-center justify-center w-7 h-7 rounded-full shrink-0 bg-green-100 dark:bg-green-900/40 text-green-600 dark:text-green-400">
<CheckCircle2 className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-green-600 dark:text-green-400">
{t('plugins.installProgress.downloading')}
</span>
</div>
</div>
)}
{skillUploadState === 'error' && (
<div className="flex items-center gap-2 sm:gap-3 px-2 sm:px-3 py-2 sm:py-2.5 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900">
<div className="flex items-center justify-center w-7 h-7 rounded-full shrink-0 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400">
<XCircle className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-red-600 dark:text-red-400">
{t('plugins.installProgress.downloading')}
</span>
</div>
</div>
)}
</div>
{/* Done banner */}
{skillUploadState === 'done' && (
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900">
<CheckCircle2 className="w-5 h-5 shrink-0 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-700 dark:text-green-300 font-medium break-words">
{t('plugins.installProgress.installComplete')}
</span>
</div>
)}
{/* Error detail */}
{skillUploadState === 'error' && skillUploadError && (
<div className="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900">
<p className="text-xs text-red-600 dark:text-red-400 break-all line-clamp-4">
{skillUploadError}
</p>
</div>
)}
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="default"
size="sm"
onClick={() => {
if (
skillUploadState === 'done' ||
skillUploadState === 'error'
) {
setSkillUploadState('idle');
setSkillUploadError(null);
{/* Skill Upload Preview Dialog */}
<Dialog
open={skillUploadPreviewOpen}
onOpenChange={(open) => {
setSkillUploadPreviewOpen(open);
if (!open) {
setSkillUploadPreviewFile(null);
}
}}
>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-3xl max-h-[85vh] overflow-y-auto p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileArchive className="size-5" />
<span>{t('skills.uploadZip')}</span>
</DialogTitle>
</DialogHeader>
{skillUploadPreviewFile && (
<SkillZipPreviewPanel
file={skillUploadPreviewFile}
onCancel={() => {
setSkillUploadPreviewOpen(false);
setSkillUploadPreviewFile(null);
}}
onImported={(skillNames) => {
setSkillUploadPreviewOpen(false);
setSkillUploadPreviewFile(null);
void refreshSkills();
const firstSkillName = skillNames[0];
if (firstSkillName) {
navigate(
`/home/skills?id=${encodeURIComponent(firstSkillName)}`,
);
}
}}
disabled={skillUploadState === 'uploading'}
>
{skillUploadState === 'done' || skillUploadState === 'error'
? t('common.close')
: t('plugins.installProgress.background')}
</Button>
</div>
/>
)}
</DialogContent>
</Dialog>
</>

View File

@@ -1,586 +0,0 @@
import { useEffect, useState, useRef } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Github, Upload as UploadIcon, ChevronLeft } from 'lucide-react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
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;
source_type?: 'release' | 'tag' | 'branch';
archive_url?: string;
}
interface GithubAsset {
id: number;
name: string;
size: number;
download_url: string;
content_type: string;
}
export default function AddPluginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const actionParam = searchParams.get('action');
const { refreshPlugins } = useSidebarData();
const {
addTask,
setSelectedTaskId,
registerOnTaskComplete,
unregisterOnTaskComplete,
} = usePluginInstallTasks();
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 [installError, setInstallError] = useState<string | null>(null);
const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
const fileInputRef = useRef<HTMLInputElement>(null);
const isGithubMode = actionParam === 'github';
const isUploadMode = actionParam === 'upload';
useEffect(() => {
const onComplete = (_taskId: number, success: boolean) => {
if (success) {
toast.success(t('plugins.installSuccess'));
refreshPlugins();
}
};
registerOnTaskComplete(onComplete);
return () => {
unregisterOnTaskComplete(onComplete);
};
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
async function checkExtensionsLimit(): Promise<boolean> {
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
if (maxExtensions < 0) return true;
try {
const [pluginsResp, mcpResp] = await Promise.all([
httpClient.getPlugins(),
httpClient.getMCPServers(),
]);
const total =
(pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);
if (total >= maxExtensions) {
toast.error(
t('limitation.maxExtensionsReached', { max: maxExtensions }),
);
return false;
}
} catch {
// If we can't check, let backend handle it
}
return true;
}
function resetGithubState() {
setGithubURL('');
setGithubReleases([]);
setSelectedRelease(null);
setGithubAssets([]);
setSelectedAsset(null);
setGithubOwner('');
setGithubRepo('');
setFetchingReleases(false);
setFetchingAssets(false);
}
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);
}
} catch (error: unknown) {
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,
release.tag_name,
release.source_type,
release.archive_url,
);
setGithubAssets(result.assets);
if (result.assets.length === 0) {
toast.warning(t('plugins.noAssetsFound'));
} else {
setPluginInstallStatus(PluginInstallStatus.SELECT_ASSET);
}
} catch (error: unknown) {
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 handleGithubConfirm() {
if (!selectedAsset || !selectedRelease) return;
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
httpClient
.installPluginFromGithub(
selectedAsset.download_url,
githubOwner,
githubRepo,
selectedRelease.tag_name,
)
.then((resp) => {
const taskId = resp.task_id;
const taskKey = `github-${taskId}`;
addTask({
taskId,
pluginName: pluginDisplayName,
source: 'github',
extensionType: 'plugin',
fileSize: selectedAsset.size,
});
setSelectedTaskId(taskKey);
resetGithubState();
toast.success(t('plugins.installSuccess'));
navigate('/home/add-extension');
})
.catch((err) => {
setInstallError(err.msg);
setPluginInstallStatus(PluginInstallStatus.ERROR);
});
}
// Local file upload
const validateFileType = (file: File): boolean => {
const allowedExtensions = ['.lbpkg'];
const fileName = file.name.toLowerCase();
return allowedExtensions.some((ext) => fileName.endsWith(ext));
};
const uploadPluginFile = async (file: File) => {
if (!validateFileType(file)) {
toast.error(t('plugins.unsupportedFileType'));
return;
}
if (!(await checkExtensionsLimit())) return;
const fileName = file.name || 'local plugin';
const fileSize = file.size;
setInstallError(null);
httpClient
.installPluginFromLocal(file)
.then((resp) => {
const taskId = resp.task_id;
const taskKey = `local-${taskId}`;
addTask({
taskId,
pluginName: fileName,
source: 'local',
extensionType: 'plugin',
fileSize: fileSize,
});
setSelectedTaskId(taskKey);
toast.success(t('plugins.installSuccess'));
navigate('/home/add-extension');
})
.catch((err) => {
toast.error(t('plugins.installFailed') + (err.msg || ''));
});
};
const handleFileSelect = async () => {
if (!(await checkExtensionsLimit())) return;
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
uploadPluginFile(file);
}
event.target.value = '';
};
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 handleCancel() {
navigate('/home/add-extension');
}
// GitHub Install View
if (isGithubMode) {
return (
<div className="h-full flex flex-col">
<input
ref={fileInputRef}
type="file"
accept=".lbpkg"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('plugins.installFromGithub')}</h1>
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}
</Button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Github className="size-5" />
{t('plugins.installFromGithub')}
</CardTitle>
<CardDescription>
{t('plugins.installFromGithubDesc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
<div>
<p className="mb-2 text-sm">{t('plugins.enterRepoUrl')}</p>
<div className="flex gap-2">
<Input
placeholder={t('plugins.repoUrlPlaceholder')}
value={githubURL}
onChange={(e) => setGithubURL(e.target.value)}
/>
<Button
onClick={fetchGithubReleases}
disabled={!githubURL.trim() || fetchingReleases}
>
{fetchingReleases
? t('plugins.loading')
: t('common.confirm')}
</Button>
</div>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-sm">
{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,
})}{' '}
&bull;{' '}
{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-muted-foreground mt-4">
{t('plugins.loading')}
</p>
)}
</div>
)}
{pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-sm">
{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-3 p-2 bg-muted rounded">
<div className="text-sm font-medium">
{selectedRelease.name || selectedRelease.tag_name}
</div>
<div className="text-xs text-muted-foreground">
{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>
)}
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-sm">
{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-muted 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 className="flex justify-end mt-4">
<Button onClick={handleGithubConfirm}>
{t('common.confirm')}
</Button>
</div>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
<div>
<p className="text-sm">{t('plugins.installing')}</p>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<div>
<p className="text-sm mb-1">{t('plugins.installFailed')}</p>
<p className="text-sm text-destructive">{installError}</p>
<div className="flex justify-end mt-4">
<Button variant="default" onClick={handleCancel}>
{t('common.close')}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
// Upload Mode - show file select dialog
if (isUploadMode) {
return (
<div className="h-full flex flex-col">
<input
ref={fileInputRef}
type="file"
accept=".lbpkg"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('plugins.uploadLocal')}</h1>
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}
</Button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UploadIcon className="size-5" />
{t('plugins.uploadLocal')}
</CardTitle>
<CardDescription>
{t('plugins.uploadPluginOnly')}
</CardDescription>
</CardHeader>
<CardContent>
<div
className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-8 text-center cursor-pointer hover:border-blue-500 transition-colors"
onClick={handleFileSelect}
>
<UploadIcon className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<p className="text-gray-600 dark:text-gray-300">
{t('plugins.dragToUpload')}
</p>
<p className="text-sm text-gray-500 mt-2">
{t('plugins.selectFileToUpload')}
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
// Default: redirect to add-extension
navigate('/home/add-extension');
return null;
}

View File

@@ -316,8 +316,6 @@ function NavItems({
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const sidebarData = useSidebarData();
const { setPendingPluginInstallAction, setPendingSkillInstallAction } =
sidebarData;
const { state: sidebarState, isMobile } = useSidebar();
const { t } = useTranslation();
// Track which entity categories have their full list expanded
@@ -814,7 +812,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/market');
navigate('/home/add-extension');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -828,8 +826,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('local');
navigate('/home/extensions');
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -842,8 +839,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
navigate('/home/extensions');
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -869,8 +865,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingSkillInstallAction('create');
navigate('/home/skills');
navigate('/home/skills?action=create');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -883,8 +878,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingSkillInstallAction('upload');
navigate('/home/skills');
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -897,8 +891,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingSkillInstallAction('github');
navigate('/home/skills');
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
@@ -992,7 +985,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/market');
navigate('/home/add-extension');
}}
>
<Store className="size-4" />
@@ -1002,8 +995,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('local');
navigate('/home/extensions');
navigate('/home/add-extension?manual=1');
}}
>
<Upload className="size-4" />
@@ -1012,8 +1004,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
navigate('/home/extensions');
navigate('/home/add-extension?manual=1');
}}
>
<Github className="size-4" />
@@ -1036,8 +1027,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingSkillInstallAction('create');
navigate('/home/skills');
navigate('/home/skills?action=create');
}}
>
<FilePlus2 className="size-4" />
@@ -1046,8 +1036,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingSkillInstallAction('upload');
navigate('/home/skills');
navigate('/home/add-extension?manual=1');
}}
>
<Upload className="size-4" />
@@ -1056,8 +1045,7 @@ function NavItems({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingSkillInstallAction('github');
navigate('/home/skills');
navigate('/home/add-extension?manual=1');
}}
>
<Github className="size-4" />
@@ -1472,7 +1460,10 @@ function findSidebarChildForPath(pathname: string): SidebarChildVO | undefined {
);
}
if (pathname === '/home/market' || pathname.startsWith('/home/market/')) {
if (
pathname === '/home/add-extension' ||
pathname.startsWith('/home/add-extension/')
) {
return sidebarConfigList.find(
(childConfig) => childConfig.id === 'add-extension',
);

View File

@@ -30,10 +30,6 @@ export interface SidebarEntityItem {
extensionType?: 'plugin' | 'mcp' | 'skill';
}
// Install action types that can be triggered from sidebar
export type PluginInstallAction = 'local' | 'github' | null;
export type SkillInstallAction = 'create' | 'github' | 'upload' | null;
// Plugin page registered by a plugin
export interface PluginPageItem {
id: string; // "author/name/pageId"
@@ -65,12 +61,6 @@ export interface SidebarDataContextValue {
// Breadcrumb: entity name shown when viewing a detail page
detailEntityName: string | null;
setDetailEntityName: (name: string | null) => void;
// Pending plugin install action triggered from sidebar
pendingPluginInstallAction: PluginInstallAction;
setPendingPluginInstallAction: (action: PluginInstallAction) => void;
// Pending skill install action triggered from sidebar
pendingSkillInstallAction: SkillInstallAction;
setPendingSkillInstallAction: (action: SkillInstallAction) => void;
// Whether the extensions list is grouped by type (shared between page and sidebar)
extensionsGroupByType: boolean;
setExtensionsGroupByType: (enabled: boolean) => void;
@@ -91,10 +81,6 @@ export function SidebarDataProvider({
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
const [pendingPluginInstallAction, setPendingPluginInstallAction] =
useState<PluginInstallAction>(null);
const [pendingSkillInstallAction, setPendingSkillInstallAction] =
useState<SkillInstallAction>(null);
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
useState<boolean>(() => {
if (typeof window === 'undefined') return false;
@@ -320,10 +306,6 @@ export function SidebarDataProvider({
refreshAll,
detailEntityName,
setDetailEntityName,
pendingPluginInstallAction,
setPendingPluginInstallAction,
pendingSkillInstallAction,
setPendingSkillInstallAction,
extensionsGroupByType,
setExtensionsGroupByType,
}}

View File

@@ -222,7 +222,7 @@ export default function FileUploadZone({
{t('knowledge.documentsTab.noParserAvailable')}
</p>
<Link
to="/home/market?category=Parser"
to="/home/add-extension"
className="text-sm text-primary hover:underline mt-1 inline-block"
>
{t('knowledge.documentsTab.installParserHint')}

View File

@@ -304,7 +304,7 @@ export default function KBForm({
{t('knowledge.noEnginesAvailable')}
</p>
<Link
to="/home/market?category=KnowledgeEngine"
to="/home/add-extension"
className="text-sm text-primary hover:underline"
>
{t('knowledge.installEngineHint')}

View File

@@ -46,7 +46,7 @@ import {
// Routes that belong to the "Extensions" section
const EXTENSIONS_ROUTES = [
'/home/extensions',
'/home/market',
'/home/add-extension',
'/home/mcp',
'/home/skills',
'/home/plugin-pages',

View File

@@ -1,207 +0,0 @@
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Download } from 'lucide-react';
import React, { useState, useCallback, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
enum PluginInstallStatus {
ASK_CONFIRM = 'ask_confirm',
INSTALLING = 'installing',
ERROR = 'error',
}
export default function MarketplacePage() {
const { t } = useTranslation();
if (!systemInfo?.enable_marketplace) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
<p className="text-muted-foreground">{t('plugins.marketplace')}</p>
</div>
);
}
return <MarketplaceContent />;
}
function MarketplaceContent() {
const { t } = useTranslation();
const { refreshPlugins } = useSidebarData();
const {
addTask,
setSelectedTaskId,
registerOnTaskComplete,
unregisterOnTaskComplete,
} = usePluginInstallTasks();
const [modalOpen, setModalOpen] = useState(false);
const [installInfo, setInstallInfo] = useState<Record<string, string>>({});
const [installExtensionType, setInstallExtensionType] = useState<'plugin' | 'mcp' | 'skill'>('plugin');
const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.ASK_CONFIRM);
const [installError, setInstallError] = useState<string | null>(null);
async function checkExtensionsLimit(): Promise<boolean> {
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
if (maxExtensions < 0) return true;
try {
const [pluginsResp, mcpResp] = await Promise.all([
httpClient.getPlugins(),
httpClient.getMCPServers(),
]);
const total =
(pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);
if (total >= maxExtensions) {
toast.error(
t('limitation.maxExtensionsReached', { max: maxExtensions }),
);
return false;
}
} catch {
// If we can't check, let backend handle it
}
return true;
}
// Register task completion callback for toast and plugin list refresh
useEffect(() => {
const onComplete = (_taskId: number, success: boolean) => {
if (success) {
toast.success(t('plugins.installSuccess'));
refreshPlugins();
}
};
registerOnTaskComplete(onComplete);
return () => {
unregisterOnTaskComplete(onComplete);
};
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
const handleInstallPlugin = useCallback(
async (plugin: PluginV4) => {
if (!(await checkExtensionsLimit())) return;
setInstallInfo({
plugin_author: plugin.author,
plugin_name: plugin.name,
plugin_version: plugin.latest_version,
});
setInstallExtensionType(plugin.type || 'plugin');
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setInstallError(null);
setModalOpen(true);
},
[t],
);
function handleModalConfirm() {
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
httpClient
.installPluginFromMarketplace(
installInfo.plugin_author,
installInfo.plugin_name,
installInfo.plugin_version,
)
.then((resp) => {
const taskId = resp.task_id;
const taskKey = `marketplace-${taskId}`;
addTask({
taskId,
pluginName: pluginDisplayName,
source: 'marketplace',
extensionType: installExtensionType,
});
setSelectedTaskId(taskKey);
setModalOpen(false);
})
.catch((err) => {
setInstallError(err.msg);
setPluginInstallStatus(PluginInstallStatus.ERROR);
});
}
return (
<>
<div className="h-full overflow-y-auto">
<MarketPage installPlugin={handleInstallPlugin} />
</div>
<Dialog
open={modalOpen}
onOpenChange={(open) => {
setModalOpen(open);
if (!open) {
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" />
<span>{t('plugins.installPlugin')}</span>
</DialogTitle>
</DialogHeader>
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div className="mt-4">
<p className="mb-2">
{installInfo.plugin_version
? t('plugins.askConfirm', {
name: installInfo.plugin_name,
version: installInfo.plugin_version,
})
: t('plugins.askConfirmNoVersion', {
name: installInfo.plugin_name,
})}
</p>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
<div className="mt-4">
<p className="mb-2">{t('plugins.installing')}</p>
</div>
)}
{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.ASK_CONFIRM && (
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleModalConfirm}>
{t('common.confirm')}
</Button>
</>
)}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<Button variant="default" onClick={() => setModalOpen(false)}>
{t('common.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,203 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Archive, CheckCircle2, Loader2, Package } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { httpClient } from '@/app/infra/http/HttpClient';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
type PluginLocalPreview = Awaited<
ReturnType<typeof httpClient.previewPluginInstallFromLocal>
>;
interface PluginLocalPreviewPanelProps {
file: File;
onInstallStarted?: () => void;
onCancel?: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
export default function PluginLocalPreviewPanel({
file,
onInstallStarted,
onCancel,
}: PluginLocalPreviewPanelProps) {
const { t } = useTranslation();
const { addTask, setSelectedTaskId } = usePluginInstallTasks();
const [preview, setPreview] = useState<PluginLocalPreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const [installing, setInstalling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const loadPreview = useCallback(async () => {
setPreviewing(true);
setPreview(null);
setErrorMessage(null);
try {
const result = await httpClient.previewPluginInstallFromLocal(file);
setPreview(result);
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('plugins.localPreview.failed'));
} finally {
setPreviewing(false);
}
}, [file, t]);
useEffect(() => {
void loadPreview();
}, [loadPreview]);
async function handleInstall() {
setInstalling(true);
setErrorMessage(null);
try {
const resp = await httpClient.installPluginFromLocal(file);
const taskId = resp.task_id;
const taskKey = `local-${taskId}`;
const pluginName =
preview?.metadata.label && extractI18nObject(preview.metadata.label)
? extractI18nObject(preview.metadata.label)
: preview?.metadata.name || file.name;
addTask({
taskId,
pluginName,
source: 'local',
extensionType: 'plugin',
fileSize: file.size,
});
setSelectedTaskId(taskKey);
toast.success(t('plugins.installSuccess'));
onInstallStarted?.();
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('plugins.installFailed'));
} finally {
setInstalling(false);
}
}
const metadata = preview?.metadata;
const label = metadata?.label ? extractI18nObject(metadata.label) : '';
const description = metadata?.description
? extractI18nObject(metadata.description)
: '';
const componentCounts = preview?.component_counts || {};
return (
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-md bg-muted/40 px-3 py-3">
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground">
{previewing ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Archive className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{previewing
? t('plugins.localPreview.unpacking')
: t('plugins.localPreview.unpackComplete')}
</div>
<div className="mt-1 break-all text-xs text-muted-foreground">
{file.name} · {formatFileSize(file.size)}
</div>
</div>
</div>
{preview && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Package className="size-4" />
{t('plugins.localPreview.pluginInfo')}
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
{t('plugins.localPreview.name')}
</span>
<span className="truncate font-medium">
{label || metadata?.name || '-'}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
{t('plugins.localPreview.author')}
</span>
<span className="truncate">{metadata?.author || '-'}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
{t('plugins.localPreview.version')}
</span>
<span>{metadata?.version || '-'}</span>
</div>
</div>
{description && (
<p className="text-sm leading-6 text-muted-foreground">
{description}
</p>
)}
<div className="flex flex-wrap items-center gap-2 text-sm">
<PluginComponentList
components={componentCounts}
showComponentName
showTitle
useBadge
t={t}
/>
</div>
</div>
)}
{preview && (
<div className="flex items-center gap-2 text-sm text-green-700 dark:text-green-300">
<CheckCircle2 className="size-4" />
{t('plugins.localPreview.ready')}
</div>
)}
{errorMessage && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
<div className="flex justify-end gap-2">
{onCancel && (
<Button variant="outline" onClick={onCancel} disabled={installing}>
{t('common.cancel')}
</Button>
)}
<Button
type="button"
onClick={handleInstall}
disabled={!preview || previewing || installing}
>
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
</Button>
</div>
</div>
);
}

View File

@@ -10,8 +10,6 @@ import { Button } from '@/components/ui/button';
import {
Download,
Package,
Settings,
Rocket,
CheckCircle2,
XCircle,
Loader2,
@@ -39,16 +37,6 @@ const STAGES: {
icon: Package,
i18nKey: 'plugins.installProgress.installingDeps',
},
{
key: InstallStage.INITIALIZING,
icon: Settings,
i18nKey: 'plugins.installProgress.initializing',
},
{
key: InstallStage.LAUNCHING,
icon: Rocket,
i18nKey: 'plugins.installProgress.launching',
},
];
function getStageIndex(stage: InstallStage): number {

View File

@@ -89,8 +89,8 @@ function mapActionToStage(action: string): InstallStage {
if (lower.includes('dependencies') || lower.includes('requirements'))
return InstallStage.INSTALLING_DEPS;
if (lower.includes('initializ') || lower.includes('setting'))
return InstallStage.INITIALIZING;
if (lower.includes('launch')) return InstallStage.LAUNCHING;
return InstallStage.INSTALLING_DEPS;
if (lower.includes('launch')) return InstallStage.INSTALLING_DEPS;
if (lower.includes('installed') || lower.includes('complete'))
return InstallStage.DONE;
return InstallStage.DOWNLOADING;
@@ -104,7 +104,7 @@ function stageToProgress(stage: InstallStage): number {
case InstallStage.DOWNLOADING:
return 10;
case InstallStage.INSTALLING_DEPS:
return 40;
return 70;
case InstallStage.INITIALIZING:
return 70;
case InstallStage.LAUNCHING:
@@ -133,7 +133,11 @@ function extractSourceFromName(
* Check if a backend task name is a plugin install task.
*/
function isPluginInstallTask(name: string): boolean {
return name.startsWith('plugin-install-') || name.startsWith('mcp-install-') || name.startsWith('skill-install-');
return (
name.startsWith('plugin-install-') ||
name.startsWith('mcp-install-') ||
name.startsWith('skill-install-')
);
}
/**
@@ -218,8 +222,9 @@ export function PluginInstallTaskProvider({
// Cleanup all intervals on unmount
useEffect(() => {
const intervals = intervalRefs.current;
return () => {
intervalRefs.current.forEach((interval) => {
intervals.forEach((interval) => {
clearInterval(interval);
});
if (syncIntervalRef.current) clearInterval(syncIntervalRef.current);

View File

@@ -4,8 +4,6 @@ import { Progress } from '@/components/ui/progress';
import {
Download,
Package,
Settings,
Rocket,
CheckCircle2,
XCircle,
Loader2,
@@ -32,8 +30,6 @@ import { cn } from '@/lib/utils';
const STAGE_ICONS: Record<string, React.ElementType> = {
[InstallStage.DOWNLOADING]: Download,
[InstallStage.INSTALLING_DEPS]: Package,
[InstallStage.INITIALIZING]: Settings,
[InstallStage.LAUNCHING]: Rocket,
[InstallStage.DONE]: CheckCircle2,
[InstallStage.ERROR]: XCircle,
};
@@ -99,12 +95,10 @@ function TaskQueueItem({
return t('plugins.installProgress.downloading');
case InstallStage.INSTALLING_DEPS:
return t('plugins.installProgress.installingDeps');
case InstallStage.INITIALIZING:
return t('plugins.installProgress.initializing');
case InstallStage.LAUNCHING:
return t('plugins.installProgress.launching');
case InstallStage.DONE:
return isDone ? getInstallCompleteMessage() : t('plugins.installProgress.completed');
return isDone
? getInstallCompleteMessage()
: t('plugins.installProgress.completed');
case InstallStage.ERROR:
return t('plugins.installProgress.failed');
default:
@@ -140,7 +134,10 @@ function TaskQueueItem({
<div className="text-sm font-medium truncate">{task.pluginName}</div>
<Badge
variant="outline"
className={cn('text-[0.6rem] px-1 py-0 flex-shrink-0', getTypeBadgeClass())}
className={cn(
'text-[0.6rem] px-1 py-0 flex-shrink-0',
getTypeBadgeClass(),
)}
>
<TypeIcon className="w-3 h-3 mr-0.5" />
{getTypeLabel()}

View File

@@ -9,40 +9,21 @@ import { Label } from '@/components/ui/label';
import PluginDetailContent from './PluginDetailContent';
import styles from './plugins.module.css';
import { Button } from '@/components/ui/button';
import {
UploadIcon,
Power,
Github,
ChevronLeft,
Code,
Copy,
Check,
Bug,
Unlink,
} from 'lucide-react';
import { Power, Code, Copy, Check, Bug, Unlink } from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from '@/components/ui/card';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Input } from '@/components/ui/input';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import React, { useState, useRef, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { systemInfo } from '@/app/infra/http/HttpClient';
import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import {
@@ -50,39 +31,10 @@ import {
usePluginInstallTasks,
} from '@/app/home/plugins/components/plugin-install-task';
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;
source_type?: 'release' | 'tag' | 'branch';
archive_url?: string;
}
interface GithubAsset {
id: number;
name: string;
size: number;
download_url: string;
content_type: string;
}
export default function PluginConfigPage() {
const [searchParams] = useSearchParams();
const detailId = searchParams.get('id');
// Show plugin detail view when ?id= query param is present
if (detailId) {
return <PluginDetailContent id={detailId} />;
}
@@ -92,40 +44,16 @@ export default function PluginConfigPage() {
function PluginListView() {
const { t } = useTranslation();
const navigate = useNavigate();
const {
refreshPlugins,
pendingPluginInstallAction,
setPendingPluginInstallAction,
extensionsGroupByType: groupByType,
setExtensionsGroupByType: setGroupByType,
} = useSidebarData();
const {
addTask,
setSelectedTaskId,
registerOnTaskComplete,
unregisterOnTaskComplete,
} = usePluginInstallTasks();
const [showGithubInstall, setShowGithubInstall] = useState(false);
const [installSource, setInstallSource] = useState<string>('local');
const [installInfo] = useState<Record<string, any>>({});
const [pluginInstallStatus, setPluginInstallStatus] =
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 { registerOnTaskComplete, unregisterOnTaskComplete } =
usePluginInstallTasks();
const [pluginSystemStatus, setPluginSystemStatus] =
useState<ApiRespPluginSystemStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const [debugInfo, setDebugInfo] = useState<{
debug_url: string;
plugin_debug_key: string;
@@ -134,8 +62,7 @@ function PluginListView() {
const [copiedDebugUrl, setCopiedDebugUrl] = useState(false);
const [copiedDebugKey, setCopiedDebugKey] = useState(false);
const [filterType, setFilterType] = useState<FilterType>('all');
const groupByType = useSidebarData().extensionsGroupByType;
const setGroupByType = useSidebarData().setExtensionsGroupByType;
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
useEffect(() => {
const fetchPluginSystemStatus = async () => {
@@ -151,19 +78,9 @@ function PluginListView() {
}
};
fetchPluginSystemStatus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
void 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];
}
// Register task completion callback for toast and plugin list refresh
useEffect(() => {
const onComplete = (_taskId: number, success: boolean) => {
if (success) {
@@ -178,285 +95,6 @@ function PluginListView() {
};
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
function resetGithubState() {
setGithubURL('');
setGithubReleases([]);
setSelectedRelease(null);
setGithubAssets([]);
setSelectedAsset(null);
setGithubOwner('');
setGithubRepo('');
setFetchingReleases(false);
setFetchingAssets(false);
}
async function checkExtensionsLimit(): Promise<boolean> {
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
if (maxExtensions < 0) return true;
try {
const [pluginsResp, mcpResp] = await Promise.all([
httpClient.getPlugins(),
httpClient.getMCPServers(),
]);
const total =
(pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);
if (total >= maxExtensions) {
toast.error(
t('limitation.maxExtensionsReached', { max: maxExtensions }),
);
return false;
}
} catch {
// If we can't check, let backend handle it
}
return true;
}
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);
}
} 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,
release.tag_name,
release.source_type,
release.archive_url,
);
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>);
}
}
function installPlugin(
installSource: string,
installInfo: Record<string, any>,
) {
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
if (installSource === 'github') {
const pluginDisplayName = `${installInfo.owner}/${installInfo.repo}`;
const assetSize = selectedAsset?.size;
httpClient
.installPluginFromGithub(
installInfo.asset_url,
installInfo.owner,
installInfo.repo,
installInfo.release_tag,
)
.then((resp) => {
const taskId = resp.task_id;
const taskKey = `github-${taskId}`;
addTask({
taskId,
pluginName: pluginDisplayName,
source: 'github',
extensionType: 'plugin',
fileSize: assetSize,
});
setSelectedTaskId(taskKey);
resetGithubState();
setShowGithubInstall(false);
})
.catch((err) => {
setInstallError(err.msg);
setPluginInstallStatus(PluginInstallStatus.ERROR);
});
} else if (installSource === 'local') {
const fileName = installInfo.file?.name || 'local plugin';
const fileSize = installInfo.file?.size;
httpClient
.installPluginFromLocal(installInfo.file)
.then((resp) => {
const taskId = resp.task_id;
const taskKey = `local-${taskId}`;
addTask({
taskId,
pluginName: fileName,
source: 'local',
extensionType: 'plugin',
fileSize: fileSize,
});
setSelectedTaskId(taskKey);
})
.catch((err) => {
setInstallError(err.msg);
setPluginInstallStatus(PluginInstallStatus.ERROR);
toast.error(t('plugins.installFailed') + (err.msg || ''));
});
}
}
const validateFileType = (file: File): boolean => {
const allowedExtensions = ['.lbpkg', '.zip'];
const fileName = file.name.toLowerCase();
return allowedExtensions.some((ext) => fileName.endsWith(ext));
};
const uploadPluginFile = useCallback(
async (file: File) => {
if (!pluginSystemStatus?.is_enable || !pluginSystemStatus?.is_connected) {
toast.error(t('plugins.pluginSystemNotReady'));
return;
}
if (!validateFileType(file)) {
toast.error(t('plugins.unsupportedFileType'));
return;
}
if (!(await checkExtensionsLimit())) return;
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
setInstallError(null);
installPlugin('local', { file });
},
[t, pluginSystemStatus, installPlugin],
);
const handleFileSelect = useCallback(async () => {
if (!(await checkExtensionsLimit())) return;
if (fileInputRef.current) {
fileInputRef.current.click();
}
}, []);
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
uploadPluginFile(file);
}
event.target.value = '';
},
[uploadPluginFile],
);
const isPluginSystemReady =
pluginSystemStatus?.is_enable && pluginSystemStatus?.is_connected;
const handleDragOver = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
if (isPluginSystemReady) {
setIsDragOver(true);
}
},
[isPluginSystemReady],
);
const handleDragLeave = useCallback((event: React.DragEvent) => {
event.preventDefault();
setIsDragOver(false);
}, []);
const handleDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
setIsDragOver(false);
if (!isPluginSystemReady) {
toast.error(t('plugins.pluginSystemNotReady'));
return;
}
const files = Array.from(event.dataTransfer.files);
if (files.length > 0) {
uploadPluginFile(files[0]);
}
},
[uploadPluginFile, isPluginSystemReady, t],
);
// Auto-trigger install action from sidebar via shared context
useEffect(() => {
if (!pendingPluginInstallAction || statusLoading || !isPluginSystemReady)
return;
// Consume the action immediately
const action = pendingPluginInstallAction;
setPendingPluginInstallAction(null);
if (action === 'local') {
// Small delay to ensure file input ref is ready
setTimeout(() => fileInputRef.current?.click(), 100);
} else if (action === 'github') {
setInstallSource('github');
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
setInstallError(null);
resetGithubState();
setShowGithubInstall(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingPluginInstallAction, statusLoading, isPluginSystemReady]);
const handleShowDebugInfo = async () => {
try {
const info = await httpClient.getPluginDebugInfo();
@@ -520,23 +158,7 @@ function PluginListView() {
}
return (
<div
className={`${styles.pageContainer} h-full flex flex-col ${
isDragOver ? 'bg-blue-50' : ''
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept=".lbpkg,.zip"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
{/* Header bar with filter tabs, debug info, and task queue */}
<div className={`${styles.pageContainer} h-full flex flex-col`}>
<div className="flex flex-col md:flex-row md:justify-between md:items-center px-[0.8rem] pb-4 flex-shrink-0 gap-2">
<div className="overflow-x-auto -mx-1 px-1">
<Tabs
@@ -588,7 +210,6 @@ function PluginListView() {
align="end"
>
<div className="space-y-3">
{/* Header with icon and title */}
<div className="flex items-center gap-2 pb-2 border-b">
<Bug className="w-4 h-4" />
<h4 className="font-semibold text-sm">
@@ -596,7 +217,6 @@ function PluginListView() {
</h4>
</div>
{/* Debug URL row */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
{t('plugins.debugUrl')}:
@@ -622,7 +242,6 @@ function PluginListView() {
</Button>
</div>
{/* Debug Key row */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
@@ -666,251 +285,6 @@ function PluginListView() {
</div>
</div>
{/* Inline GitHub install flow */}
{showGithubInstall && (
<div className="px-[0.8rem] pb-4 flex-shrink-0">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="flex items-center gap-2 text-base">
<Github className="size-5" />
<span>{t('plugins.installPlugin')}</span>
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowGithubInstall(false);
resetGithubState();
setInstallError(null);
}}
>
{t('common.cancel')}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{/* Step 1: Enter repo URL */}
{pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
<div>
<p className="mb-2 text-sm">{t('plugins.enterRepoUrl')}</p>
<div className="flex gap-2">
<Input
placeholder={t('plugins.repoUrlPlaceholder')}
value={githubURL}
onChange={(e) => setGithubURL(e.target.value)}
/>
<Button
onClick={fetchGithubReleases}
disabled={!githubURL.trim() || fetchingReleases}
>
{fetchingReleases
? t('plugins.loading')
: t('common.confirm')}
</Button>
</div>
</div>
)}
{/* Step 2: Select release */}
{pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-sm">
{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,
})}{' '}
&bull;{' '}
{t('plugins.publishedAt', {
date: new Date(
release.published_at,
).toLocaleDateString(),
})}
</CardDescription>
</div>
{release.prerelease && (
<Badge
variant="secondary"
className="ml-2 shrink-0"
>
{t('plugins.prerelease')}
</Badge>
)}
</CardHeader>
</Card>
))}
</div>
{fetchingAssets && (
<p className="text-sm text-muted-foreground mt-4">
{t('plugins.loading')}
</p>
)}
</div>
)}
{/* Step 3: Select asset */}
{pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-sm">
{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-3 p-2 bg-muted rounded">
<div className="text-sm font-medium">
{selectedRelease.name || selectedRelease.tag_name}
</div>
<div className="text-xs text-muted-foreground">
{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>
)}
{/* Step 4: Confirm install */}
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-sm">
{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-muted 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 className="flex justify-end mt-4">
<Button onClick={() => handleModalConfirm()}>
{t('common.confirm')}
</Button>
</div>
</div>
)}
{/* Installing state */}
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
<div>
<p className="text-sm">{t('plugins.installing')}</p>
</div>
)}
{/* Error state */}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<div>
<p className="text-sm mb-1">{t('plugins.installFailed')}</p>
<p className="text-sm text-destructive">{installError}</p>
<div className="flex justify-end mt-4">
<Button
variant="default"
onClick={() => {
setShowGithubInstall(false);
resetGithubState();
setInstallError(null);
}}
>
{t('common.close')}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)}
{/* Installed plugins grid */}
<div className="flex-1 overflow-y-auto">
<PluginInstalledComponent
ref={pluginInstalledRef}
@@ -918,17 +292,6 @@ function PluginListView() {
groupByType={groupByType}
/>
</div>
{isDragOver && (
<div className="fixed inset-0 bg-foreground/40 flex items-center justify-center z-50 pointer-events-none">
<Card className="border-2 border-dashed">
<CardContent className="flex flex-col items-center justify-center px-8 py-6">
<UploadIcon className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium">{t('plugins.dragToUpload')}</p>
</CardContent>
</Card>
</div>
)}
</div>
);
}

View File

@@ -1,645 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { ChevronLeft, Github, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { httpClient } from '@/app/infra/http/HttpClient';
import type { Skill } from '@/app/infra/entities/api';
interface GithubRelease {
id: number;
tag_name: string;
name: string;
published_at: string;
prerelease: boolean;
draft: boolean;
source_type?: 'release' | 'tag' | 'branch';
archive_url?: string;
}
interface GithubAsset {
id: number;
name: string;
size: number;
download_url: string;
content_type: string;
}
interface PreviewSkill extends Skill {
source_path?: string;
entry_file?: string;
}
interface SkillGithubImportPanelProps {
onImported: (skillNames: string[]) => void;
/** Which section to display. Defaults to 'all' (both GitHub and upload). */
mode?: 'all' | 'github' | 'upload';
}
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 previewPath(skill: PreviewSkill): string {
return skill.source_path || '';
}
export default function SkillGithubImportPanel({
onImported,
mode = 'all',
}: SkillGithubImportPanelProps) {
const { t } = useTranslation();
const [githubURL, setGithubURL] = useState('');
const [githubOwner, setGithubOwner] = useState('');
const [githubRepo, setGithubRepo] = useState('');
const [githubSourceSubdir, setGithubSourceSubdir] = 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 [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
const [selectedPreviewPaths, setSelectedPreviewPaths] = useState<string[]>(
[],
);
const [activePreviewPath, setActivePreviewPath] = useState('');
const [fetchingReleases, setFetchingReleases] = useState(false);
const [fetchingAssets, setFetchingAssets] = useState(false);
const [previewingGithub, setPreviewingGithub] = useState(false);
const [installingGithub, setInstallingGithub] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadPreviewSkills, setUploadPreviewSkills] = useState<
PreviewSkill[]
>([]);
const [selectedUploadPreviewPaths, setSelectedUploadPreviewPaths] = useState<
string[]
>([]);
const [activeUploadPreviewPath, setActiveUploadPreviewPath] = useState('');
const [previewingUpload, setPreviewingUpload] = useState(false);
const [installingUpload, setInstallingUpload] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const activePreviewSkill =
previewSkills.find((skill) => previewPath(skill) === activePreviewPath) ||
null;
const activeUploadPreviewSkill =
uploadPreviewSkills.find(
(skill) => previewPath(skill) === activeUploadPreviewPath,
) || null;
function initializeSelection(
skills: PreviewSkill[],
setSelectedPaths: (paths: string[]) => void,
setActivePath: (path: string) => void,
) {
const paths = skills.map(previewPath);
setSelectedPaths(paths);
setActivePath(paths[0] || '');
}
function toggleSelection(
targetPath: string,
selectedPaths: string[],
setSelectedPaths: (paths: string[]) => void,
setActivePath: (path: string) => void,
) {
if (selectedPaths.includes(targetPath)) {
const nextPaths = selectedPaths.filter((path) => path !== targetPath);
setSelectedPaths(nextPaths);
if (!nextPaths.includes(targetPath)) {
setActivePath(nextPaths[0] || targetPath);
}
return;
}
setSelectedPaths([...selectedPaths, targetPath]);
setActivePath(targetPath);
}
function buildSourceArchiveAsset(release: GithubRelease): GithubAsset | null {
if (!release.archive_url) return null;
return {
id: 0,
name: t('skills.sourceArchive'),
size: 0,
download_url: release.archive_url,
content_type: 'application/zip',
};
}
async function fetchReleases() {
if (!githubURL.trim()) return;
setFetchingReleases(true);
setErrorMessage(null);
setPreviewSkills([]);
setSelectedPreviewPaths([]);
setActivePreviewPath('');
try {
const result = await httpClient.getGithubReleases(githubURL);
setGithubReleases(result.releases);
setGithubOwner(result.owner);
setGithubRepo(result.repo);
setGithubSourceSubdir(result.source_subdir || '');
if (result.releases.length === 0) {
toast.warning(t('skills.noReleasesFound'));
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message || t('skills.fetchReleasesError'));
} finally {
setFetchingReleases(false);
}
}
async function handleReleaseSelect(release: GithubRelease) {
setSelectedRelease(release);
setSelectedAsset(null);
setPreviewSkills([]);
setSelectedPreviewPaths([]);
setActivePreviewPath('');
setErrorMessage(null);
setFetchingAssets(true);
try {
if (release.source_type && release.source_type !== 'release') {
const archiveAsset = buildSourceArchiveAsset(release);
setGithubAssets(archiveAsset ? [archiveAsset] : []);
if (!archiveAsset) {
toast.warning(t('skills.noAssetsFound'));
}
return;
}
const result = await httpClient.getGithubReleaseAssets(
githubOwner,
githubRepo,
release.id,
release.tag_name,
release.source_type,
release.archive_url,
);
let assets = result.assets;
if (assets.length === 0) {
const archiveAsset = buildSourceArchiveAsset(release);
if (archiveAsset) {
assets = [archiveAsset];
}
}
setGithubAssets(assets);
if (assets.length === 0) {
toast.warning(t('skills.noAssetsFound'));
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message || t('skills.fetchAssetsError'));
} finally {
setFetchingAssets(false);
}
}
async function handleGithubPreview(asset: GithubAsset) {
if (!selectedRelease) return;
setSelectedAsset(asset);
setPreviewSkills([]);
setSelectedPreviewPaths([]);
setActivePreviewPath('');
setErrorMessage(null);
setPreviewingGithub(true);
try {
const resp = await httpClient.previewSkillInstallFromGithub(
asset.download_url,
githubOwner,
githubRepo,
selectedRelease.tag_name,
githubSourceSubdir,
);
const skills = resp.skills as PreviewSkill[];
setPreviewSkills(skills);
initializeSelection(
skills,
setSelectedPreviewPaths,
setActivePreviewPath,
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message || t('skills.installError'));
} finally {
setPreviewingGithub(false);
}
}
async function handleGithubImport() {
if (!selectedAsset || !selectedRelease || selectedPreviewPaths.length === 0)
return;
setInstallingGithub(true);
setErrorMessage(null);
try {
const resp = await httpClient.installSkillFromGithub(
selectedAsset.download_url,
githubOwner,
githubRepo,
selectedRelease.tag_name,
selectedPreviewPaths,
githubSourceSubdir,
);
toast.success(t('skills.installSuccess'));
onImported(resp.skills.map((skill) => skill.name));
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message || t('skills.installError'));
} finally {
setInstallingGithub(false);
}
}
async function handleUploadPreview() {
if (!uploadFile) return;
if (!uploadFile.name.toLowerCase().endsWith('.zip')) {
setErrorMessage(t('skills.uploadZipOnly'));
return;
}
setPreviewingUpload(true);
setUploadPreviewSkills([]);
setSelectedUploadPreviewPaths([]);
setActiveUploadPreviewPath('');
setErrorMessage(null);
try {
const resp = await httpClient.previewSkillInstallFromUpload(uploadFile);
const skills = resp.skills as PreviewSkill[];
setUploadPreviewSkills(skills);
initializeSelection(
skills,
setSelectedUploadPreviewPaths,
setActiveUploadPreviewPath,
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message || t('skills.installError'));
} finally {
setPreviewingUpload(false);
}
}
async function handleUploadImport() {
if (!uploadFile || selectedUploadPreviewPaths.length === 0) return;
setInstallingUpload(true);
setErrorMessage(null);
try {
const resp = await httpClient.installSkillFromUpload(
uploadFile,
selectedUploadPreviewPaths,
);
toast.success(t('skills.installSuccess'));
onImported(resp.skills.map((skill) => skill.name));
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message || t('skills.installError'));
} finally {
setInstallingUpload(false);
}
}
function renderCandidateSelector(
skills: PreviewSkill[],
selectedPaths: string[],
activePath: string,
setSelectedPaths: (paths: string[]) => void,
setActivePath: (path: string) => void,
) {
if (skills.length <= 1) {
return null;
}
return (
<div className="space-y-2">
{skills.map((skill) => {
const path = previewPath(skill);
const selected = selectedPaths.includes(path);
const active = path === activePath;
return (
<div
key={path || skill.name}
className={`w-full rounded-lg border p-3 transition-colors ${
active ? 'border-primary bg-accent/50' : 'hover:bg-accent/50'
}`}
>
<div className="flex items-start gap-3">
<Checkbox
checked={selected}
onCheckedChange={() =>
toggleSelection(
path,
selectedPaths,
setSelectedPaths,
setActivePath,
)
}
/>
<button
type="button"
className="flex-1 text-left"
onClick={() => setActivePath(path)}
>
<div className="font-medium">
{skill.display_name || skill.name}
</div>
<div className="text-sm text-muted-foreground">
{(skill.source_path || '.') + ' · ' + skill.name}
</div>
</button>
</div>
</div>
);
})}
</div>
);
}
function renderPreviewDetail(skill: PreviewSkill | null) {
if (!skill) return null;
return (
<>
<div className="space-y-2 text-sm">
<div>
<span className="font-medium">{t('skills.displayName')}:</span>{' '}
{skill.display_name || '-'}
</div>
<div>
<span className="font-medium">{t('skills.skillSlug')}:</span>{' '}
{skill.name}
</div>
<div>
<span className="font-medium">{t('skills.skillDescription')}:</span>{' '}
{skill.description}
</div>
<div>
<span className="font-medium">{t('skills.packageRoot')}:</span>{' '}
{skill.package_root}
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">
{t('skills.skillInstructions')}
</div>
<pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs">
{skill.instructions || ''}
</pre>
</div>
</>
);
}
return (
<div className="space-y-6">
{(mode === 'all' || mode === 'github') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Github className="size-5" />
<span>{t('skills.importFromGithub')}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{githubReleases.length === 0 && (
<div className="flex gap-2">
<Input
placeholder={t('skills.repoUrlPlaceholder')}
value={githubURL}
onChange={(e) => setGithubURL(e.target.value)}
/>
<Button
type="button"
onClick={fetchReleases}
disabled={!githubURL.trim() || fetchingReleases}
>
{fetchingReleases ? t('skills.loading') : t('common.confirm')}
</Button>
</div>
)}
{githubReleases.length > 0 && !selectedRelease && (
<div className="space-y-2">
{githubReleases.map((release) => (
<button
key={release.id}
type="button"
className="w-full rounded-lg border p-3 text-left hover:bg-accent/50 transition-colors"
onClick={() => handleReleaseSelect(release)}
>
<div className="font-medium">
{release.name || release.tag_name}
</div>
<div className="text-sm text-muted-foreground">
{t('skills.releaseTag', { tag: release.tag_name })}
</div>
</button>
))}
</div>
)}
{selectedRelease && previewSkills.length === 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="font-medium">
{selectedRelease.name || selectedRelease.tag_name}
</div>
<div className="text-sm text-muted-foreground">
{t('skills.releaseTag', {
tag: selectedRelease.tag_name,
})}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setSelectedRelease(null);
setGithubAssets([]);
setSelectedAsset(null);
setPreviewSkills([]);
setSelectedPreviewPaths([]);
setActivePreviewPath('');
setErrorMessage(null);
}}
>
<ChevronLeft className="size-4 mr-1" />
{t('skills.backToReleases')}
</Button>
</div>
{fetchingAssets && (
<div className="text-sm text-muted-foreground">
{t('skills.loading')}
</div>
)}
{!fetchingAssets && githubAssets.length > 0 && (
<div className="space-y-2">
{githubAssets.map((asset) => (
<button
key={asset.id}
type="button"
className="w-full rounded-lg border p-3 text-left hover:bg-accent/50 transition-colors"
onClick={() => handleGithubPreview(asset)}
disabled={previewingGithub}
>
<div className="font-medium">{asset.name}</div>
<div className="text-sm text-muted-foreground">
{t('skills.assetSize', {
size: formatFileSize(asset.size),
})}
</div>
</button>
))}
</div>
)}
</div>
)}
{previewSkills.length > 0 && selectedRelease && selectedAsset && (
<div className="space-y-4 rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="font-medium">{t('skills.preview')}</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setPreviewSkills([]);
setSelectedPreviewPaths([]);
setActivePreviewPath('');
setSelectedAsset(null);
}}
>
<ChevronLeft className="size-4 mr-1" />
{t('skills.backToAssets')}
</Button>
</div>
{renderCandidateSelector(
previewSkills,
selectedPreviewPaths,
activePreviewPath,
setSelectedPreviewPaths,
setActivePreviewPath,
)}
{renderPreviewDetail(activePreviewSkill)}
<div className="flex justify-end">
<Button
type="button"
onClick={handleGithubImport}
disabled={
installingGithub || selectedPreviewPaths.length === 0
}
>
{installingGithub
? t('skills.installing')
: t('skills.confirmInstall')}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
{(mode === 'all' || mode === 'upload') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="size-5" />
<span>{t('skills.uploadZip')}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input
type="file"
accept=".zip,application/zip"
onChange={(e) => {
const file = e.target.files?.[0] ?? null;
setUploadFile(file);
setUploadPreviewSkills([]);
setSelectedUploadPreviewPaths([]);
setActiveUploadPreviewPath('');
setErrorMessage(null);
}}
/>
{uploadFile && (
<div className="text-sm text-muted-foreground">
{uploadFile.name}
</div>
)}
<div className="flex justify-end">
<Button
type="button"
onClick={handleUploadPreview}
disabled={!uploadFile || previewingUpload}
>
{previewingUpload ? t('skills.loading') : t('skills.preview')}
</Button>
</div>
{uploadPreviewSkills.length > 0 && uploadFile && (
<div className="space-y-4 rounded-lg border p-4">
<div className="font-medium">{t('skills.preview')}</div>
{renderCandidateSelector(
uploadPreviewSkills,
selectedUploadPreviewPaths,
activeUploadPreviewPath,
setSelectedUploadPreviewPaths,
setActiveUploadPreviewPath,
)}
{renderPreviewDetail(activeUploadPreviewSkill)}
<div className="flex justify-end">
<Button
type="button"
onClick={handleUploadImport}
disabled={
installingUpload ||
selectedUploadPreviewPaths.length === 0
}
>
{installingUpload
? t('skills.installing')
: t('skills.confirmInstall')}
</Button>
</div>
</div>
)}
{errorMessage && (
<div className="text-sm text-destructive">{errorMessage}</div>
)}
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,277 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { BookOpen, FileArchive, Loader2, PackageOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { httpClient } from '@/app/infra/http/HttpClient';
import type { Skill } from '@/app/infra/entities/api';
import { cn } from '@/lib/utils';
interface PreviewSkill extends Skill {
source_path?: string;
}
interface SkillZipPreviewPanelProps {
file: File;
onImported: (skillNames: string[]) => void;
onCancel?: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
function previewPath(skill: PreviewSkill): string {
return skill.source_path ?? '';
}
function displayPreviewPath(skill: PreviewSkill): string {
return previewPath(skill) || skill.name;
}
function truncateInstructions(instructions?: string): string {
if (!instructions) return '';
const trimmed = instructions.trim();
if (trimmed.length <= 900) return trimmed;
return trimmed.slice(0, 900).trimEnd() + '\n...';
}
export default function SkillZipPreviewPanel({
file,
onImported,
onCancel,
}: SkillZipPreviewPanelProps) {
const { t } = useTranslation();
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
const [activePath, setActivePath] = useState('');
const [previewing, setPreviewing] = useState(false);
const [installing, setInstalling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const lastPreviewSignatureRef = useRef('');
const previewFileSignature = `${file.name}:${file.size}:${file.lastModified}`;
const activeSkill = useMemo(
() =>
previewSkills.find((skill) => previewPath(skill) === activePath) ||
previewSkills[0] ||
null,
[activePath, previewSkills],
);
const loadPreview = useCallback(async () => {
setPreviewing(true);
setPreviewSkills([]);
setSelectedPaths([]);
setActivePath('');
setErrorMessage(null);
try {
const resp = await httpClient.previewSkillInstallFromUpload(file);
const skills = (resp.skills || []) as PreviewSkill[];
setPreviewSkills(skills);
const paths = skills.map(previewPath);
setSelectedPaths(paths);
setActivePath(paths[0] || '');
if (skills.length === 0) {
setErrorMessage(t('skills.noSkillMdInDirectory'));
} else {
setErrorMessage(null);
}
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('skills.previewLoadError'));
} finally {
setPreviewing(false);
}
}, [file, t]);
useEffect(() => {
if (lastPreviewSignatureRef.current === previewFileSignature) return;
lastPreviewSignatureRef.current = previewFileSignature;
void loadPreview();
}, [loadPreview, previewFileSignature]);
function toggleSelection(path: string) {
setSelectedPaths((current) => {
if (current.includes(path)) {
const next = current.filter((item) => item !== path);
if (activePath === path) {
setActivePath(next[0] || path);
}
return next;
}
setActivePath(path);
return [...current, path];
});
}
async function handleInstall() {
if (selectedPaths.length === 0) return;
setInstalling(true);
setErrorMessage(null);
try {
const resp = await httpClient.installSkillFromUpload(file, selectedPaths);
toast.success(t('skills.installSuccess'));
onImported(resp.skills.map((skill) => skill.name));
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('skills.installError'));
} finally {
setInstalling(false);
}
}
const activeInstructions = truncateInstructions(activeSkill?.instructions);
return (
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-md bg-muted/40 px-3 py-3">
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground">
{previewing ? (
<Loader2 className="size-4 animate-spin" />
) : (
<FileArchive className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{previewing ? t('skills.loading') : t('skills.preview')}
</div>
<div className="mt-1 break-all text-xs text-muted-foreground">
{file.name} · {formatFileSize(file.size)}
</div>
</div>
</div>
{previewSkills.length > 0 && (
<div
className={cn(
'grid gap-4',
previewSkills.length > 1 && 'md:grid-cols-[240px_minmax(0,1fr)]',
)}
>
{previewSkills.length > 1 && (
<div className="space-y-2">
{previewSkills.map((skill) => {
const path = previewPath(skill);
const displayPath = displayPreviewPath(skill);
const selected = selectedPaths.includes(path);
const active = activePath === path;
return (
<button
key={`${path}:${skill.name}`}
type="button"
className={cn(
'flex w-full items-start gap-2 rounded-md px-3 py-2 text-left transition-colors',
active ? 'bg-accent' : 'bg-muted/30 hover:bg-accent/70',
)}
onClick={() => setActivePath(path)}
>
<Checkbox
checked={selected}
onCheckedChange={() => toggleSelection(path)}
onClick={(event) => event.stopPropagation()}
className="mt-0.5"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{skill.display_name || skill.name}
</span>
{path && (
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{displayPath}
</span>
)}
</span>
</button>
);
})}
</div>
)}
{activeSkill && (
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<BookOpen className="size-4 text-muted-foreground" />
<h3 className="min-w-0 truncate text-base font-semibold">
{activeSkill.display_name || activeSkill.name}
</h3>
</div>
{activeSkill.description && (
<p className="text-sm leading-6 text-muted-foreground">
{activeSkill.description}
</p>
)}
{activeInstructions && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t('skills.previewInstructions')}
</div>
<div className="max-h-56 overflow-y-auto rounded-md bg-muted/40 p-3 font-mono text-xs leading-5 whitespace-pre-wrap">
{activeInstructions}
</div>
</div>
)}
</div>
)}
</div>
)}
{errorMessage && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
<div className="flex justify-end gap-2">
{onCancel && (
<Button variant="outline" onClick={onCancel} disabled={installing}>
{t('common.cancel')}
</Button>
)}
<Button
type="button"
onClick={handleInstall}
disabled={
previewing ||
installing ||
previewSkills.length === 0 ||
selectedPaths.length === 0
}
>
{installing ? (
<>
<Loader2 className="size-4 animate-spin" />
{t('skills.installing')}
</>
) : (
<>
<PackageOpen className="size-4" />
{t('skills.confirmInstall')}
</>
)}
</Button>
</div>
</div>
);
}

View File

@@ -1,131 +1,73 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import SkillDetailContent from '@/app/home/skills/SkillDetailContent';
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import SkillGithubImportPanel from '@/app/home/skills/components/SkillGithubImportPanel';
import {
useSidebarData,
type SkillInstallAction,
} from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
export default function SkillsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const detailId = searchParams.get('id');
const actionParam = searchParams.get('action') as SkillInstallAction | null;
const {
refreshSkills,
pendingSkillInstallAction,
setPendingSkillInstallAction,
} = useSidebarData();
const actionParam = searchParams.get('action');
const { refreshSkills } = useSidebarData();
const [activeView, setActiveView] = useState<SkillInstallAction>(null);
const isCreateView = actionParam === 'create';
useEffect(() => {
if (actionParam && ['create', 'github', 'upload'].includes(actionParam)) {
setActiveView(actionParam);
return;
if (!detailId && !isCreateView) {
navigate('/home/add-extension', { replace: true });
}
if (!pendingSkillInstallAction) return;
const action = pendingSkillInstallAction;
setPendingSkillInstallAction(null);
setActiveView(action);
}, [actionParam, pendingSkillInstallAction, setPendingSkillInstallAction]);
}, [detailId, isCreateView, navigate]);
if (detailId) {
return <SkillDetailContent id={detailId} />;
}
function handleImportedSkills(_skillNames: string[]) {
function handleCreatedSkill(skillName: string) {
void refreshSkills();
setActiveView(null);
navigate('/home/add-extension');
navigate(`/home/skills?id=${encodeURIComponent(skillName)}`, {
replace: true,
});
}
function handleCancel() {
setActiveView(null);
navigate('/home/add-extension');
}
if (activeView === 'create') {
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between pb-4 shrink-0">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 className="text-xl font-semibold">{t('skills.createSkill')}</h1>
<p className="text-sm text-muted-foreground">
{t('skills.createSkillDescription')}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}
</Button>
<Button type="submit" form="skill-form">
{t('common.save')}
</Button>
</div>
</div>
<div className="min-h-0 flex-1">
<SkillForm
key="new-skill"
initSkillName={undefined}
layout="split"
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
onSkillUpdated={() => {}}
/>
</div>
</div>
);
if (!isCreateView) {
return null;
}
if (activeView === 'github') {
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">
{t('skills.importFromGithub')}
</h1>
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between pb-4 shrink-0">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 className="text-xl font-semibold">{t('skills.createSkill')}</h1>
<p className="text-sm text-muted-foreground">
{t('skills.createSkillDescription')}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}
</Button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<SkillGithubImportPanel
mode="github"
onImported={handleImportedSkills}
/>
</div>
</div>
</div>
);
}
if (activeView === 'upload') {
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('skills.uploadZip')}</h1>
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}
<Button type="submit" form="skill-form">
{t('common.save')}
</Button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<SkillGithubImportPanel
mode="upload"
onImported={handleImportedSkills}
/>
</div>
</div>
</div>
);
}
navigate('/home/add-extension');
return null;
<div className="min-h-0 flex-1">
<SkillForm
key="new-skill"
initSkillName={undefined}
layout="split"
onNewSkillCreated={handleCreatedSkill}
onSkillUpdated={() => {}}
/>
</div>
</div>
);
}

View File

@@ -55,6 +55,7 @@ import {
ApiRespSkill,
} from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin';
import type { I18nObject } from '@/app/infra/entities/common';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
@@ -710,6 +711,28 @@ export class BackendClient extends BaseHttpClient {
return this.postFile('/api/v1/plugins/install/local', formData);
}
public previewPluginInstallFromLocal(file: File): Promise<{
filename: string;
size: number;
manifest: Record<string, unknown>;
metadata: {
author?: string;
name?: string;
version?: string;
label?: I18nObject;
description?: I18nObject;
repository?: string;
};
component_types: string[];
component_counts: Record<string, number>;
requirements: string[];
file_count: number;
}> {
const formData = new FormData();
formData.append('file', file);
return this.postFile('/api/v1/plugins/install/local/preview', formData);
}
// ============ Skill Install API ============
public installSkillFromGithub(
assetUrl: string,

View File

@@ -520,6 +520,21 @@ const enUS = {
uploadLocal: 'Upload Local',
debugging: 'Debugging',
uploadLocalPlugin: 'Upload Local Plugin',
localPreview: {
title: 'Preview Local Plugin Package',
unpacking: 'Unpacking package preview...',
unpackComplete: 'Package preview ready',
failed: 'Failed to preview package',
pluginInfo: 'Plugin Info',
packageInfo: 'Package Info',
name: 'Name',
author: 'Author',
version: 'Version',
fileCount: 'Files',
dependencies: 'Dependencies',
components: 'Components',
ready: 'The plugin package is unpacked. Confirm to start installation.',
},
uploadPluginOnly: 'Only .lbpkg files are supported',
dragToUpload: 'Drag plugin file here to upload',
unsupportedFileType:
@@ -1370,7 +1385,7 @@ const enUS = {
scanError: 'Failed to scan directory: ',
noSkills: 'No skills configured',
preview: 'Preview',
previewInstructions: 'Preview Instructions',
previewInstructions: 'SKILL.md Content Preview',
instructionsPlaceholder: 'Enter skill instructions in Markdown format...',
descriptionPlaceholder:
'A brief description of what this skill does (shown to the LLM)',

View File

@@ -532,6 +532,22 @@ const esES = {
uploadLocal: 'Subir local',
debugging: 'Depuración',
uploadLocalPlugin: 'Subir plugin local',
localPreview: {
title: 'Previsualizar paquete de plugin local',
unpacking: 'Descomprimiendo vista previa del paquete...',
unpackComplete: 'Vista previa del paquete lista',
failed: 'No se pudo previsualizar el paquete',
pluginInfo: 'Información del plugin',
packageInfo: 'Información del paquete',
name: 'Nombre',
author: 'Autor',
version: 'Versión',
fileCount: 'Archivos',
dependencies: 'Dependencias',
components: 'Componentes',
ready:
'El paquete del plugin está descomprimido. Confirma para iniciar la instalación.',
},
dragToUpload: 'Arrastra el archivo del plugin aquí para subirlo',
unsupportedFileType:
'Tipo de archivo no soportado, solo se admiten archivos .lbpkg y .zip',
@@ -1486,7 +1502,7 @@ const esES = {
scanError: 'Error al escanear el directorio: ',
noSkills: 'No hay skills configuradas',
preview: 'Vista previa',
previewInstructions: 'Vista previa de instrucciones',
previewInstructions: 'Vista previa del contenido de SKILL.md',
instructionsPlaceholder:
'Introduce las instrucciones de la skill en formato Markdown...',
descriptionPlaceholder:

View File

@@ -524,6 +524,22 @@ const jaJP = {
uploadLocal: 'ローカルアップロード',
debugging: 'デバッグ中',
uploadLocalPlugin: 'ローカルプラグインのアップロード',
localPreview: {
title: 'ローカルプラグインパッケージをプレビュー',
unpacking: 'パッケージを展開してプレビュー中...',
unpackComplete: 'パッケージのプレビュー準備完了',
failed: 'パッケージのプレビューに失敗しました',
pluginInfo: 'プラグイン情報',
packageInfo: 'パッケージ情報',
name: '名前',
author: '作者',
version: 'バージョン',
fileCount: 'ファイル数',
dependencies: '依存関係',
components: 'コンポーネント',
ready:
'プラグインパッケージを展開しました。確認するとインストールを開始します。',
},
dragToUpload: 'ファイルをここにドラッグしてアップロード',
unsupportedFileType:
'サポートされていないファイルタイプです。.lbpkg と .zip ファイルのみサポートされています',
@@ -1476,7 +1492,7 @@ const jaJP = {
scanError: 'ディレクトリのスキャンに失敗しました: ',
noSkills: '設定済みのスキルはありません',
preview: 'プレビュー',
previewInstructions: '指示内容プレビュー',
previewInstructions: 'SKILL.md 内容プレビュー',
instructionsPlaceholder: 'Markdown 形式でスキルの指示を入力...',
descriptionPlaceholder: 'このスキルの概要LLM に表示されます)',
packageRoot: 'パッケージディレクトリ',

View File

@@ -530,6 +530,21 @@ const ruRU = {
uploadLocal: 'Загрузить локально',
debugging: 'Отладка',
uploadLocalPlugin: 'Загрузить локальный плагин',
localPreview: {
title: 'Предпросмотр локального пакета плагина',
unpacking: 'Распаковка пакета для предпросмотра...',
unpackComplete: 'Предпросмотр пакета готов',
failed: 'Не удалось выполнить предпросмотр пакета',
pluginInfo: 'Информация о плагине',
packageInfo: 'Информация о пакете',
name: 'Название',
author: 'Автор',
version: 'Версия',
fileCount: 'Файлы',
dependencies: 'Зависимости',
components: 'Компоненты',
ready: 'Пакет плагина распакован. Подтвердите, чтобы начать установку.',
},
dragToUpload: 'Перетащите файл плагина сюда для загрузки',
unsupportedFileType:
'Неподдерживаемый тип файла, поддерживаются только файлы .lbpkg и .zip',
@@ -1459,7 +1474,7 @@ const ruRU = {
scanError: 'Не удалось просканировать каталог: ',
noSkills: 'Навыки не настроены',
preview: 'Предпросмотр',
previewInstructions: 'Предпросмотр инструкций',
previewInstructions: 'Предпросмотр содержимого SKILL.md',
instructionsPlaceholder: 'Введите инструкции навыка в формате Markdown...',
descriptionPlaceholder:
'Краткое описание того, что делает этот навык (показывается LLM)',

View File

@@ -514,6 +514,21 @@ const thTH = {
uploadLocal: 'อัปโหลดจากเครื่อง',
debugging: 'ดีบัก',
uploadLocalPlugin: 'อัปโหลดปลั๊กอินจากเครื่อง',
localPreview: {
title: 'ดูตัวอย่างแพ็กเกจปลั๊กอินในเครื่อง',
unpacking: 'กำลังแตกแพ็กเกจเพื่อดูตัวอย่าง...',
unpackComplete: 'พร้อมดูตัวอย่างแพ็กเกจแล้ว',
failed: 'ดูตัวอย่างแพ็กเกจไม่สำเร็จ',
pluginInfo: 'ข้อมูลปลั๊กอิน',
packageInfo: 'ข้อมูลแพ็กเกจ',
name: 'ชื่อ',
author: 'ผู้เขียน',
version: 'เวอร์ชัน',
fileCount: 'จำนวนไฟล์',
dependencies: 'แพ็กเกจที่ต้องใช้',
components: 'คอมโพเนนต์',
ready: 'แตกแพ็กเกจปลั๊กอินแล้ว ยืนยันเพื่อเริ่มติดตั้ง',
},
dragToUpload: 'ลากไฟล์ปลั๊กอินมาวางที่นี่เพื่ออัปโหลด',
unsupportedFileType: 'ประเภทไฟล์ไม่รองรับ รองรับเฉพาะไฟล์ .lbpkg และ .zip',
uploadingPlugin: 'กำลังอัปโหลดปลั๊กอิน...',
@@ -1425,7 +1440,7 @@ const thTH = {
scanError: 'สแกนไดเรกทอรีไม่สำเร็จ: ',
noSkills: 'ยังไม่มีสกิลที่ตั้งค่าไว้',
preview: 'ดูตัวอย่าง',
previewInstructions: 'ดูตัวอย่างคำสั่ง',
previewInstructions: 'ตัวอย่างเนื้อหา SKILL.md',
instructionsPlaceholder: 'ป้อนคำสั่งของสกิลในรูปแบบ Markdown...',
descriptionPlaceholder:
'คำอธิบายสั้น ๆ ว่าสกิลนี้ทำอะไร (แสดงให้ LLM เห็น)',

View File

@@ -525,6 +525,21 @@ const viVN = {
uploadLocal: 'Tải lên cục bộ',
debugging: 'Gỡ lỗi',
uploadLocalPlugin: 'Tải lên Plugin cục bộ',
localPreview: {
title: 'Xem trước gói plugin cục bộ',
unpacking: 'Đang giải nén để xem trước gói...',
unpackComplete: 'Bản xem trước gói đã sẵn sàng',
failed: 'Không thể xem trước gói',
pluginInfo: 'Thông tin plugin',
packageInfo: 'Thông tin gói',
name: 'Tên',
author: 'Tác giả',
version: 'Phiên bản',
fileCount: 'Tệp',
dependencies: 'Phụ thuộc',
components: 'Thành phần',
ready: 'Gói plugin đã được giải nén. Xác nhận để bắt đầu cài đặt.',
},
dragToUpload: 'Kéo tệp plugin vào đây để tải lên',
unsupportedFileType:
'Loại tệp không được hỗ trợ, chỉ hỗ trợ tệp .lbpkg và .zip',
@@ -1451,7 +1466,7 @@ const viVN = {
scanError: 'Quét thư mục thất bại: ',
noSkills: 'Chưa cấu hình kỹ năng nào',
preview: 'Xem trước',
previewInstructions: 'Xem trước hướng dẫn',
previewInstructions: 'Xem trước nội dung SKILL.md',
instructionsPlaceholder:
'Nhập hướng dẫn kỹ năng theo định dạng Markdown...',
descriptionPlaceholder:

View File

@@ -497,6 +497,21 @@ const zhHans = {
uploadLocal: '本地上传',
debugging: '调试中',
uploadLocalPlugin: '上传本地插件',
localPreview: {
title: '预览本地插件包',
unpacking: '正在解包预览...',
unpackComplete: '解包预览完成',
failed: '解包预览失败',
pluginInfo: '插件信息',
packageInfo: '包信息',
name: '名称',
author: '作者',
version: '版本',
fileCount: '文件数',
dependencies: '依赖',
components: '组件',
ready: '插件包已解包,确认后开始安装。',
},
uploadPluginOnly: '仅支持 .lbpkg 文件',
dragToUpload: '拖拽文件到此处上传',
unsupportedFileType: '不支持的文件类型,仅支持 .lbpkg 文件',
@@ -1313,7 +1328,7 @@ const zhHans = {
scanError: '扫描目录失败:',
noSkills: '暂未配置任何技能',
preview: '预览',
previewInstructions: '预览指令',
previewInstructions: 'SKILL.md 内容预览',
instructionsPlaceholder: '使用 Markdown 格式输入技能指令...',
descriptionPlaceholder: '简短描述此技能的功能(会展示给 LLM',
packageRoot: '技能目录',

View File

@@ -498,6 +498,21 @@ const zhHant = {
uploadLocal: '本地上傳',
debugging: '調試中',
uploadLocalPlugin: '上傳本地插件',
localPreview: {
title: '預覽本地外掛包',
unpacking: '正在解包預覽...',
unpackComplete: '解包預覽完成',
failed: '解包預覽失敗',
pluginInfo: '外掛資訊',
packageInfo: '包資訊',
name: '名稱',
author: '作者',
version: '版本',
fileCount: '檔案數',
dependencies: '依賴',
components: '元件',
ready: '外掛包已解包,確認後開始安裝。',
},
dragToUpload: '拖拽文件到此處上傳',
unsupportedFileType: '不支持的文件類型,僅支持 .lbpkg 和 .zip 文件',
uploadingPlugin: '正在上傳插件...',
@@ -1405,7 +1420,7 @@ const zhHant = {
scanError: '掃描目錄失敗:',
noSkills: '暫未配置任何技能',
preview: '預覽',
previewInstructions: '預覽指令',
previewInstructions: 'SKILL.md 內容預覽',
instructionsPlaceholder: '使用 Markdown 格式輸入技能指令...',
descriptionPlaceholder: '簡短描述此技能的功能',
packageRoot: '技能目錄',

View File

@@ -19,8 +19,6 @@ import BotsPage from '@/app/home/bots/page';
import PipelinesPage from '@/app/home/pipelines/page';
import PluginsPage from '@/app/home/plugins/page';
import AddExtensionPage from '@/app/home/add-extension/page';
import AddPluginPage from '@/app/home/add-plugin/page';
import MarketPage from '@/app/home/market/page';
import MCPPage from '@/app/home/mcp/page';
import KnowledgePage from '@/app/home/knowledge/page';
import SkillsPage from '@/app/home/skills/page';
@@ -134,26 +132,6 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: '/home/add-plugin',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<AddPluginPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/market',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<MarketPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/mcp',
element: (