From 747ea069aa0c7623a856a79f776179414a18315a Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Mon, 18 May 2026 23:32:56 +0800 Subject: [PATCH] feat: polish extension import flow --- .../pkg/api/http/controller/groups/plugins.py | 113 +++ web/src/app/home/add-extension/page.tsx | 286 +++----- web/src/app/home/add-plugin/page.tsx | 586 ---------------- .../components/home-sidebar/HomeSidebar.tsx | 41 +- .../home-sidebar/SidebarDataContext.tsx | 18 - .../components/kb-docs/FileUploadZone.tsx | 2 +- .../knowledge/components/kb-form/KBForm.tsx | 2 +- web/src/app/home/layout.tsx | 2 +- web/src/app/home/market/page.tsx | 207 ------ .../components/PluginLocalPreviewPanel.tsx | 203 ++++++ .../PluginInstallProgressDialog.tsx | 12 - .../PluginInstallTaskContext.tsx | 15 +- .../PluginInstallTaskQueue.tsx | 17 +- web/src/app/home/plugins/page.tsx | 659 +----------------- .../components/SkillGithubImportPanel.tsx | 645 ----------------- .../components/SkillZipPreviewPanel.tsx | 277 ++++++++ web/src/app/home/skills/page.tsx | 132 +--- web/src/app/infra/http/BackendClient.ts | 23 + web/src/i18n/locales/en-US.ts | 17 +- web/src/i18n/locales/es-ES.ts | 18 +- web/src/i18n/locales/ja-JP.ts | 18 +- web/src/i18n/locales/ru-RU.ts | 17 +- web/src/i18n/locales/th-TH.ts | 17 +- web/src/i18n/locales/vi-VN.ts | 17 +- web/src/i18n/locales/zh-Hans.ts | 17 +- web/src/i18n/locales/zh-Hant.ts | 17 +- web/src/router.tsx | 22 - 27 files changed, 919 insertions(+), 2481 deletions(-) delete mode 100644 web/src/app/home/add-plugin/page.tsx delete mode 100644 web/src/app/home/market/page.tsx create mode 100644 web/src/app/home/plugins/components/PluginLocalPreviewPanel.tsx delete mode 100644 web/src/app/home/skills/components/SkillGithubImportPanel.tsx create mode 100644 web/src/app/home/skills/components/SkillZipPreviewPanel.tsx diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index 7b7e16b9..6b7d33c7 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -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""" diff --git a/web/src/app/home/add-extension/page.tsx b/web/src/app/home/add-extension/page.tsx index 644e7b82..4c8445a6 100644 --- a/web/src/app/home/add-extension/page.tsx +++ b/web/src/app/home/add-extension/page.tsx @@ -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('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 [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); @@ -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() { - {/* Skill Upload Progress Dialog */} + {/* Plugin Upload Preview Dialog */} { - if (!open && skillUploadState !== 'uploading') { - setSkillUploadState('idle'); - setSkillUploadError(null); + setPluginUploadPreviewOpen(open); + if (!open) { + setPluginUploadPreviewFile(null); } }} > - + - - - - {t('plugins.installProgress.title', { - name: skillUploadFileName, - })} - + + + {t('plugins.localPreview.title')} + {pluginUploadPreviewFile && ( + { + setPluginUploadPreviewOpen(false); + setPluginUploadPreviewFile(null); + }} + onInstallStarted={() => { + setPluginUploadPreviewOpen(false); + setPluginUploadPreviewFile(null); + }} + /> + )} + + -
- {/* 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/app/home/add-plugin/page.tsx b/web/src/app/home/add-plugin/page.tsx deleted file mode 100644 index f9cf95bc..00000000 --- a/web/src/app/home/add-plugin/page.tsx +++ /dev/null @@ -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([]); - 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 [installError, setInstallError] = useState(null); - const [pluginInstallStatus, setPluginInstallStatus] = - useState(PluginInstallStatus.WAIT_INPUT); - const fileInputRef = useRef(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 { - 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) => { - 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 ( -
- - -
-

{t('plugins.installFromGithub')}

- -
- -
-
- - - - - {t('plugins.installFromGithub')} - - - {t('plugins.installFromGithubDesc')} - - - - {pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && ( -
-

{t('plugins.enterRepoUrl')}

-
- setGithubURL(e.target.value)} - /> - -
-
- )} - - {pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && ( -
-
-

- {t('plugins.selectRelease')} -

- -
-
- {githubReleases.map((release) => ( - handleReleaseSelect(release)} - > - -
- - {release.name || release.tag_name} - - - {t('plugins.releaseTag', { - tag: release.tag_name, - })}{' '} - •{' '} - {t('plugins.publishedAt', { - date: new Date( - release.published_at, - ).toLocaleDateString(), - })} - -
- {release.prerelease && ( - - {t('plugins.prerelease')} - - )} -
-
- ))} -
- {fetchingAssets && ( -

- {t('plugins.loading')} -

- )} -
- )} - - {pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && ( -
-
-

- {t('plugins.selectAsset')} -

- -
- {selectedRelease && ( -
-
- {selectedRelease.name || selectedRelease.tag_name} -
-
- {selectedRelease.tag_name} -
-
- )} -
- {githubAssets.map((asset) => ( - handleAssetSelect(asset)} - > - - - {asset.name} - - - {t('plugins.assetSize', { - size: formatFileSize(asset.size), - })} - - - - ))} -
-
- )} - - {pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( -
-
-

- {t('plugins.confirmInstall')} -

- -
- {selectedRelease && selectedAsset && ( -
-
- - Repository:{' '} - - - {githubOwner}/{githubRepo} - -
-
- Release: - - {selectedRelease.tag_name} - -
-
- File: - {selectedAsset.name} -
-
- )} -
- -
-
- )} - - {pluginInstallStatus === PluginInstallStatus.INSTALLING && ( -
-

{t('plugins.installing')}

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

{t('plugins.installFailed')}

-

{installError}

-
- -
-
- )} -
-
-
-
-
- ); - } - - // Upload Mode - show file select dialog - if (isUploadMode) { - return ( -
- - -
-

{t('plugins.uploadLocal')}

- -
- -
-
- - - - - {t('plugins.uploadLocal')} - - - {t('plugins.uploadPluginOnly')} - - - -
- -

- {t('plugins.dragToUpload')} -

-

- {t('plugins.selectFileToUpload')} -

-
-
-
-
-
-
- ); - } - - // Default: redirect to add-extension - navigate('/home/add-extension'); - return null; -} \ No newline at end of file diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 22bd2d19..22321298 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -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({ { e.stopPropagation(); - navigate('/home/market'); + navigate('/home/add-extension'); setPopoverOpen((prev) => ({ ...prev, [config.id]: false, @@ -828,8 +826,7 @@ function NavItems({ { 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({ { 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({ { e.stopPropagation(); - setPendingSkillInstallAction('create'); - navigate('/home/skills'); + navigate('/home/skills?action=create'); setPopoverOpen((prev) => ({ ...prev, [config.id]: false, @@ -883,8 +878,7 @@ function NavItems({ { 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({ { 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({ { e.stopPropagation(); - navigate('/home/market'); + navigate('/home/add-extension'); }} > @@ -1002,8 +995,7 @@ function NavItems({ { e.stopPropagation(); - setPendingPluginInstallAction('local'); - navigate('/home/extensions'); + navigate('/home/add-extension?manual=1'); }} > @@ -1012,8 +1004,7 @@ function NavItems({ { e.stopPropagation(); - setPendingPluginInstallAction('github'); - navigate('/home/extensions'); + navigate('/home/add-extension?manual=1'); }} > @@ -1036,8 +1027,7 @@ function NavItems({ { e.stopPropagation(); - setPendingSkillInstallAction('create'); - navigate('/home/skills'); + navigate('/home/skills?action=create'); }} > @@ -1046,8 +1036,7 @@ function NavItems({ { e.stopPropagation(); - setPendingSkillInstallAction('upload'); - navigate('/home/skills'); + navigate('/home/add-extension?manual=1'); }} > @@ -1056,8 +1045,7 @@ function NavItems({ { e.stopPropagation(); - setPendingSkillInstallAction('github'); - navigate('/home/skills'); + navigate('/home/add-extension?manual=1'); }} > @@ -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', ); diff --git a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx index b18b07e7..6f2adb3d 100644 --- a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx +++ b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx @@ -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([]); const [pluginPages, setPluginPages] = useState([]); const [detailEntityName, setDetailEntityName] = useState(null); - const [pendingPluginInstallAction, setPendingPluginInstallAction] = - useState(null); - const [pendingSkillInstallAction, setPendingSkillInstallAction] = - useState(null); const [extensionsGroupByType, setExtensionsGroupByTypeState] = useState(() => { if (typeof window === 'undefined') return false; @@ -320,10 +306,6 @@ export function SidebarDataProvider({ refreshAll, detailEntityName, setDetailEntityName, - pendingPluginInstallAction, - setPendingPluginInstallAction, - pendingSkillInstallAction, - setPendingSkillInstallAction, extensionsGroupByType, setExtensionsGroupByType, }} diff --git a/web/src/app/home/knowledge/components/kb-docs/FileUploadZone.tsx b/web/src/app/home/knowledge/components/kb-docs/FileUploadZone.tsx index ab1cbc01..33fd4b19 100644 --- a/web/src/app/home/knowledge/components/kb-docs/FileUploadZone.tsx +++ b/web/src/app/home/knowledge/components/kb-docs/FileUploadZone.tsx @@ -222,7 +222,7 @@ export default function FileUploadZone({ {t('knowledge.documentsTab.noParserAvailable')}

{t('knowledge.documentsTab.installParserHint')} diff --git a/web/src/app/home/knowledge/components/kb-form/KBForm.tsx b/web/src/app/home/knowledge/components/kb-form/KBForm.tsx index af9250fb..b0c805a7 100644 --- a/web/src/app/home/knowledge/components/kb-form/KBForm.tsx +++ b/web/src/app/home/knowledge/components/kb-form/KBForm.tsx @@ -304,7 +304,7 @@ export default function KBForm({ {t('knowledge.noEnginesAvailable')}

{t('knowledge.installEngineHint')} diff --git a/web/src/app/home/layout.tsx b/web/src/app/home/layout.tsx index 9ac9770a..0fc7022e 100644 --- a/web/src/app/home/layout.tsx +++ b/web/src/app/home/layout.tsx @@ -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', diff --git a/web/src/app/home/market/page.tsx b/web/src/app/home/market/page.tsx deleted file mode 100644 index b1087c8d..00000000 --- a/web/src/app/home/market/page.tsx +++ /dev/null @@ -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 ( -
-

{t('plugins.marketplace')}

-
- ); - } - - return ; -} - -function MarketplaceContent() { - const { t } = useTranslation(); - const { refreshPlugins } = useSidebarData(); - const { - addTask, - setSelectedTaskId, - registerOnTaskComplete, - unregisterOnTaskComplete, - } = 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); - - 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; - } - - // 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 ( - <> -
- -
- - { - 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 && ( - - )} - -
-
- - ); -} diff --git a/web/src/app/home/plugins/components/PluginLocalPreviewPanel.tsx b/web/src/app/home/plugins/components/PluginLocalPreviewPanel.tsx new file mode 100644 index 00000000..52434ed2 --- /dev/null +++ b/web/src/app/home/plugins/components/PluginLocalPreviewPanel.tsx @@ -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 +>; + +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(null); + const [previewing, setPreviewing] = useState(false); + const [installing, setInstalling] = useState(false); + const [errorMessage, setErrorMessage] = useState(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 ( +
+
+
+ {previewing ? ( + + ) : ( + + )} +
+
+
+ {previewing + ? t('plugins.localPreview.unpacking') + : t('plugins.localPreview.unpackComplete')} +
+
+ {file.name} · {formatFileSize(file.size)} +
+
+
+ + {preview && ( +
+
+ + {t('plugins.localPreview.pluginInfo')} +
+
+
+ + {t('plugins.localPreview.name')} + + + {label || metadata?.name || '-'} + +
+
+ + {t('plugins.localPreview.author')} + + {metadata?.author || '-'} +
+
+ + {t('plugins.localPreview.version')} + + {metadata?.version || '-'} +
+
+ {description && ( +

+ {description} +

+ )} +
+ +
+
+ )} + + {preview && ( +
+ + {t('plugins.localPreview.ready')} +
+ )} + + {errorMessage && ( +
+ {errorMessage} +
+ )} + +
+ {onCancel && ( + + )} + +
+
+ ); +} diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx index 9718bae4..3ad5bef9 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx @@ -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 { diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx index a3f774e2..edc27d5e 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx @@ -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); diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx index 27e5be0d..af0a867b 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx @@ -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 = { [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({
{task.pluginName}
{getTypeLabel()} diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index d4da110c..0f23f1a6 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -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 ; } @@ -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('local'); - const [installInfo] = useState>({}); - const [pluginInstallStatus, setPluginInstallStatus] = - useState(PluginInstallStatus.WAIT_INPUT); - const [installError, setInstallError] = useState(null); - 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 [isDragOver, setIsDragOver] = useState(false); + const { registerOnTaskComplete, unregisterOnTaskComplete } = + usePluginInstallTasks(); const [pluginSystemStatus, setPluginSystemStatus] = useState(null); const [statusLoading, setStatusLoading] = useState(true); - const fileInputRef = useRef(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('all'); - const groupByType = useSidebarData().extensionsGroupByType; - const setGroupByType = useSidebarData().setExtensionsGroupByType; + const pluginInstalledRef = useRef(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(null); - - function resetGithubState() { - setGithubURL(''); - setGithubReleases([]); - setSelectedRelease(null); - setGithubAssets([]); - setSelectedAsset(null); - setGithubOwner(''); - setGithubRepo(''); - setFetchingReleases(false); - setFetchingAssets(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; - } - - 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); - } - } - - function installPlugin( - installSource: string, - installInfo: Record, - ) { - 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) => { - 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 ( -
- - - {/* Header bar with filter tabs, debug info, and task queue */} +
- {/* Header with icon and title */}

@@ -596,7 +217,6 @@ function PluginListView() {

- {/* Debug URL row */}
- {/* Debug Key row */}
- {/* Inline GitHub install flow */} - {showGithubInstall && ( -
- - - - - {t('plugins.installPlugin')} - - - - - {/* Step 1: Enter repo URL */} - {pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && ( -
-

{t('plugins.enterRepoUrl')}

-
- setGithubURL(e.target.value)} - /> - -
-
- )} - - {/* Step 2: Select release */} - {pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && ( -
-
-

- {t('plugins.selectRelease')} -

- -
-
- {githubReleases.map((release) => ( - handleReleaseSelect(release)} - > - -
- - {release.name || release.tag_name} - - - {t('plugins.releaseTag', { - tag: release.tag_name, - })}{' '} - •{' '} - {t('plugins.publishedAt', { - date: new Date( - release.published_at, - ).toLocaleDateString(), - })} - -
- {release.prerelease && ( - - {t('plugins.prerelease')} - - )} -
-
- ))} -
- {fetchingAssets && ( -

- {t('plugins.loading')} -

- )} -
- )} - - {/* Step 3: Select asset */} - {pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && ( -
-
-

- {t('plugins.selectAsset')} -

- -
- {selectedRelease && ( -
-
- {selectedRelease.name || selectedRelease.tag_name} -
-
- {selectedRelease.tag_name} -
-
- )} -
- {githubAssets.map((asset) => ( - handleAssetSelect(asset)} - > - - - {asset.name} - - - {t('plugins.assetSize', { - size: formatFileSize(asset.size), - })} - - - - ))} -
-
- )} - - {/* Step 4: Confirm install */} - {pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( -
-
-

- {t('plugins.confirmInstall')} -

- -
- {selectedRelease && selectedAsset && ( -
-
- - Repository:{' '} - - - {githubOwner}/{githubRepo} - -
-
- Release: - - {selectedRelease.tag_name} - -
-
- File: - {selectedAsset.name} -
-
- )} -
- -
-
- )} - - {/* Installing state */} - {pluginInstallStatus === PluginInstallStatus.INSTALLING && ( -
-

{t('plugins.installing')}

-
- )} - - {/* Error state */} - {pluginInstallStatus === PluginInstallStatus.ERROR && ( -
-

{t('plugins.installFailed')}

-

{installError}

-
- -
-
- )} -
-
-
- )} - - {/* Installed plugins grid */}
- - {isDragOver && ( -
- - - -

{t('plugins.dragToUpload')}

-
-
-
- )}
); } diff --git a/web/src/app/home/skills/components/SkillGithubImportPanel.tsx b/web/src/app/home/skills/components/SkillGithubImportPanel.tsx deleted file mode 100644 index c3eb0941..00000000 --- a/web/src/app/home/skills/components/SkillGithubImportPanel.tsx +++ /dev/null @@ -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([]); - const [selectedRelease, setSelectedRelease] = useState( - null, - ); - const [githubAssets, setGithubAssets] = useState([]); - const [selectedAsset, setSelectedAsset] = useState(null); - const [previewSkills, setPreviewSkills] = useState([]); - const [selectedPreviewPaths, setSelectedPreviewPaths] = useState( - [], - ); - 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(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(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 ( -
- {skills.map((skill) => { - const path = previewPath(skill); - const selected = selectedPaths.includes(path); - const active = path === activePath; - return ( -
-
- - toggleSelection( - path, - selectedPaths, - setSelectedPaths, - setActivePath, - ) - } - /> - -
-
- ); - })} -
- ); - } - - function renderPreviewDetail(skill: PreviewSkill | null) { - if (!skill) return null; - - return ( - <> -
-
- {t('skills.displayName')}:{' '} - {skill.display_name || '-'} -
-
- {t('skills.skillSlug')}:{' '} - {skill.name} -
-
- {t('skills.skillDescription')}:{' '} - {skill.description} -
-
- {t('skills.packageRoot')}:{' '} - {skill.package_root} -
-
- -
-
- {t('skills.skillInstructions')} -
-
-            {skill.instructions || ''}
-          
-
- - ); - } - - return ( -
- {(mode === 'all' || mode === 'github') && ( - - - - - {t('skills.importFromGithub')} - - - - {githubReleases.length === 0 && ( -
- setGithubURL(e.target.value)} - /> - -
- )} - - {githubReleases.length > 0 && !selectedRelease && ( -
- {githubReleases.map((release) => ( - - ))} -
- )} - - {selectedRelease && previewSkills.length === 0 && ( -
-
-
-
- {selectedRelease.name || selectedRelease.tag_name} -
-
- {t('skills.releaseTag', { - tag: selectedRelease.tag_name, - })} -
-
- -
- - {fetchingAssets && ( -
- {t('skills.loading')} -
- )} - - {!fetchingAssets && githubAssets.length > 0 && ( -
- {githubAssets.map((asset) => ( - - ))} -
- )} -
- )} - - {previewSkills.length > 0 && selectedRelease && selectedAsset && ( -
-
-
{t('skills.preview')}
- -
- - {renderCandidateSelector( - previewSkills, - selectedPreviewPaths, - activePreviewPath, - setSelectedPreviewPaths, - setActivePreviewPath, - )} - {renderPreviewDetail(activePreviewSkill)} - -
- -
-
- )} -
-
- )} - - {(mode === 'all' || mode === 'upload') && ( - - - - - {t('skills.uploadZip')} - - - - { - const file = e.target.files?.[0] ?? null; - setUploadFile(file); - setUploadPreviewSkills([]); - setSelectedUploadPreviewPaths([]); - setActiveUploadPreviewPath(''); - setErrorMessage(null); - }} - /> - {uploadFile && ( -
- {uploadFile.name} -
- )} - -
- -
- - {uploadPreviewSkills.length > 0 && uploadFile && ( -
-
{t('skills.preview')}
- - {renderCandidateSelector( - uploadPreviewSkills, - selectedUploadPreviewPaths, - activeUploadPreviewPath, - setSelectedUploadPreviewPaths, - setActiveUploadPreviewPath, - )} - {renderPreviewDetail(activeUploadPreviewSkill)} - -
- -
-
- )} - - {errorMessage && ( -
{errorMessage}
- )} -
-
- )} -
- ); -} diff --git a/web/src/app/home/skills/components/SkillZipPreviewPanel.tsx b/web/src/app/home/skills/components/SkillZipPreviewPanel.tsx new file mode 100644 index 00000000..1970f8e5 --- /dev/null +++ b/web/src/app/home/skills/components/SkillZipPreviewPanel.tsx @@ -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([]); + const [selectedPaths, setSelectedPaths] = useState([]); + const [activePath, setActivePath] = useState(''); + const [previewing, setPreviewing] = useState(false); + const [installing, setInstalling] = useState(false); + const [errorMessage, setErrorMessage] = useState(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 ( +
+
+
+ {previewing ? ( + + ) : ( + + )} +
+
+
+ {previewing ? t('skills.loading') : t('skills.preview')} +
+
+ {file.name} · {formatFileSize(file.size)} +
+
+
+ + {previewSkills.length > 0 && ( +
1 && 'md:grid-cols-[240px_minmax(0,1fr)]', + )} + > + {previewSkills.length > 1 && ( +
+ {previewSkills.map((skill) => { + const path = previewPath(skill); + const displayPath = displayPreviewPath(skill); + const selected = selectedPaths.includes(path); + const active = activePath === path; + + return ( + + ); + })} +
+ )} + + {activeSkill && ( +
+
+ +

+ {activeSkill.display_name || activeSkill.name} +

+
+ + {activeSkill.description && ( +

+ {activeSkill.description} +

+ )} + + {activeInstructions && ( +
+
+ {t('skills.previewInstructions')} +
+
+ {activeInstructions} +
+
+ )} +
+ )} +
+ )} + + {errorMessage && ( +
+ {errorMessage} +
+ )} + +
+ {onCancel && ( + + )} + +
+
+ ); +} diff --git a/web/src/app/home/skills/page.tsx b/web/src/app/home/skills/page.tsx index 7fb305f1..22d5c78f 100644 --- a/web/src/app/home/skills/page.tsx +++ b/web/src/app/home/skills/page.tsx @@ -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(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 ; } - 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 ( -
-
-
-

{t('skills.createSkill')}

-

- {t('skills.createSkillDescription')} -

-
-
- - -
-
-
- handleImportedSkills([skillName])} - onSkillUpdated={() => {}} - /> -
-
- ); + if (!isCreateView) { + return null; } - if (activeView === 'github') { - return ( -
-
-

- {t('skills.importFromGithub')} -

+ return ( +
+
+
+

{t('skills.createSkill')}

+

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

+
+
-
-
-
- -
-
-
- ); - } - - if (activeView === 'upload') { - return ( -
-
-

{t('skills.uploadZip')}

-
-
-
- -
-
- ); - } - - navigate('/home/add-extension'); - return null; +
+ {}} + /> +
+
+ ); } diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 5a46f88c..e13beae3 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -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; + metadata: { + author?: string; + name?: string; + version?: string; + label?: I18nObject; + description?: I18nObject; + repository?: string; + }; + component_types: string[]; + component_counts: Record; + 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, diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index b6db7800..b1e80c36 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -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)', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index eb38d29b..1edad872 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -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: diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index dfff5580..c55409fa 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -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: 'パッケージディレクトリ', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 84b4d046..c073ee31 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -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)', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 39b0c7e1..73588eb4 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -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 เห็น)', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index c67e0c36..9256a189 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -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: diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index bef43b37..f3411be5 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -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: '技能目录', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index b51b8fa7..0f2fe7e1 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -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: '技能目錄', diff --git a/web/src/router.tsx b/web/src/router.tsx index 0a60a331..28bc1284 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -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([ ), }, - { - path: '/home/add-plugin', - element: ( - }> - - - - - ), - }, - { - path: '/home/market', - element: ( - }> - - - - - ), - }, { path: '/home/mcp', element: (