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 */}
-
- {/* 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%'}
-
-
-
-
- {/* 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 (
- <>
-
-
-
-
-
- >
- );
-}
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: (