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 { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { Download, PlusIcon, ChevronLeft, ChevronRight, Server, Github, BookOpen, FileArchive, Loader2, CircleHelp, } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; import React, { useState, useCallback, useEffect, useRef } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { httpClient, systemInfo } from '@/app/infra/http/HttpClient'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { PluginV4 } from '@/app/infra/entities/plugin'; import type { Skill } from '@/app/infra/entities/api'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task'; import MCPForm from '@/app/home/mcp/components/mcp-form/MCPForm'; import type { MCPFormDraft, MCPFormHandle, } from '@/app/home/mcp/components/mcp-form/MCPForm'; import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel'; import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel'; type PopoverView = 'menu' | 'mcp' | 'github'; enum GithubInstallStatus { WAIT_INPUT = 'wait_input', SELECT_RELEASE = 'select_release', SELECT_ASSET = 'select_asset', ASK_CONFIRM = 'ask_confirm', INSTALLING = 'installing', SKILL_PREVIEW = 'skill_preview', SKILL_INSTALLING = 'skill_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; } interface GithubSkillMdInfo { owner: string; repo: string; ref: string; path: string; } function isGithubSkillMdUrl(rawUrl: string): boolean { try { const url = new URL(rawUrl.trim()); return url.pathname.toLowerCase().endsWith('/skill.md'); } catch { return rawUrl.trim().toLowerCase().split('?', 1)[0].endsWith('skill.md'); } } function parseGithubSkillMdUrl(rawUrl: string): GithubSkillMdInfo { const url = new URL(rawUrl.trim()); const parts = url.pathname.split('/').filter(Boolean); if (url.hostname === 'github.com') { if (parts.length < 5 || parts[2] !== 'blob') { throw new Error('Invalid GitHub SKILL.md URL'); } return { owner: parts[0], repo: parts[1], ref: parts[3], path: parts.slice(4).join('/'), }; } if (url.hostname === 'raw.githubusercontent.com') { if (parts.length < 4) { throw new Error('Invalid GitHub SKILL.md URL'); } return { owner: parts[0], repo: parts[1], ref: parts[2], path: parts.slice(3).join('/'), }; } throw new Error('Invalid GitHub SKILL.md URL'); } enum PluginInstallStatus { ASK_CONFIRM = 'ask_confirm', INSTALLING = 'installing', ERROR = 'error', } export default function AddExtensionPage() { const { t } = useTranslation(); if (!systemInfo?.enable_marketplace) { return (

{t('plugins.marketplace')}

); } return ; } function AddExtensionContent() { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData(); const { addTask, setSelectedTaskId, registerOnTaskComplete, unregisterOnTaskComplete, clearCompletedTasks, } = usePluginInstallTasks(); const [modalOpen, setModalOpen] = useState(false); const [installInfo, setInstallInfo] = useState>({}); const [installExtensionType, setInstallExtensionType] = useState< 'plugin' | 'mcp' | 'skill' >('plugin'); const [pluginInstallStatus, setPluginInstallStatus] = useState(PluginInstallStatus.ASK_CONFIRM); const [installError, setInstallError] = useState(null); const [popoverOpen, setPopoverOpen] = useState(false); const [popoverView, setPopoverView] = useState('menu'); const [isDragOver, setIsDragOver] = useState(false); const [skillUploadPreviewOpen, setSkillUploadPreviewOpen] = useState(false); const [skillUploadPreviewFile, setSkillUploadPreviewFile] = useState(null); const [pluginUploadPreviewOpen, setPluginUploadPreviewOpen] = useState(false); const [pluginUploadPreviewFile, setPluginUploadPreviewFile] = useState(null); const fileInputRef = useRef(null); const mcpFormRef = useRef(null); const [mcpTesting, setMcpTesting] = useState(false); const [mcpDraft, setMcpDraft] = useState(); // GitHub install state const [githubURL, setGithubURL] = useState(''); const [githubReleases, setGithubReleases] = useState([]); const [selectedRelease, setSelectedRelease] = useState( null, ); const [githubAssets, setGithubAssets] = useState([]); const [selectedAsset, setSelectedAsset] = useState(null); const [githubOwner, setGithubOwner] = useState(''); const [githubRepo, setGithubRepo] = useState(''); const [fetchingReleases, setFetchingReleases] = useState(false); const [fetchingAssets, setFetchingAssets] = useState(false); const [fetchingSkillPreview, setFetchingSkillPreview] = useState(false); const [githubSkillInfo, setGithubSkillInfo] = useState(null); const [githubSkillPreview, setGithubSkillPreview] = useState( null, ); const [githubInstallStatus, setGithubInstallStatus] = useState(GithubInstallStatus.WAIT_INPUT); const [githubInstallError, setGithubInstallError] = useState( null, ); useEffect(() => { // Clear any stale completed tasks on mount 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) { toast.success(t('plugins.installSuccess')); refreshPlugins(); } }; registerOnTaskComplete(onComplete); return () => { unregisterOnTaskComplete(onComplete); }; }, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]); const handleInstallPlugin = useCallback(async (plugin: PluginV4) => { 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); }, []); 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: { task_id: number }) => { const taskId = resp.task_id; const taskKey = `marketplace-${taskId}`; addTask({ taskId, pluginName: pluginDisplayName, source: 'marketplace', extensionType: installExtensionType, }); setSelectedTaskId(taskKey); setModalOpen(false); }) .catch((err: { msg?: string }) => { setInstallError(err.msg || null); setPluginInstallStatus(PluginInstallStatus.ERROR); }); } const validateFileType = (file: File): boolean => { const allowedExtensions = ['.lbpkg', '.zip']; const fileName = file.name.toLowerCase(); return allowedExtensions.some((ext) => fileName.endsWith(ext)); }; const getExtensionTypeFromFile = (file: File): 'plugin' | 'skill' => { const fileName = file.name.toLowerCase(); if (fileName.endsWith('.lbpkg')) return 'plugin'; if (fileName.endsWith('.zip')) return 'skill'; return 'plugin'; }; const uploadFile = useCallback( async (file: File) => { if (!validateFileType(file)) { toast.error(t('addExtension.unsupportedFileType')); return; } const extType = getExtensionTypeFromFile(file); setPopoverOpen(false); // Clear any selected task to avoid showing stale dialogs setSelectedTaskId(null); if (extType === 'plugin') { setPluginUploadPreviewFile(file); setPluginUploadPreviewOpen(true); } else { setSkillUploadPreviewFile(file); setSkillUploadPreviewOpen(true); } }, [t, setSelectedTaskId], ); const handleFileSelect = useCallback(() => { if (fileInputRef.current) { fileInputRef.current.click(); } }, []); const handleFileChange = useCallback( (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { uploadFile(file); } event.target.value = ''; }, [uploadFile], ); const handleDragOver = useCallback((event: React.DragEvent) => { event.preventDefault(); setIsDragOver(true); }, []); const handleDragLeave = useCallback((event: React.DragEvent) => { event.preventDefault(); setIsDragOver(false); }, []); const handleDrop = useCallback( (event: React.DragEvent) => { event.preventDefault(); setIsDragOver(false); const files = Array.from(event.dataTransfer.files); if (files.length > 0) { uploadFile(files[0]); } }, [uploadFile], ); function handleMCPCreated(_serverName: string) { setMcpDraft(undefined); refreshMCPServers(); setPopoverView('menu'); setPopoverOpen(false); } async function checkExtensionsLimit(): Promise { const maxExtensions = systemInfo.limitation?.max_extensions ?? -1; if (maxExtensions < 0) return true; try { const [pluginsResp, mcpResp, skillsResp] = await Promise.all([ httpClient.getPlugins(), httpClient.getMCPServers(), httpClient.getSkills(), ]); const total = (pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0) + (skillsResp.skills?.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); setFetchingSkillPreview(false); setGithubSkillInfo(null); setGithubSkillPreview(null); setGithubInstallStatus(GithubInstallStatus.WAIT_INPUT); setGithubInstallError(null); } async function handleGithubAddressSubmit() { if (isGithubSkillMdUrl(githubURL)) { await previewGithubSkillMd(); return; } await fetchGithubReleases(); } async function fetchGithubReleases() { if (!githubURL.trim()) { toast.error(t('plugins.enterRepoUrl')); return; } setFetchingReleases(true); setGithubInstallError(null); setGithubSkillInfo(null); setGithubSkillPreview(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 { setGithubInstallStatus(GithubInstallStatus.SELECT_RELEASE); } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); setGithubInstallError(errorMessage || t('plugins.fetchReleasesError')); setGithubInstallStatus(GithubInstallStatus.ERROR); } finally { setFetchingReleases(false); } } async function previewGithubSkillMd() { if (!githubURL.trim()) { toast.error(t('addExtension.githubUrlRequired')); return; } setFetchingSkillPreview(true); setGithubInstallError(null); setGithubReleases([]); setGithubAssets([]); setSelectedRelease(null); setSelectedAsset(null); try { const skillInfo = parseGithubSkillMdUrl(githubURL); const result = await httpClient.previewSkillInstallFromGithub( githubURL.trim(), skillInfo.owner, skillInfo.repo, skillInfo.ref, ); const preview = result.skills?.[0]; if (!preview) { throw new Error(t('addExtension.noSkillPreviewFound')); } setGithubOwner(skillInfo.owner); setGithubRepo(skillInfo.repo); setGithubSkillInfo(skillInfo); setGithubSkillPreview(preview); setGithubInstallStatus(GithubInstallStatus.SKILL_PREVIEW); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); setGithubInstallError(errorMessage || t('skills.previewLoadError')); setGithubInstallStatus(GithubInstallStatus.ERROR); } finally { setFetchingSkillPreview(false); } } async function handleReleaseSelect(release: GithubRelease) { setSelectedRelease(release); setFetchingAssets(true); setGithubInstallError(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 { setGithubInstallStatus(GithubInstallStatus.SELECT_ASSET); } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); setGithubInstallError(errorMessage || t('plugins.fetchAssetsError')); setGithubInstallStatus(GithubInstallStatus.ERROR); } finally { setFetchingAssets(false); } } function handleAssetSelect(asset: GithubAsset) { setSelectedAsset(asset); setGithubInstallStatus(GithubInstallStatus.ASK_CONFIRM); } async function handleGithubConfirm() { if (!selectedAsset || !selectedRelease) return; if (!(await checkExtensionsLimit())) return; setGithubInstallStatus(GithubInstallStatus.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(); setPopoverOpen(false); }) .catch((err) => { setGithubInstallError(err.msg); setGithubInstallStatus(GithubInstallStatus.ERROR); }); } async function handleGithubSkillConfirm() { if (!githubSkillInfo) return; if (!(await checkExtensionsLimit())) return; setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING); try { await httpClient.installSkillFromGithub( githubURL.trim(), githubSkillInfo.owner, githubSkillInfo.repo, githubSkillInfo.ref, ); toast.success(t('skills.installSuccess')); refreshPlugins(); refreshSkills(); resetGithubState(); setPopoverOpen(false); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : typeof err === 'object' && err && 'msg' in err ? String((err as { msg?: string }).msg || '') : String(err); setGithubInstallError(errorMessage); setGithubInstallStatus(GithubInstallStatus.ERROR); } } 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 getPopoverWidth(): string { switch (popoverView) { case 'mcp': return 'w-[calc(100vw-2rem)] sm:w-[560px]'; case 'github': return 'w-[calc(100vw-2rem)] sm:w-[560px]'; default: return 'w-[calc(100vw-2rem)] sm:w-[380px]'; } } const extensionActions = ( <> { setPopoverOpen(open); }} > {/* ===== Menu View ===== */} {popoverView === 'menu' && (
{/* File upload area */}

{t('addExtension.uploadExtension')}

{t('addExtension.uploadHint')}

{t('addExtension.orContinueWith')}

)} {/* ===== MCP Form View ===== */} {popoverView === 'mcp' && (

{t('mcp.createServer')}

{}} onNewServerCreated={handleMCPCreated} onDraftChange={setMcpDraft} onTestingChange={setMcpTesting} />
)} {/* ===== GitHub Install View ===== */} {popoverView === 'github' && (

{t('addExtension.installFromGithub')}

{githubInstallStatus === GithubInstallStatus.WAIT_INPUT && (
{t('addExtension.githubUrlHelp')} {t('addExtension.githubUrlTooltip')}
setGithubURL(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleGithubAddressSubmit(); }} />
)} {githubInstallStatus === GithubInstallStatus.SELECT_RELEASE && (

{t('plugins.selectRelease')}

{githubReleases.map((release) => (
handleReleaseSelect(release)} >
{release.name || release.tag_name}
{release.tag_name} •{' '} {new Date( release.published_at, ).toLocaleDateString()}
{release.prerelease && ( Pre )}
))}
{fetchingAssets && (

{t('plugins.loading')}

)}
)} {githubInstallStatus === GithubInstallStatus.SELECT_ASSET && (

{t('plugins.selectAsset')}

{selectedRelease && (
{selectedRelease.name || selectedRelease.tag_name}
)}
{githubAssets.map((asset) => (
handleAssetSelect(asset)} > {asset.name} {formatFileSize(asset.size)}
))}
)} {githubInstallStatus === GithubInstallStatus.ASK_CONFIRM && (

{t('plugins.confirmInstall')}

{selectedRelease && selectedAsset && (
Repository: {githubOwner}/{githubRepo}
Release: {selectedRelease.tag_name}
File: {selectedAsset.name}
)}
)} {githubInstallStatus === GithubInstallStatus.SKILL_PREVIEW && (

{t('addExtension.previewSkill')}

{githubSkillPreview && (
{githubSkillPreview.display_name || githubSkillPreview.name}
{githubSkillPreview.name}
{githubSkillPreview.description && (

{githubSkillPreview.description}

)}
Repository:{' '} {githubSkillInfo?.owner}/{githubSkillInfo?.repo}
File:{' '} {githubSkillInfo?.path}
{githubSkillPreview.package_root && (
Directory:{' '} {githubSkillPreview.package_root}
)}
)}
)} {githubInstallStatus === GithubInstallStatus.INSTALLING && (
{t('plugins.installing')}
)} {githubInstallStatus === GithubInstallStatus.SKILL_INSTALLING && (
{t('skills.installing')}
)} {githubInstallStatus === GithubInstallStatus.ERROR && (

{t('plugins.installFailed')}

{githubInstallError && (

{githubInstallError}

)}
)}
)}
); return ( <>
{ setModalOpen(open); if (!open) { setInstallError(null); } }} > {t('plugins.installPlugin')} {pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (

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

)} {pluginInstallStatus === PluginInstallStatus.INSTALLING && (

{t('plugins.installing')}

)} {pluginInstallStatus === PluginInstallStatus.ERROR && (

{t('plugins.installFailed')}

{installError}

)} {pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( <> )} {pluginInstallStatus === PluginInstallStatus.ERROR && ( )}
{/* Plugin Upload Preview Dialog */} { setPluginUploadPreviewOpen(open); if (!open) { setPluginUploadPreviewFile(null); } }} > {t('plugins.localPreview.title')} {pluginUploadPreviewFile && ( { setPluginUploadPreviewOpen(false); setPluginUploadPreviewFile(null); }} onInstallStarted={() => { setPluginUploadPreviewOpen(false); setPluginUploadPreviewFile(null); }} /> )} {/* Skill Upload Preview Dialog */} { setSkillUploadPreviewOpen(open); if (!open) { setSkillUploadPreviewFile(null); } }} > {t('skills.uploadZip')} {skillUploadPreviewFile && ( { setSkillUploadPreviewOpen(false); setSkillUploadPreviewFile(null); }} onImported={(skillNames) => { setSkillUploadPreviewOpen(false); setSkillUploadPreviewFile(null); void refreshSkills(); const firstSkillName = skillNames[0]; if (firstSkillName) { navigate( `/home/skills?id=${encodeURIComponent(firstSkillName)}`, ); } }} /> )} ); }