diff --git a/web/src/app/home/add-extension/page.tsx b/web/src/app/home/add-extension/page.tsx index b8922a3a..b894be9d 100644 --- a/web/src/app/home/add-extension/page.tsx +++ b/web/src/app/home/add-extension/page.tsx @@ -8,20 +8,75 @@ import { } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Download, PlusIcon, ChevronDownIcon } from 'lucide-react'; -import React, { useState, useCallback, useEffect } from 'react'; + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + Download, + PlusIcon, + ChevronLeft, + Server, + Github, + BookOpen, + FileArchive, + Loader2, + CheckCircle2, + XCircle, +} from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import { useNavigate } 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 { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; -import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task'; +import { + usePluginInstallTasks, +} from '@/app/home/plugins/components/plugin-install-task'; +import MCPForm from '@/app/home/mcp/components/mcp-form/MCPForm'; +import type { MCPFormHandle } from '@/app/home/mcp/components/mcp-form/MCPForm'; +import SkillForm from '@/app/home/skills/components/skill-form/SkillForm'; +import { Progress } from '@/components/ui/progress'; +import { cn } from '@/lib/utils'; + +type PopoverView = 'menu' | 'mcp' | 'skill' | 'github'; + +enum GithubInstallStatus { + 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; +} enum PluginInstallStatus { ASK_CONFIRM = 'ask_confirm', @@ -31,7 +86,6 @@ enum PluginInstallStatus { export default function AddExtensionPage() { const { t } = useTranslation(); - const navigate = useNavigate(); if (!systemInfo?.enable_marketplace) { return ( @@ -47,12 +101,13 @@ export default function AddExtensionPage() { function AddExtensionContent() { const { t } = useTranslation(); const navigate = useNavigate(); - const { refreshPlugins } = useSidebarData(); + const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData(); const { addTask, setSelectedTaskId, registerOnTaskComplete, unregisterOnTaskComplete, + clearCompletedTasks, } = usePluginInstallTasks(); const [modalOpen, setModalOpen] = useState(false); const [installInfo, setInstallInfo] = useState>({}); @@ -62,6 +117,36 @@ function AddExtensionContent() { 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 [skillUploadState, setSkillUploadState] = useState< + 'idle' | 'uploading' | 'done' | 'error' + >('idle'); + const [skillUploadFileName, setSkillUploadFileName] = useState(''); + const [skillUploadError, setSkillUploadError] = useState(null); + const fileInputRef = useRef(null); + const mcpFormRef = useRef(null); + const [mcpTesting, setMcpTesting] = useState(false); + + // 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 [githubInstallStatus, setGithubInstallStatus] = + useState(GithubInstallStatus.WAIT_INPUT); + const [githubInstallError, setGithubInstallError] = useState(null); + + useEffect(() => { + // Clear any stale completed tasks on mount + clearCompletedTasks(); + }, []); useEffect(() => { const onComplete = (_taskId: number, success: boolean) => { @@ -88,7 +173,7 @@ function AddExtensionContent() { setInstallError(null); setModalOpen(true); }, - [t], + [], ); function handleModalConfirm() { @@ -118,71 +203,707 @@ function AddExtensionContent() { }); } + 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); + 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 || '')); + }); + } 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); + }); + } + }, + [t, addTask, setSelectedTaskId, refreshPlugins], + ); + + 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) { + refreshMCPServers(); + setPopoverView('menu'); + setPopoverOpen(false); + } + + function handleSkillCreated(skillName: string) { + refreshPlugins(); + refreshSkills(); + setPopoverView('menu'); + setPopoverOpen(false); + } + + async function checkExtensionsLimit(): Promise { + 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); + setGithubInstallStatus(GithubInstallStatus.WAIT_INPUT); + setGithubInstallError(null); + } + + async function fetchGithubReleases() { + if (!githubURL.trim()) { + toast.error(t('plugins.enterRepoUrl')); + return; + } + + setFetchingReleases(true); + setGithubInstallError(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 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); + }); + } + + 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-[500px]'; + case 'skill': + return 'w-[460px]'; + case 'github': + return 'w-[460px]'; + default: + return 'w-[360px]'; + } + } + const extensionActions = ( <> - - - - + - - - navigate('/home/skills?action=create')} - > - {t('skills.createManually')} - - navigate('/home/skills?action=upload')} - > - {t('skills.uploadZip')} - - navigate('/home/skills?action=github')} - > - {t('skills.importFromGithub')} - - - + + + {/* ===== Menu View ===== */} + {popoverView === 'menu' && ( +
+ {/* File upload area */} +
+ +

+ {t('addExtension.uploadExtension')} +

+

+ {t('addExtension.uploadHint')} +

+
- - - - - - navigate('/home/add-plugin?action=github')} - > - {t('plugins.installFromGithub')} - - navigate('/home/add-plugin?action=upload')} - > - {t('plugins.uploadLocal')} - - - + {/* Divider */} +
+
+ +
+
+ + {t('addExtension.orContinueWith')} + +
+
+ + {/* MCP Config button */} + + + {/* Two side-by-side buttons */} +
+ + +
+ + {/* Hints for the two buttons */} +
+

+ {t('addExtension.installFromGithubHint')} +

+

+ {t('addExtension.createSkillHint')} +

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

+ {t('mcp.createServer')} +

+
+ +
+ {}} + onNewServerCreated={handleMCPCreated} + onTestingChange={setMcpTesting} + /> +
+ +
+ + +
+
+ )} + + {/* ===== Skill Form View ===== */} + {popoverView === 'skill' && ( +
+
+ +

+ {t('skills.createSkill')} +

+
+ +
+ {}} + /> +
+ +
+ +
+
+ )} + + {/* ===== GitHub Install View ===== */} + {popoverView === 'github' && ( +
+
+ +

+ {t('plugins.installFromGithub')} +

+
+ +
+ {githubInstallStatus === GithubInstallStatus.WAIT_INPUT && ( +
+

+ {t('plugins.enterRepoUrl')} +

+ setGithubURL(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') fetchGithubReleases(); + }} + /> + +
+ )} + + {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.INSTALLING && ( +
+ + {t('plugins.installing')} +
+ )} + + {githubInstallStatus === GithubInstallStatus.ERROR && ( +
+

+ {t('plugins.installFailed')} +

+ {githubInstallError && ( +

+ {githubInstallError} +

+ )} + +
+ )} +
+
+ )} +
+ ); @@ -257,6 +978,173 @@ function AddExtensionContent() { + + {/* Skill Upload Progress Dialog */} + { + if (!open && skillUploadState !== 'uploading') { + setSkillUploadState('idle'); + setSkillUploadError(null); + } + }} + > + + + + + + {t('plugins.installProgress.title', { + name: skillUploadFileName, + })} + + + + +
+ {/* Overall progress bar */} +
+
+ + {skillUploadState === 'done' + ? t('plugins.installProgress.completed') + : skillUploadState === 'error' + ? t('plugins.installProgress.failed') + : t('plugins.installProgress.overallProgress')} + + + {skillUploadState === 'done' + ? '100%' + : skillUploadState === 'error' + ? '0%' + : '50%'} + +
+ 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', + )} + /> +
+ + {/* Stage display */} +
+ {skillUploadState === 'uploading' && ( +
+
+ +
+
+ + {t('plugins.installProgress.downloading')} + +
+
+ )} + + {skillUploadState === 'done' && ( +
+
+ +
+
+ + {t('plugins.installProgress.downloading')} + +
+
+ )} + + {skillUploadState === 'error' && ( +
+
+ +
+
+ + {t('plugins.installProgress.downloading')} + +
+
+ )} +
+ + {/* Done banner */} + {skillUploadState === 'done' && ( +
+ + + {t('plugins.installProgress.installComplete')} + +
+ )} + + {/* Error detail */} + {skillUploadState === 'error' && skillUploadError && ( +
+

+ {skillUploadError} +

+
+ )} +
+ +
+ +
+
+
); } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index bf35f6a4..4c015c0e 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1374,7 +1374,7 @@ const enUS = { selectSkills: 'Select Skills', addSkill: 'Add Skill', builtin: 'Built-in', - importFromGithub: 'Import from GitHub', + importFromGithub: 'Install Plugin from GitHub', createManually: 'Create Manually', uploadZip: 'Upload ZIP Package', uploadZipOnly: 'Only .zip skill packages are supported', @@ -1469,6 +1469,18 @@ const enUS = { backToWorkbench: 'Back to Workbench', }, }, + addExtension: { + manualAdd: 'Manual Add', + uploadExtension: 'Drag & drop or click to upload', + uploadHint: 'Supports .zip (skills) and .lbpkg (plugins) files', + orContinueWith: 'or choose an action below', + installFromGithub: 'Install Plugin from GitHub', + installFromGithubHint: 'Install plugin extension from GitHub Release', + createSkill: 'Create New Skill', + createSkillHint: 'Manually create a new skill extension', + unsupportedFileType: + 'Unsupported file type. Only .zip and .lbpkg files are supported', + }, errorPage: { unexpectedError: 'Something went wrong', unexpectedErrorDescription: diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index c6be05df..4c0fe996 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1386,6 +1386,18 @@ const jaJP = { backToWorkbench: 'ワークベンチに戻る', }, }, + addExtension: { + manualAdd: '手動追加', + uploadExtension: 'ドラッグ&ドロップまたはクリックしてアップロード', + uploadHint: '.zip(スキル)と.lbpkg(プラグイン)ファイルに対応', + orContinueWith: 'または以下の操作を選択', + installFromGithub: 'GitHubからプラグインをインストール', + installFromGithubHint: 'GitHub Releaseからプラグイン拡張をインストール', + createSkill: '新しいスキルを作成', + createSkillHint: '新しいスキル拡張を手動で作成', + unsupportedFileType: + 'サポートされていないファイルタイプです。.zipと.lbpkgファイルのみサポートされています', + }, errorPage: { unexpectedError: 'エラーが発生しました', unexpectedErrorDescription: diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 156fbc82..7c0e5713 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1317,7 +1317,7 @@ const zhHans = { selectSkills: '选择技能', builtin: '内置', addSkill: '添加技能', - importFromGithub: '从 GitHub 导入', + importFromGithub: '从 GitHub 安装插件', createManually: '手动创建', uploadZip: '上传 ZIP 包', uploadZipOnly: '仅支持 .zip 技能包', @@ -1408,6 +1408,17 @@ const zhHans = { backToWorkbench: '返回工作台', }, }, + addExtension: { + manualAdd: '手动添加', + uploadExtension: '拖拽或点击上传扩展包', + uploadHint: '支持 .zip(技能)和 .lbpkg(插件)文件', + orContinueWith: '或选择以下操作', + installFromGithub: '从 GitHub 安装插件', + installFromGithubHint: '从 GitHub Release 安装插件扩展', + createSkill: '创建新的技能', + createSkillHint: '手动创建一个新的技能扩展', + unsupportedFileType: '不支持的文件类型,仅支持 .zip 和 .lbpkg 文件', + }, errorPage: { unexpectedError: '出错了', unexpectedErrorDescription: '发生了意外错误,请稍后重试。', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 98c4bb76..9e18d8a7 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1321,6 +1321,17 @@ const zhHant = { backToWorkbench: '返回工作台', }, }, + addExtension: { + manualAdd: '手動新增', + uploadExtension: '拖拽或點擊上傳擴充套件', + uploadHint: '支援 .zip(技能)和 .lbpkg(插件)檔案', + orContinueWith: '或選擇以下操作', + installFromGithub: '從 GitHub 安裝插件', + installFromGithubHint: '從 GitHub Release 安裝插件擴充', + createSkill: '建立新的技能', + createSkillHint: '手動建立一個新的技能擴充', + unsupportedFileType: '不支援的檔案類型,僅支援 .zip 和 .lbpkg 檔案', + }, errorPage: { unexpectedError: '出錯了', unexpectedErrorDescription: '發生了意外錯誤,請稍後重試。', @@ -1374,7 +1385,7 @@ const zhHant = { selectSkills: '選擇技能', builtin: '內建', addSkill: '添加技能', - importFromGithub: '從 GitHub 導入', + importFromGithub: '從 GitHub 安裝插件', createManually: '手動創建', uploadZip: '上傳 ZIP 包', uploadZipOnly: '僅支援 .zip 技能包',