feat: polish extension import flow

This commit is contained in:
Junyan Qin
2026-05-18 23:32:56 +08:00
parent 9e62227104
commit 747ea069aa
27 changed files with 919 additions and 2481 deletions
@@ -1,11 +1,14 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import io
import quart import quart
import re import re
import httpx import httpx
import uuid import uuid
import os import os
import zipfile
import yaml
from urllib.parse import urlparse from urllib.parse import urlparse
import posixpath import posixpath
@@ -42,6 +45,60 @@ def _normalize_plugin_asset_path(filepath: str) -> str | None:
@group.group_class('plugins', '/api/v1/plugins') @group.group_class('plugins', '/api/v1/plugins')
class PluginsRouterGroup(group.RouterGroup): 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 @staticmethod
def _parse_github_repo_url(repo_url: str) -> dict | None: def _parse_github_repo_url(repo_url: str) -> dict | None:
raw_url = str(repo_url or '').strip() raw_url = str(repo_url or '').strip()
@@ -490,6 +547,62 @@ class PluginsRouterGroup(group.RouterGroup):
return self.success(data={'task_id': wrapper.id}) 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) @self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str: async def _() -> str:
"""Upload a file for plugin configuration""" """Upload a file for plugin configuration"""
+89 -197
View File
@@ -22,8 +22,6 @@ import {
BookOpen, BookOpen,
FileArchive, FileArchive,
Loader2, Loader2,
CheckCircle2,
XCircle,
CircleHelp, CircleHelp,
} from 'lucide-react'; } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -33,7 +31,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import React, { useState, useCallback, useEffect, useRef } from 'react'; 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 { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -46,8 +44,8 @@ import type {
MCPFormDraft, MCPFormDraft,
MCPFormHandle, MCPFormHandle,
} from '@/app/home/mcp/components/mcp-form/MCPForm'; } from '@/app/home/mcp/components/mcp-form/MCPForm';
import { Progress } from '@/components/ui/progress'; import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel';
import { cn } from '@/lib/utils'; import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel';
type PopoverView = 'menu' | 'mcp' | 'github'; type PopoverView = 'menu' | 'mcp' | 'github';
@@ -151,6 +149,7 @@ export default function AddExtensionPage() {
function AddExtensionContent() { function AddExtensionContent() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData(); const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
const { const {
addTask, addTask,
@@ -170,11 +169,12 @@ function AddExtensionContent() {
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const [popoverView, setPopoverView] = useState<PopoverView>('menu'); const [popoverView, setPopoverView] = useState<PopoverView>('menu');
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [skillUploadState, setSkillUploadState] = useState< const [skillUploadPreviewOpen, setSkillUploadPreviewOpen] = useState(false);
'idle' | 'uploading' | 'done' | 'error' const [skillUploadPreviewFile, setSkillUploadPreviewFile] =
>('idle'); useState<File | null>(null);
const [skillUploadFileName, setSkillUploadFileName] = useState(''); const [pluginUploadPreviewOpen, setPluginUploadPreviewOpen] = useState(false);
const [skillUploadError, setSkillUploadError] = useState<string | null>(null); const [pluginUploadPreviewFile, setPluginUploadPreviewFile] =
useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const mcpFormRef = useRef<MCPFormHandle>(null); const mcpFormRef = useRef<MCPFormHandle>(null);
const [mcpTesting, setMcpTesting] = useState(false); const [mcpTesting, setMcpTesting] = useState(false);
@@ -209,6 +209,21 @@ function AddExtensionContent() {
clearCompletedTasks(); clearCompletedTasks();
}, [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(() => { useEffect(() => {
const onComplete = (_taskId: number, success: boolean) => { const onComplete = (_taskId: number, success: boolean) => {
if (success) { if (success) {
@@ -282,49 +297,20 @@ function AddExtensionContent() {
} }
const extType = getExtensionTypeFromFile(file); const extType = getExtensionTypeFromFile(file);
const fileName = file.name;
const fileSize = file.size;
setPopoverOpen(false); setPopoverOpen(false);
// Clear any selected task to avoid showing stale dialogs // Clear any selected task to avoid showing stale dialogs
setSelectedTaskId(null); setSelectedTaskId(null);
if (extType === 'plugin') { if (extType === 'plugin') {
httpClient setPluginUploadPreviewFile(file);
.installPluginFromLocal(file) setPluginUploadPreviewOpen(true);
.then((resp) => {
const taskId = resp.task_id;
const taskKey = `local-${taskId}`;
addTask({
taskId,
pluginName: fileName,
source: 'local',
extensionType: 'plugin',
fileSize,
});
setSelectedTaskId(taskKey);
})
.catch((err: { msg?: string }) => {
toast.error(t('plugins.installFailed') + (err.msg || ''));
});
} else { } else {
setSkillUploadFileName(fileName); setSkillUploadPreviewFile(file);
setSkillUploadState('uploading'); setSkillUploadPreviewOpen(true);
setSkillUploadError(null);
httpClient
.installSkillFromUpload(file)
.then(() => {
setSkillUploadState('done');
refreshPlugins();
refreshSkills();
})
.catch((err: { msg?: string }) => {
setSkillUploadState('error');
setSkillUploadError(err.msg || null);
});
} }
}, },
[t, addTask, setSelectedTaskId, refreshPlugins, refreshSkills], [t, setSelectedTaskId],
); );
const handleFileSelect = useCallback(() => { const handleFileSelect = useCallback(() => {
@@ -1211,170 +1197,76 @@ function AddExtensionContent() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Skill Upload Progress Dialog */} {/* Plugin Upload Preview Dialog */}
<Dialog <Dialog
open={skillUploadState !== 'idle'} open={pluginUploadPreviewOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open && skillUploadState !== 'uploading') { setPluginUploadPreviewOpen(open);
setSkillUploadState('idle'); if (!open) {
setSkillUploadError(null); setPluginUploadPreviewFile(null);
} }
}} }}
> >
<DialogContent <DialogContent className="w-[calc(100vw-2rem)] sm:max-w-xl max-h-[85vh] overflow-y-auto p-4 sm:p-6">
className="sm:max-w-lg w-[90vw] max-h-[80vh] p-4 sm:p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto overflow-x-hidden"
hideCloseButton={skillUploadState === 'uploading'}
>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-start gap-3"> <DialogTitle className="flex items-center gap-2">
<Download className="size-5 shrink-0 mt-0.5" /> <FileArchive className="size-5" />
<span className="break-words"> <span>{t('plugins.localPreview.title')}</span>
{t('plugins.installProgress.title', {
name: skillUploadFileName,
})}
</span>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
{pluginUploadPreviewFile && (
<PluginLocalPreviewPanel
file={pluginUploadPreviewFile}
onCancel={() => {
setPluginUploadPreviewOpen(false);
setPluginUploadPreviewFile(null);
}}
onInstallStarted={() => {
setPluginUploadPreviewOpen(false);
setPluginUploadPreviewFile(null);
}}
/>
)}
</DialogContent>
</Dialog>
<div className="space-y-4"> {/* Skill Upload Preview Dialog */}
{/* Overall progress bar */} <Dialog
<div className="space-y-2"> open={skillUploadPreviewOpen}
<div className="flex items-center justify-between gap-2"> onOpenChange={(open) => {
<span setSkillUploadPreviewOpen(open);
className={cn( if (!open) {
'text-sm font-medium shrink-0', setSkillUploadPreviewFile(null);
skillUploadState === 'done' }
? 'text-green-700 dark:text-green-300' }}
: skillUploadState === 'error' >
? 'text-red-700 dark:text-red-300' <DialogContent className="w-[calc(100vw-2rem)] sm:max-w-3xl max-h-[85vh] overflow-y-auto p-4 sm:p-6">
: 'text-blue-700 dark:text-blue-300', <DialogHeader>
)} <DialogTitle className="flex items-center gap-2">
> <FileArchive className="size-5" />
{skillUploadState === 'done' <span>{t('skills.uploadZip')}</span>
? t('plugins.installProgress.completed') </DialogTitle>
: skillUploadState === 'error' </DialogHeader>
? t('plugins.installProgress.failed') {skillUploadPreviewFile && (
: t('plugins.installProgress.overallProgress')} <SkillZipPreviewPanel
</span> file={skillUploadPreviewFile}
<span onCancel={() => {
className={cn( setSkillUploadPreviewOpen(false);
'text-sm font-medium', setSkillUploadPreviewFile(null);
skillUploadState === 'done' }}
? 'text-green-600 dark:text-green-400' onImported={(skillNames) => {
: skillUploadState === 'error' setSkillUploadPreviewOpen(false);
? 'text-red-600 dark:text-red-400' setSkillUploadPreviewFile(null);
: 'text-blue-600 dark:text-blue-400', void refreshSkills();
)} const firstSkillName = skillNames[0];
> if (firstSkillName) {
{skillUploadState === 'done' navigate(
? '100%' `/home/skills?id=${encodeURIComponent(firstSkillName)}`,
: skillUploadState === 'error' );
? '0%'
: '50%'}
</span>
</div>
<Progress
value={
skillUploadState === 'done'
? 100
: skillUploadState === 'error'
? 0
: 50
}
className={cn(
'h-2.5',
'[&>div]:bg-blue-500 dark:[&>div]:bg-blue-400',
'bg-blue-100 dark:bg-blue-900/30',
skillUploadState === 'done' &&
'[&>div]:bg-green-500 dark:[&>div]:bg-green-400 bg-green-100 dark:bg-green-900/30',
skillUploadState === 'error' &&
'[&>div]:bg-red-500 dark:[&>div]:bg-red-400 bg-red-100 dark:bg-red-900/30',
)}
/>
</div>
{/* Stage display */}
<div className="space-y-1.5">
{skillUploadState === 'uploading' && (
<div className="flex items-center gap-2 sm:gap-3 px-2 sm:px-3 py-2 sm:py-2.5 rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800">
<div className="flex items-center justify-center w-7 h-7 rounded-full shrink-0 bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
{t('plugins.installProgress.downloading')}
</span>
</div>
</div>
)}
{skillUploadState === 'done' && (
<div className="flex items-center gap-2 sm:gap-3 px-2 sm:px-3 py-2 sm:py-2.5 rounded-lg bg-green-50/50 dark:bg-green-950/15 border border-green-100 dark:border-green-900/50">
<div className="flex items-center justify-center w-7 h-7 rounded-full shrink-0 bg-green-100 dark:bg-green-900/40 text-green-600 dark:text-green-400">
<CheckCircle2 className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-green-600 dark:text-green-400">
{t('plugins.installProgress.downloading')}
</span>
</div>
</div>
)}
{skillUploadState === 'error' && (
<div className="flex items-center gap-2 sm:gap-3 px-2 sm:px-3 py-2 sm:py-2.5 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900">
<div className="flex items-center justify-center w-7 h-7 rounded-full shrink-0 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400">
<XCircle className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-red-600 dark:text-red-400">
{t('plugins.installProgress.downloading')}
</span>
</div>
</div>
)}
</div>
{/* Done banner */}
{skillUploadState === 'done' && (
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900">
<CheckCircle2 className="w-5 h-5 shrink-0 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-700 dark:text-green-300 font-medium break-words">
{t('plugins.installProgress.installComplete')}
</span>
</div>
)}
{/* Error detail */}
{skillUploadState === 'error' && skillUploadError && (
<div className="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900">
<p className="text-xs text-red-600 dark:text-red-400 break-all line-clamp-4">
{skillUploadError}
</p>
</div>
)}
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="default"
size="sm"
onClick={() => {
if (
skillUploadState === 'done' ||
skillUploadState === 'error'
) {
setSkillUploadState('idle');
setSkillUploadError(null);
} }
}} }}
disabled={skillUploadState === 'uploading'} />
> )}
{skillUploadState === 'done' || skillUploadState === 'error'
? t('common.close')
: t('plugins.installProgress.background')}
</Button>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>
-586
View File
@@ -1,586 +0,0 @@
import { useEffect, useState, useRef } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Github, Upload as UploadIcon, ChevronLeft } from 'lucide-react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
enum PluginInstallStatus {
WAIT_INPUT = 'wait_input',
SELECT_RELEASE = 'select_release',
SELECT_ASSET = 'select_asset',
ASK_CONFIRM = 'ask_confirm',
INSTALLING = 'installing',
ERROR = 'error',
}
interface GithubRelease {
id: number;
tag_name: string;
name: string;
published_at: string;
prerelease: boolean;
draft: boolean;
source_type?: 'release' | 'tag' | 'branch';
archive_url?: string;
}
interface GithubAsset {
id: number;
name: string;
size: number;
download_url: string;
content_type: string;
}
export default function AddPluginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const actionParam = searchParams.get('action');
const { refreshPlugins } = useSidebarData();
const {
addTask,
setSelectedTaskId,
registerOnTaskComplete,
unregisterOnTaskComplete,
} = usePluginInstallTasks();
const [githubURL, setGithubURL] = useState('');
const [githubReleases, setGithubReleases] = useState<GithubRelease[]>([]);
const [selectedRelease, setSelectedRelease] = useState<GithubRelease | null>(null);
const [githubAssets, setGithubAssets] = useState<GithubAsset[]>([]);
const [selectedAsset, setSelectedAsset] = useState<GithubAsset | null>(null);
const [githubOwner, setGithubOwner] = useState('');
const [githubRepo, setGithubRepo] = useState('');
const [fetchingReleases, setFetchingReleases] = useState(false);
const [fetchingAssets, setFetchingAssets] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
const fileInputRef = useRef<HTMLInputElement>(null);
const isGithubMode = actionParam === 'github';
const isUploadMode = actionParam === 'upload';
useEffect(() => {
const onComplete = (_taskId: number, success: boolean) => {
if (success) {
toast.success(t('plugins.installSuccess'));
refreshPlugins();
}
};
registerOnTaskComplete(onComplete);
return () => {
unregisterOnTaskComplete(onComplete);
};
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
async function checkExtensionsLimit(): Promise<boolean> {
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
if (maxExtensions < 0) return true;
try {
const [pluginsResp, mcpResp] = await Promise.all([
httpClient.getPlugins(),
httpClient.getMCPServers(),
]);
const total =
(pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);
if (total >= maxExtensions) {
toast.error(
t('limitation.maxExtensionsReached', { max: maxExtensions }),
);
return false;
}
} catch {
// If we can't check, let backend handle it
}
return true;
}
function resetGithubState() {
setGithubURL('');
setGithubReleases([]);
setSelectedRelease(null);
setGithubAssets([]);
setSelectedAsset(null);
setGithubOwner('');
setGithubRepo('');
setFetchingReleases(false);
setFetchingAssets(false);
}
async function fetchGithubReleases() {
if (!githubURL.trim()) {
toast.error(t('plugins.enterRepoUrl'));
return;
}
setFetchingReleases(true);
setInstallError(null);
try {
const result = await httpClient.getGithubReleases(githubURL);
setGithubReleases(result.releases);
setGithubOwner(result.owner);
setGithubRepo(result.repo);
if (result.releases.length === 0) {
toast.warning(t('plugins.noReleasesFound'));
} else {
setPluginInstallStatus(PluginInstallStatus.SELECT_RELEASE);
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
setInstallError(errorMessage || t('plugins.fetchReleasesError'));
setPluginInstallStatus(PluginInstallStatus.ERROR);
} finally {
setFetchingReleases(false);
}
}
async function handleReleaseSelect(release: GithubRelease) {
setSelectedRelease(release);
setFetchingAssets(true);
setInstallError(null);
try {
const result = await httpClient.getGithubReleaseAssets(
githubOwner,
githubRepo,
release.id,
release.tag_name,
release.source_type,
release.archive_url,
);
setGithubAssets(result.assets);
if (result.assets.length === 0) {
toast.warning(t('plugins.noAssetsFound'));
} else {
setPluginInstallStatus(PluginInstallStatus.SELECT_ASSET);
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
setInstallError(errorMessage || t('plugins.fetchAssetsError'));
setPluginInstallStatus(PluginInstallStatus.ERROR);
} finally {
setFetchingAssets(false);
}
}
function handleAssetSelect(asset: GithubAsset) {
setSelectedAsset(asset);
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
}
function handleGithubConfirm() {
if (!selectedAsset || !selectedRelease) return;
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
httpClient
.installPluginFromGithub(
selectedAsset.download_url,
githubOwner,
githubRepo,
selectedRelease.tag_name,
)
.then((resp) => {
const taskId = resp.task_id;
const taskKey = `github-${taskId}`;
addTask({
taskId,
pluginName: pluginDisplayName,
source: 'github',
extensionType: 'plugin',
fileSize: selectedAsset.size,
});
setSelectedTaskId(taskKey);
resetGithubState();
toast.success(t('plugins.installSuccess'));
navigate('/home/add-extension');
})
.catch((err) => {
setInstallError(err.msg);
setPluginInstallStatus(PluginInstallStatus.ERROR);
});
}
// Local file upload
const validateFileType = (file: File): boolean => {
const allowedExtensions = ['.lbpkg'];
const fileName = file.name.toLowerCase();
return allowedExtensions.some((ext) => fileName.endsWith(ext));
};
const uploadPluginFile = async (file: File) => {
if (!validateFileType(file)) {
toast.error(t('plugins.unsupportedFileType'));
return;
}
if (!(await checkExtensionsLimit())) return;
const fileName = file.name || 'local plugin';
const fileSize = file.size;
setInstallError(null);
httpClient
.installPluginFromLocal(file)
.then((resp) => {
const taskId = resp.task_id;
const taskKey = `local-${taskId}`;
addTask({
taskId,
pluginName: fileName,
source: 'local',
extensionType: 'plugin',
fileSize: fileSize,
});
setSelectedTaskId(taskKey);
toast.success(t('plugins.installSuccess'));
navigate('/home/add-extension');
})
.catch((err) => {
toast.error(t('plugins.installFailed') + (err.msg || ''));
});
};
const handleFileSelect = async () => {
if (!(await checkExtensionsLimit())) return;
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
uploadPluginFile(file);
}
event.target.value = '';
};
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
function handleCancel() {
navigate('/home/add-extension');
}
// GitHub Install View
if (isGithubMode) {
return (
<div className="h-full flex flex-col">
<input
ref={fileInputRef}
type="file"
accept=".lbpkg"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('plugins.installFromGithub')}</h1>
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}
</Button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Github className="size-5" />
{t('plugins.installFromGithub')}
</CardTitle>
<CardDescription>
{t('plugins.installFromGithubDesc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
<div>
<p className="mb-2 text-sm">{t('plugins.enterRepoUrl')}</p>
<div className="flex gap-2">
<Input
placeholder={t('plugins.repoUrlPlaceholder')}
value={githubURL}
onChange={(e) => setGithubURL(e.target.value)}
/>
<Button
onClick={fetchGithubReleases}
disabled={!githubURL.trim() || fetchingReleases}
>
{fetchingReleases
? t('plugins.loading')
: t('common.confirm')}
</Button>
</div>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-sm">
{t('plugins.selectRelease')}
</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
setGithubReleases([]);
}}
>
<ChevronLeft className="w-4 h-4 mr-1" />
{t('plugins.backToRepoUrl')}
</Button>
</div>
<div className="max-h-[400px] overflow-y-auto space-y-2 pb-2">
{githubReleases.map((release) => (
<Card
key={release.id}
className="cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-4"
onClick={() => handleReleaseSelect(release)}
>
<CardHeader className="flex flex-row items-start justify-between px-3 space-y-0">
<div className="flex-1">
<CardTitle className="text-sm">
{release.name || release.tag_name}
</CardTitle>
<CardDescription className="text-xs mt-1">
{t('plugins.releaseTag', {
tag: release.tag_name,
})}{' '}
&bull;{' '}
{t('plugins.publishedAt', {
date: new Date(
release.published_at,
).toLocaleDateString(),
})}
</CardDescription>
</div>
{release.prerelease && (
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded ml-2 shrink-0">
{t('plugins.prerelease')}
</span>
)}
</CardHeader>
</Card>
))}
</div>
{fetchingAssets && (
<p className="text-sm text-muted-foreground mt-4">
{t('plugins.loading')}
</p>
)}
</div>
)}
{pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-sm">
{t('plugins.selectAsset')}
</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPluginInstallStatus(
PluginInstallStatus.SELECT_RELEASE,
);
setGithubAssets([]);
setSelectedAsset(null);
}}
>
<ChevronLeft className="w-4 h-4 mr-1" />
{t('plugins.backToReleases')}
</Button>
</div>
{selectedRelease && (
<div className="mb-3 p-2 bg-muted rounded">
<div className="text-sm font-medium">
{selectedRelease.name || selectedRelease.tag_name}
</div>
<div className="text-xs text-muted-foreground">
{selectedRelease.tag_name}
</div>
</div>
)}
<div className="max-h-[400px] overflow-y-auto space-y-2 pb-2">
{githubAssets.map((asset) => (
<Card
key={asset.id}
className="cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-3"
onClick={() => handleAssetSelect(asset)}
>
<CardHeader className="px-3">
<CardTitle className="text-sm">
{asset.name}
</CardTitle>
<CardDescription className="text-xs">
{t('plugins.assetSize', {
size: formatFileSize(asset.size),
})}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-sm">
{t('plugins.confirmInstall')}
</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPluginInstallStatus(
PluginInstallStatus.SELECT_ASSET,
);
setSelectedAsset(null);
}}
>
<ChevronLeft className="w-4 h-4 mr-1" />
{t('plugins.backToAssets')}
</Button>
</div>
{selectedRelease && selectedAsset && (
<div className="p-3 bg-muted rounded space-y-2">
<div>
<span className="text-sm font-medium">
Repository:{' '}
</span>
<span className="text-sm">
{githubOwner}/{githubRepo}
</span>
</div>
<div>
<span className="text-sm font-medium">Release: </span>
<span className="text-sm">
{selectedRelease.tag_name}
</span>
</div>
<div>
<span className="text-sm font-medium">File: </span>
<span className="text-sm">{selectedAsset.name}</span>
</div>
</div>
)}
<div className="flex justify-end mt-4">
<Button onClick={handleGithubConfirm}>
{t('common.confirm')}
</Button>
</div>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
<div>
<p className="text-sm">{t('plugins.installing')}</p>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<div>
<p className="text-sm mb-1">{t('plugins.installFailed')}</p>
<p className="text-sm text-destructive">{installError}</p>
<div className="flex justify-end mt-4">
<Button variant="default" onClick={handleCancel}>
{t('common.close')}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
// Upload Mode - show file select dialog
if (isUploadMode) {
return (
<div className="h-full flex flex-col">
<input
ref={fileInputRef}
type="file"
accept=".lbpkg"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('plugins.uploadLocal')}</h1>
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}
</Button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UploadIcon className="size-5" />
{t('plugins.uploadLocal')}
</CardTitle>
<CardDescription>
{t('plugins.uploadPluginOnly')}
</CardDescription>
</CardHeader>
<CardContent>
<div
className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-8 text-center cursor-pointer hover:border-blue-500 transition-colors"
onClick={handleFileSelect}
>
<UploadIcon className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<p className="text-gray-600 dark:text-gray-300">
{t('plugins.dragToUpload')}
</p>
<p className="text-sm text-gray-500 mt-2">
{t('plugins.selectFileToUpload')}
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
// Default: redirect to add-extension
navigate('/home/add-extension');
return null;
}
@@ -316,8 +316,6 @@ function NavItems({
const pathname = location.pathname; const pathname = location.pathname;
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const sidebarData = useSidebarData(); const sidebarData = useSidebarData();
const { setPendingPluginInstallAction, setPendingSkillInstallAction } =
sidebarData;
const { state: sidebarState, isMobile } = useSidebar(); const { state: sidebarState, isMobile } = useSidebar();
const { t } = useTranslation(); const { t } = useTranslation();
// Track which entity categories have their full list expanded // Track which entity categories have their full list expanded
@@ -814,7 +812,7 @@ function NavItems({
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigate('/home/market'); navigate('/home/add-extension');
setPopoverOpen((prev) => ({ setPopoverOpen((prev) => ({
...prev, ...prev,
[config.id]: false, [config.id]: false,
@@ -828,8 +826,7 @@ function NavItems({
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPendingPluginInstallAction('local'); navigate('/home/add-extension?manual=1');
navigate('/home/extensions');
setPopoverOpen((prev) => ({ setPopoverOpen((prev) => ({
...prev, ...prev,
[config.id]: false, [config.id]: false,
@@ -842,8 +839,7 @@ function NavItems({
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPendingPluginInstallAction('github'); navigate('/home/add-extension?manual=1');
navigate('/home/extensions');
setPopoverOpen((prev) => ({ setPopoverOpen((prev) => ({
...prev, ...prev,
[config.id]: false, [config.id]: false,
@@ -869,8 +865,7 @@ function NavItems({
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPendingSkillInstallAction('create'); navigate('/home/skills?action=create');
navigate('/home/skills');
setPopoverOpen((prev) => ({ setPopoverOpen((prev) => ({
...prev, ...prev,
[config.id]: false, [config.id]: false,
@@ -883,8 +878,7 @@ function NavItems({
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPendingSkillInstallAction('upload'); navigate('/home/add-extension?manual=1');
navigate('/home/skills');
setPopoverOpen((prev) => ({ setPopoverOpen((prev) => ({
...prev, ...prev,
[config.id]: false, [config.id]: false,
@@ -897,8 +891,7 @@ function NavItems({
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPendingSkillInstallAction('github'); navigate('/home/add-extension?manual=1');
navigate('/home/skills');
setPopoverOpen((prev) => ({ setPopoverOpen((prev) => ({
...prev, ...prev,
[config.id]: false, [config.id]: false,
@@ -992,7 +985,7 @@ function NavItems({
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigate('/home/market'); navigate('/home/add-extension');
}} }}
> >
<Store className="size-4" /> <Store className="size-4" />
@@ -1002,8 +995,7 @@ function NavItems({
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPendingPluginInstallAction('local'); navigate('/home/add-extension?manual=1');
navigate('/home/extensions');
}} }}
> >
<Upload className="size-4" /> <Upload className="size-4" />
@@ -1012,8 +1004,7 @@ function NavItems({
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPendingPluginInstallAction('github'); navigate('/home/add-extension?manual=1');
navigate('/home/extensions');
}} }}
> >
<Github className="size-4" /> <Github className="size-4" />
@@ -1036,8 +1027,7 @@ function NavItems({
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPendingSkillInstallAction('create'); navigate('/home/skills?action=create');
navigate('/home/skills');
}} }}
> >
<FilePlus2 className="size-4" /> <FilePlus2 className="size-4" />
@@ -1046,8 +1036,7 @@ function NavItems({
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPendingSkillInstallAction('upload'); navigate('/home/add-extension?manual=1');
navigate('/home/skills');
}} }}
> >
<Upload className="size-4" /> <Upload className="size-4" />
@@ -1056,8 +1045,7 @@ function NavItems({
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPendingSkillInstallAction('github'); navigate('/home/add-extension?manual=1');
navigate('/home/skills');
}} }}
> >
<Github className="size-4" /> <Github className="size-4" />
@@ -1472,7 +1460,10 @@ function findSidebarChildForPath(pathname: string): SidebarChildVO | undefined {
); );
} }
if (pathname === '/home/market' || pathname.startsWith('/home/market/')) { if (
pathname === '/home/add-extension' ||
pathname.startsWith('/home/add-extension/')
) {
return sidebarConfigList.find( return sidebarConfigList.find(
(childConfig) => childConfig.id === 'add-extension', (childConfig) => childConfig.id === 'add-extension',
); );
@@ -30,10 +30,6 @@ export interface SidebarEntityItem {
extensionType?: 'plugin' | 'mcp' | 'skill'; 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 // Plugin page registered by a plugin
export interface PluginPageItem { export interface PluginPageItem {
id: string; // "author/name/pageId" id: string; // "author/name/pageId"
@@ -65,12 +61,6 @@ export interface SidebarDataContextValue {
// Breadcrumb: entity name shown when viewing a detail page // Breadcrumb: entity name shown when viewing a detail page
detailEntityName: string | null; detailEntityName: string | null;
setDetailEntityName: (name: string | null) => void; 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) // Whether the extensions list is grouped by type (shared between page and sidebar)
extensionsGroupByType: boolean; extensionsGroupByType: boolean;
setExtensionsGroupByType: (enabled: boolean) => void; setExtensionsGroupByType: (enabled: boolean) => void;
@@ -91,10 +81,6 @@ export function SidebarDataProvider({
const [skills, setSkills] = useState<SidebarEntityItem[]>([]); const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]); const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
const [detailEntityName, setDetailEntityName] = useState<string | null>(null); const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
const [pendingPluginInstallAction, setPendingPluginInstallAction] =
useState<PluginInstallAction>(null);
const [pendingSkillInstallAction, setPendingSkillInstallAction] =
useState<SkillInstallAction>(null);
const [extensionsGroupByType, setExtensionsGroupByTypeState] = const [extensionsGroupByType, setExtensionsGroupByTypeState] =
useState<boolean>(() => { useState<boolean>(() => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
@@ -320,10 +306,6 @@ export function SidebarDataProvider({
refreshAll, refreshAll,
detailEntityName, detailEntityName,
setDetailEntityName, setDetailEntityName,
pendingPluginInstallAction,
setPendingPluginInstallAction,
pendingSkillInstallAction,
setPendingSkillInstallAction,
extensionsGroupByType, extensionsGroupByType,
setExtensionsGroupByType, setExtensionsGroupByType,
}} }}
@@ -222,7 +222,7 @@ export default function FileUploadZone({
{t('knowledge.documentsTab.noParserAvailable')} {t('knowledge.documentsTab.noParserAvailable')}
</p> </p>
<Link <Link
to="/home/market?category=Parser" to="/home/add-extension"
className="text-sm text-primary hover:underline mt-1 inline-block" className="text-sm text-primary hover:underline mt-1 inline-block"
> >
{t('knowledge.documentsTab.installParserHint')} {t('knowledge.documentsTab.installParserHint')}
@@ -304,7 +304,7 @@ export default function KBForm({
{t('knowledge.noEnginesAvailable')} {t('knowledge.noEnginesAvailable')}
</p> </p>
<Link <Link
to="/home/market?category=KnowledgeEngine" to="/home/add-extension"
className="text-sm text-primary hover:underline" className="text-sm text-primary hover:underline"
> >
{t('knowledge.installEngineHint')} {t('knowledge.installEngineHint')}
+1 -1
View File
@@ -46,7 +46,7 @@ import {
// Routes that belong to the "Extensions" section // Routes that belong to the "Extensions" section
const EXTENSIONS_ROUTES = [ const EXTENSIONS_ROUTES = [
'/home/extensions', '/home/extensions',
'/home/market', '/home/add-extension',
'/home/mcp', '/home/mcp',
'/home/skills', '/home/skills',
'/home/plugin-pages', '/home/plugin-pages',
-207
View File
@@ -1,207 +0,0 @@
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Download } from 'lucide-react';
import React, { useState, useCallback, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
enum PluginInstallStatus {
ASK_CONFIRM = 'ask_confirm',
INSTALLING = 'installing',
ERROR = 'error',
}
export default function MarketplacePage() {
const { t } = useTranslation();
if (!systemInfo?.enable_marketplace) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
<p className="text-muted-foreground">{t('plugins.marketplace')}</p>
</div>
);
}
return <MarketplaceContent />;
}
function MarketplaceContent() {
const { t } = useTranslation();
const { refreshPlugins } = useSidebarData();
const {
addTask,
setSelectedTaskId,
registerOnTaskComplete,
unregisterOnTaskComplete,
} = usePluginInstallTasks();
const [modalOpen, setModalOpen] = useState(false);
const [installInfo, setInstallInfo] = useState<Record<string, string>>({});
const [installExtensionType, setInstallExtensionType] = useState<'plugin' | 'mcp' | 'skill'>('plugin');
const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.ASK_CONFIRM);
const [installError, setInstallError] = useState<string | null>(null);
async function checkExtensionsLimit(): Promise<boolean> {
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
if (maxExtensions < 0) return true;
try {
const [pluginsResp, mcpResp] = await Promise.all([
httpClient.getPlugins(),
httpClient.getMCPServers(),
]);
const total =
(pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);
if (total >= maxExtensions) {
toast.error(
t('limitation.maxExtensionsReached', { max: maxExtensions }),
);
return false;
}
} catch {
// If we can't check, let backend handle it
}
return true;
}
// Register task completion callback for toast and plugin list refresh
useEffect(() => {
const onComplete = (_taskId: number, success: boolean) => {
if (success) {
toast.success(t('plugins.installSuccess'));
refreshPlugins();
}
};
registerOnTaskComplete(onComplete);
return () => {
unregisterOnTaskComplete(onComplete);
};
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
const handleInstallPlugin = useCallback(
async (plugin: PluginV4) => {
if (!(await checkExtensionsLimit())) return;
setInstallInfo({
plugin_author: plugin.author,
plugin_name: plugin.name,
plugin_version: plugin.latest_version,
});
setInstallExtensionType(plugin.type || 'plugin');
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setInstallError(null);
setModalOpen(true);
},
[t],
);
function handleModalConfirm() {
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
httpClient
.installPluginFromMarketplace(
installInfo.plugin_author,
installInfo.plugin_name,
installInfo.plugin_version,
)
.then((resp) => {
const taskId = resp.task_id;
const taskKey = `marketplace-${taskId}`;
addTask({
taskId,
pluginName: pluginDisplayName,
source: 'marketplace',
extensionType: installExtensionType,
});
setSelectedTaskId(taskKey);
setModalOpen(false);
})
.catch((err) => {
setInstallError(err.msg);
setPluginInstallStatus(PluginInstallStatus.ERROR);
});
}
return (
<>
<div className="h-full overflow-y-auto">
<MarketPage installPlugin={handleInstallPlugin} />
</div>
<Dialog
open={modalOpen}
onOpenChange={(open) => {
setModalOpen(open);
if (!open) {
setInstallError(null);
}
}}
>
<DialogContent className="w-[500px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-4">
<Download className="size-6" />
<span>{t('plugins.installPlugin')}</span>
</DialogTitle>
</DialogHeader>
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div className="mt-4">
<p className="mb-2">
{installInfo.plugin_version
? t('plugins.askConfirm', {
name: installInfo.plugin_name,
version: installInfo.plugin_version,
})
: t('plugins.askConfirmNoVersion', {
name: installInfo.plugin_name,
})}
</p>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
<div className="mt-4">
<p className="mb-2">{t('plugins.installing')}</p>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<div className="mt-4">
<p className="mb-2">{t('plugins.installFailed')}</p>
<p className="mb-2 text-red-500">{installError}</p>
</div>
)}
<DialogFooter>
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleModalConfirm}>
{t('common.confirm')}
</Button>
</>
)}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<Button variant="default" onClick={() => setModalOpen(false)}>
{t('common.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,203 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Archive, CheckCircle2, Loader2, Package } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { httpClient } from '@/app/infra/http/HttpClient';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
type PluginLocalPreview = Awaited<
ReturnType<typeof httpClient.previewPluginInstallFromLocal>
>;
interface PluginLocalPreviewPanelProps {
file: File;
onInstallStarted?: () => void;
onCancel?: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
export default function PluginLocalPreviewPanel({
file,
onInstallStarted,
onCancel,
}: PluginLocalPreviewPanelProps) {
const { t } = useTranslation();
const { addTask, setSelectedTaskId } = usePluginInstallTasks();
const [preview, setPreview] = useState<PluginLocalPreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const [installing, setInstalling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const loadPreview = useCallback(async () => {
setPreviewing(true);
setPreview(null);
setErrorMessage(null);
try {
const result = await httpClient.previewPluginInstallFromLocal(file);
setPreview(result);
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('plugins.localPreview.failed'));
} finally {
setPreviewing(false);
}
}, [file, t]);
useEffect(() => {
void loadPreview();
}, [loadPreview]);
async function handleInstall() {
setInstalling(true);
setErrorMessage(null);
try {
const resp = await httpClient.installPluginFromLocal(file);
const taskId = resp.task_id;
const taskKey = `local-${taskId}`;
const pluginName =
preview?.metadata.label && extractI18nObject(preview.metadata.label)
? extractI18nObject(preview.metadata.label)
: preview?.metadata.name || file.name;
addTask({
taskId,
pluginName,
source: 'local',
extensionType: 'plugin',
fileSize: file.size,
});
setSelectedTaskId(taskKey);
toast.success(t('plugins.installSuccess'));
onInstallStarted?.();
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('plugins.installFailed'));
} finally {
setInstalling(false);
}
}
const metadata = preview?.metadata;
const label = metadata?.label ? extractI18nObject(metadata.label) : '';
const description = metadata?.description
? extractI18nObject(metadata.description)
: '';
const componentCounts = preview?.component_counts || {};
return (
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-md bg-muted/40 px-3 py-3">
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground">
{previewing ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Archive className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{previewing
? t('plugins.localPreview.unpacking')
: t('plugins.localPreview.unpackComplete')}
</div>
<div className="mt-1 break-all text-xs text-muted-foreground">
{file.name} · {formatFileSize(file.size)}
</div>
</div>
</div>
{preview && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Package className="size-4" />
{t('plugins.localPreview.pluginInfo')}
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
{t('plugins.localPreview.name')}
</span>
<span className="truncate font-medium">
{label || metadata?.name || '-'}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
{t('plugins.localPreview.author')}
</span>
<span className="truncate">{metadata?.author || '-'}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
{t('plugins.localPreview.version')}
</span>
<span>{metadata?.version || '-'}</span>
</div>
</div>
{description && (
<p className="text-sm leading-6 text-muted-foreground">
{description}
</p>
)}
<div className="flex flex-wrap items-center gap-2 text-sm">
<PluginComponentList
components={componentCounts}
showComponentName
showTitle
useBadge
t={t}
/>
</div>
</div>
)}
{preview && (
<div className="flex items-center gap-2 text-sm text-green-700 dark:text-green-300">
<CheckCircle2 className="size-4" />
{t('plugins.localPreview.ready')}
</div>
)}
{errorMessage && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
<div className="flex justify-end gap-2">
{onCancel && (
<Button variant="outline" onClick={onCancel} disabled={installing}>
{t('common.cancel')}
</Button>
)}
<Button
type="button"
onClick={handleInstall}
disabled={!preview || previewing || installing}
>
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
</Button>
</div>
</div>
);
}
@@ -10,8 +10,6 @@ import { Button } from '@/components/ui/button';
import { import {
Download, Download,
Package, Package,
Settings,
Rocket,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Loader2, Loader2,
@@ -39,16 +37,6 @@ const STAGES: {
icon: Package, icon: Package,
i18nKey: 'plugins.installProgress.installingDeps', 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 { function getStageIndex(stage: InstallStage): number {
@@ -89,8 +89,8 @@ function mapActionToStage(action: string): InstallStage {
if (lower.includes('dependencies') || lower.includes('requirements')) if (lower.includes('dependencies') || lower.includes('requirements'))
return InstallStage.INSTALLING_DEPS; return InstallStage.INSTALLING_DEPS;
if (lower.includes('initializ') || lower.includes('setting')) if (lower.includes('initializ') || lower.includes('setting'))
return InstallStage.INITIALIZING; return InstallStage.INSTALLING_DEPS;
if (lower.includes('launch')) return InstallStage.LAUNCHING; if (lower.includes('launch')) return InstallStage.INSTALLING_DEPS;
if (lower.includes('installed') || lower.includes('complete')) if (lower.includes('installed') || lower.includes('complete'))
return InstallStage.DONE; return InstallStage.DONE;
return InstallStage.DOWNLOADING; return InstallStage.DOWNLOADING;
@@ -104,7 +104,7 @@ function stageToProgress(stage: InstallStage): number {
case InstallStage.DOWNLOADING: case InstallStage.DOWNLOADING:
return 10; return 10;
case InstallStage.INSTALLING_DEPS: case InstallStage.INSTALLING_DEPS:
return 40; return 70;
case InstallStage.INITIALIZING: case InstallStage.INITIALIZING:
return 70; return 70;
case InstallStage.LAUNCHING: case InstallStage.LAUNCHING:
@@ -133,7 +133,11 @@ function extractSourceFromName(
* Check if a backend task name is a plugin install task. * Check if a backend task name is a plugin install task.
*/ */
function isPluginInstallTask(name: string): boolean { 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 // Cleanup all intervals on unmount
useEffect(() => { useEffect(() => {
const intervals = intervalRefs.current;
return () => { return () => {
intervalRefs.current.forEach((interval) => { intervals.forEach((interval) => {
clearInterval(interval); clearInterval(interval);
}); });
if (syncIntervalRef.current) clearInterval(syncIntervalRef.current); if (syncIntervalRef.current) clearInterval(syncIntervalRef.current);
@@ -4,8 +4,6 @@ import { Progress } from '@/components/ui/progress';
import { import {
Download, Download,
Package, Package,
Settings,
Rocket,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Loader2, Loader2,
@@ -32,8 +30,6 @@ import { cn } from '@/lib/utils';
const STAGE_ICONS: Record<string, React.ElementType> = { const STAGE_ICONS: Record<string, React.ElementType> = {
[InstallStage.DOWNLOADING]: Download, [InstallStage.DOWNLOADING]: Download,
[InstallStage.INSTALLING_DEPS]: Package, [InstallStage.INSTALLING_DEPS]: Package,
[InstallStage.INITIALIZING]: Settings,
[InstallStage.LAUNCHING]: Rocket,
[InstallStage.DONE]: CheckCircle2, [InstallStage.DONE]: CheckCircle2,
[InstallStage.ERROR]: XCircle, [InstallStage.ERROR]: XCircle,
}; };
@@ -99,12 +95,10 @@ function TaskQueueItem({
return t('plugins.installProgress.downloading'); return t('plugins.installProgress.downloading');
case InstallStage.INSTALLING_DEPS: case InstallStage.INSTALLING_DEPS:
return t('plugins.installProgress.installingDeps'); return t('plugins.installProgress.installingDeps');
case InstallStage.INITIALIZING:
return t('plugins.installProgress.initializing');
case InstallStage.LAUNCHING:
return t('plugins.installProgress.launching');
case InstallStage.DONE: case InstallStage.DONE:
return isDone ? getInstallCompleteMessage() : t('plugins.installProgress.completed'); return isDone
? getInstallCompleteMessage()
: t('plugins.installProgress.completed');
case InstallStage.ERROR: case InstallStage.ERROR:
return t('plugins.installProgress.failed'); return t('plugins.installProgress.failed');
default: default:
@@ -140,7 +134,10 @@ function TaskQueueItem({
<div className="text-sm font-medium truncate">{task.pluginName}</div> <div className="text-sm font-medium truncate">{task.pluginName}</div>
<Badge <Badge
variant="outline" variant="outline"
className={cn('text-[0.6rem] px-1 py-0 flex-shrink-0', getTypeBadgeClass())} className={cn(
'text-[0.6rem] px-1 py-0 flex-shrink-0',
getTypeBadgeClass(),
)}
> >
<TypeIcon className="w-3 h-3 mr-0.5" /> <TypeIcon className="w-3 h-3 mr-0.5" />
{getTypeLabel()} {getTypeLabel()}
+11 -648
View File
@@ -9,40 +9,21 @@ import { Label } from '@/components/ui/label';
import PluginDetailContent from './PluginDetailContent'; import PluginDetailContent from './PluginDetailContent';
import styles from './plugins.module.css'; import styles from './plugins.module.css';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Power, Code, Copy, Check, Bug, Unlink } from 'lucide-react';
UploadIcon,
Power,
Github,
ChevronLeft,
Code,
Copy,
Check,
Bug,
Unlink,
} from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard'; import { copyToClipboard } from '@/app/utils/clipboard';
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from '@/components/ui/card';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import React, { useState, useRef, useCallback, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { systemInfo } from '@/app/infra/http/HttpClient';
import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { import {
@@ -50,39 +31,10 @@ import {
usePluginInstallTasks, usePluginInstallTasks,
} from '@/app/home/plugins/components/plugin-install-task'; } 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() { export default function PluginConfigPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const detailId = searchParams.get('id'); const detailId = searchParams.get('id');
// Show plugin detail view when ?id= query param is present
if (detailId) { if (detailId) {
return <PluginDetailContent id={detailId} />; return <PluginDetailContent id={detailId} />;
} }
@@ -92,40 +44,16 @@ export default function PluginConfigPage() {
function PluginListView() { function PluginListView() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const { const {
refreshPlugins, refreshPlugins,
pendingPluginInstallAction, extensionsGroupByType: groupByType,
setPendingPluginInstallAction, setExtensionsGroupByType: setGroupByType,
} = useSidebarData(); } = useSidebarData();
const { const { registerOnTaskComplete, unregisterOnTaskComplete } =
addTask, usePluginInstallTasks();
setSelectedTaskId,
registerOnTaskComplete,
unregisterOnTaskComplete,
} = usePluginInstallTasks();
const [showGithubInstall, setShowGithubInstall] = useState(false);
const [installSource, setInstallSource] = useState<string>('local');
const [installInfo] = useState<Record<string, any>>({});
const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
const [installError, setInstallError] = useState<string | null>(null);
const [githubURL, setGithubURL] = useState('');
const [githubReleases, setGithubReleases] = useState<GithubRelease[]>([]);
const [selectedRelease, setSelectedRelease] = useState<GithubRelease | null>(
null,
);
const [githubAssets, setGithubAssets] = useState<GithubAsset[]>([]);
const [selectedAsset, setSelectedAsset] = useState<GithubAsset | null>(null);
const [githubOwner, setGithubOwner] = useState('');
const [githubRepo, setGithubRepo] = useState('');
const [fetchingReleases, setFetchingReleases] = useState(false);
const [fetchingAssets, setFetchingAssets] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [pluginSystemStatus, setPluginSystemStatus] = const [pluginSystemStatus, setPluginSystemStatus] =
useState<ApiRespPluginSystemStatus | null>(null); useState<ApiRespPluginSystemStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true); const [statusLoading, setStatusLoading] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const [debugInfo, setDebugInfo] = useState<{ const [debugInfo, setDebugInfo] = useState<{
debug_url: string; debug_url: string;
plugin_debug_key: string; plugin_debug_key: string;
@@ -134,8 +62,7 @@ function PluginListView() {
const [copiedDebugUrl, setCopiedDebugUrl] = useState(false); const [copiedDebugUrl, setCopiedDebugUrl] = useState(false);
const [copiedDebugKey, setCopiedDebugKey] = useState(false); const [copiedDebugKey, setCopiedDebugKey] = useState(false);
const [filterType, setFilterType] = useState<FilterType>('all'); const [filterType, setFilterType] = useState<FilterType>('all');
const groupByType = useSidebarData().extensionsGroupByType; const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
const setGroupByType = useSidebarData().setExtensionsGroupByType;
useEffect(() => { useEffect(() => {
const fetchPluginSystemStatus = async () => { const fetchPluginSystemStatus = async () => {
@@ -151,19 +78,9 @@ function PluginListView() {
} }
}; };
fetchPluginSystemStatus(); void fetchPluginSystemStatus();
// eslint-disable-next-line react-hooks/exhaustive-deps }, [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(() => { useEffect(() => {
const onComplete = (_taskId: number, success: boolean) => { const onComplete = (_taskId: number, success: boolean) => {
if (success) { if (success) {
@@ -178,285 +95,6 @@ function PluginListView() {
}; };
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]); }, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
function resetGithubState() {
setGithubURL('');
setGithubReleases([]);
setSelectedRelease(null);
setGithubAssets([]);
setSelectedAsset(null);
setGithubOwner('');
setGithubRepo('');
setFetchingReleases(false);
setFetchingAssets(false);
}
async function checkExtensionsLimit(): Promise<boolean> {
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
if (maxExtensions < 0) return true;
try {
const [pluginsResp, mcpResp] = await Promise.all([
httpClient.getPlugins(),
httpClient.getMCPServers(),
]);
const total =
(pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);
if (total >= maxExtensions) {
toast.error(
t('limitation.maxExtensionsReached', { max: maxExtensions }),
);
return false;
}
} catch {
// If we can't check, let backend handle it
}
return true;
}
async function fetchGithubReleases() {
if (!githubURL.trim()) {
toast.error(t('plugins.enterRepoUrl'));
return;
}
setFetchingReleases(true);
setInstallError(null);
try {
const result = await httpClient.getGithubReleases(githubURL);
setGithubReleases(result.releases);
setGithubOwner(result.owner);
setGithubRepo(result.repo);
if (result.releases.length === 0) {
toast.warning(t('plugins.noReleasesFound'));
} else {
setPluginInstallStatus(PluginInstallStatus.SELECT_RELEASE);
}
} catch (error: unknown) {
console.error('Failed to fetch GitHub releases:', error);
const errorMessage =
error instanceof Error ? error.message : String(error);
setInstallError(errorMessage || t('plugins.fetchReleasesError'));
setPluginInstallStatus(PluginInstallStatus.ERROR);
} finally {
setFetchingReleases(false);
}
}
async function handleReleaseSelect(release: GithubRelease) {
setSelectedRelease(release);
setFetchingAssets(true);
setInstallError(null);
try {
const result = await httpClient.getGithubReleaseAssets(
githubOwner,
githubRepo,
release.id,
release.tag_name,
release.source_type,
release.archive_url,
);
setGithubAssets(result.assets);
if (result.assets.length === 0) {
toast.warning(t('plugins.noAssetsFound'));
} else {
setPluginInstallStatus(PluginInstallStatus.SELECT_ASSET);
}
} catch (error: unknown) {
console.error('Failed to fetch GitHub release assets:', error);
const errorMessage =
error instanceof Error ? error.message : String(error);
setInstallError(errorMessage || t('plugins.fetchAssetsError'));
setPluginInstallStatus(PluginInstallStatus.ERROR);
} finally {
setFetchingAssets(false);
}
}
function handleAssetSelect(asset: GithubAsset) {
setSelectedAsset(asset);
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
}
function handleModalConfirm() {
if (installSource === 'github' && selectedAsset && selectedRelease) {
installPlugin('github', {
asset_url: selectedAsset.download_url,
owner: githubOwner,
repo: githubRepo,
release_tag: selectedRelease.tag_name,
});
} else {
installPlugin(installSource, installInfo as Record<string, any>);
}
}
function installPlugin(
installSource: string,
installInfo: Record<string, any>,
) {
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
if (installSource === 'github') {
const pluginDisplayName = `${installInfo.owner}/${installInfo.repo}`;
const assetSize = selectedAsset?.size;
httpClient
.installPluginFromGithub(
installInfo.asset_url,
installInfo.owner,
installInfo.repo,
installInfo.release_tag,
)
.then((resp) => {
const taskId = resp.task_id;
const taskKey = `github-${taskId}`;
addTask({
taskId,
pluginName: pluginDisplayName,
source: 'github',
extensionType: 'plugin',
fileSize: assetSize,
});
setSelectedTaskId(taskKey);
resetGithubState();
setShowGithubInstall(false);
})
.catch((err) => {
setInstallError(err.msg);
setPluginInstallStatus(PluginInstallStatus.ERROR);
});
} else if (installSource === 'local') {
const fileName = installInfo.file?.name || 'local plugin';
const fileSize = installInfo.file?.size;
httpClient
.installPluginFromLocal(installInfo.file)
.then((resp) => {
const taskId = resp.task_id;
const taskKey = `local-${taskId}`;
addTask({
taskId,
pluginName: fileName,
source: 'local',
extensionType: 'plugin',
fileSize: fileSize,
});
setSelectedTaskId(taskKey);
})
.catch((err) => {
setInstallError(err.msg);
setPluginInstallStatus(PluginInstallStatus.ERROR);
toast.error(t('plugins.installFailed') + (err.msg || ''));
});
}
}
const validateFileType = (file: File): boolean => {
const allowedExtensions = ['.lbpkg', '.zip'];
const fileName = file.name.toLowerCase();
return allowedExtensions.some((ext) => fileName.endsWith(ext));
};
const uploadPluginFile = useCallback(
async (file: File) => {
if (!pluginSystemStatus?.is_enable || !pluginSystemStatus?.is_connected) {
toast.error(t('plugins.pluginSystemNotReady'));
return;
}
if (!validateFileType(file)) {
toast.error(t('plugins.unsupportedFileType'));
return;
}
if (!(await checkExtensionsLimit())) return;
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
setInstallError(null);
installPlugin('local', { file });
},
[t, pluginSystemStatus, installPlugin],
);
const handleFileSelect = useCallback(async () => {
if (!(await checkExtensionsLimit())) return;
if (fileInputRef.current) {
fileInputRef.current.click();
}
}, []);
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
uploadPluginFile(file);
}
event.target.value = '';
},
[uploadPluginFile],
);
const isPluginSystemReady =
pluginSystemStatus?.is_enable && pluginSystemStatus?.is_connected;
const handleDragOver = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
if (isPluginSystemReady) {
setIsDragOver(true);
}
},
[isPluginSystemReady],
);
const handleDragLeave = useCallback((event: React.DragEvent) => {
event.preventDefault();
setIsDragOver(false);
}, []);
const handleDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
setIsDragOver(false);
if (!isPluginSystemReady) {
toast.error(t('plugins.pluginSystemNotReady'));
return;
}
const files = Array.from(event.dataTransfer.files);
if (files.length > 0) {
uploadPluginFile(files[0]);
}
},
[uploadPluginFile, isPluginSystemReady, t],
);
// Auto-trigger install action from sidebar via shared context
useEffect(() => {
if (!pendingPluginInstallAction || statusLoading || !isPluginSystemReady)
return;
// Consume the action immediately
const action = pendingPluginInstallAction;
setPendingPluginInstallAction(null);
if (action === 'local') {
// Small delay to ensure file input ref is ready
setTimeout(() => fileInputRef.current?.click(), 100);
} else if (action === 'github') {
setInstallSource('github');
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
setInstallError(null);
resetGithubState();
setShowGithubInstall(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingPluginInstallAction, statusLoading, isPluginSystemReady]);
const handleShowDebugInfo = async () => { const handleShowDebugInfo = async () => {
try { try {
const info = await httpClient.getPluginDebugInfo(); const info = await httpClient.getPluginDebugInfo();
@@ -520,23 +158,7 @@ function PluginListView() {
} }
return ( return (
<div <div className={`${styles.pageContainer} h-full flex flex-col`}>
className={`${styles.pageContainer} h-full flex flex-col ${
isDragOver ? 'bg-blue-50' : ''
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept=".lbpkg,.zip"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
{/* Header bar with filter tabs, debug info, and task queue */}
<div className="flex flex-col md:flex-row md:justify-between md:items-center px-[0.8rem] pb-4 flex-shrink-0 gap-2"> <div className="flex flex-col md:flex-row md:justify-between md:items-center px-[0.8rem] pb-4 flex-shrink-0 gap-2">
<div className="overflow-x-auto -mx-1 px-1"> <div className="overflow-x-auto -mx-1 px-1">
<Tabs <Tabs
@@ -588,7 +210,6 @@ function PluginListView() {
align="end" align="end"
> >
<div className="space-y-3"> <div className="space-y-3">
{/* Header with icon and title */}
<div className="flex items-center gap-2 pb-2 border-b"> <div className="flex items-center gap-2 pb-2 border-b">
<Bug className="w-4 h-4" /> <Bug className="w-4 h-4" />
<h4 className="font-semibold text-sm"> <h4 className="font-semibold text-sm">
@@ -596,7 +217,6 @@ function PluginListView() {
</h4> </h4>
</div> </div>
{/* Debug URL row */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]"> <label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
{t('plugins.debugUrl')}: {t('plugins.debugUrl')}:
@@ -622,7 +242,6 @@ function PluginListView() {
</Button> </Button>
</div> </div>
{/* Debug Key row */}
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]"> <label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
@@ -666,251 +285,6 @@ function PluginListView() {
</div> </div>
</div> </div>
{/* Inline GitHub install flow */}
{showGithubInstall && (
<div className="px-[0.8rem] pb-4 flex-shrink-0">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="flex items-center gap-2 text-base">
<Github className="size-5" />
<span>{t('plugins.installPlugin')}</span>
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowGithubInstall(false);
resetGithubState();
setInstallError(null);
}}
>
{t('common.cancel')}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{/* Step 1: Enter repo URL */}
{pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
<div>
<p className="mb-2 text-sm">{t('plugins.enterRepoUrl')}</p>
<div className="flex gap-2">
<Input
placeholder={t('plugins.repoUrlPlaceholder')}
value={githubURL}
onChange={(e) => setGithubURL(e.target.value)}
/>
<Button
onClick={fetchGithubReleases}
disabled={!githubURL.trim() || fetchingReleases}
>
{fetchingReleases
? t('plugins.loading')
: t('common.confirm')}
</Button>
</div>
</div>
)}
{/* Step 2: Select release */}
{pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-sm">
{t('plugins.selectRelease')}
</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
setGithubReleases([]);
}}
>
<ChevronLeft className="w-4 h-4 mr-1" />
{t('plugins.backToRepoUrl')}
</Button>
</div>
<div className="max-h-[400px] overflow-y-auto space-y-2 pb-2">
{githubReleases.map((release) => (
<Card
key={release.id}
className="cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-4"
onClick={() => handleReleaseSelect(release)}
>
<CardHeader className="flex flex-row items-start justify-between px-3 space-y-0">
<div className="flex-1">
<CardTitle className="text-sm">
{release.name || release.tag_name}
</CardTitle>
<CardDescription className="text-xs mt-1">
{t('plugins.releaseTag', {
tag: release.tag_name,
})}{' '}
&bull;{' '}
{t('plugins.publishedAt', {
date: new Date(
release.published_at,
).toLocaleDateString(),
})}
</CardDescription>
</div>
{release.prerelease && (
<Badge
variant="secondary"
className="ml-2 shrink-0"
>
{t('plugins.prerelease')}
</Badge>
)}
</CardHeader>
</Card>
))}
</div>
{fetchingAssets && (
<p className="text-sm text-muted-foreground mt-4">
{t('plugins.loading')}
</p>
)}
</div>
)}
{/* Step 3: Select asset */}
{pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-sm">
{t('plugins.selectAsset')}
</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPluginInstallStatus(
PluginInstallStatus.SELECT_RELEASE,
);
setGithubAssets([]);
setSelectedAsset(null);
}}
>
<ChevronLeft className="w-4 h-4 mr-1" />
{t('plugins.backToReleases')}
</Button>
</div>
{selectedRelease && (
<div className="mb-3 p-2 bg-muted rounded">
<div className="text-sm font-medium">
{selectedRelease.name || selectedRelease.tag_name}
</div>
<div className="text-xs text-muted-foreground">
{selectedRelease.tag_name}
</div>
</div>
)}
<div className="max-h-[400px] overflow-y-auto space-y-2 pb-2">
{githubAssets.map((asset) => (
<Card
key={asset.id}
className="cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-3"
onClick={() => handleAssetSelect(asset)}
>
<CardHeader className="px-3">
<CardTitle className="text-sm">
{asset.name}
</CardTitle>
<CardDescription className="text-xs">
{t('plugins.assetSize', {
size: formatFileSize(asset.size),
})}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
)}
{/* Step 4: Confirm install */}
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-sm">
{t('plugins.confirmInstall')}
</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPluginInstallStatus(
PluginInstallStatus.SELECT_ASSET,
);
setSelectedAsset(null);
}}
>
<ChevronLeft className="w-4 h-4 mr-1" />
{t('plugins.backToAssets')}
</Button>
</div>
{selectedRelease && selectedAsset && (
<div className="p-3 bg-muted rounded space-y-2">
<div>
<span className="text-sm font-medium">
Repository:{' '}
</span>
<span className="text-sm">
{githubOwner}/{githubRepo}
</span>
</div>
<div>
<span className="text-sm font-medium">Release: </span>
<span className="text-sm">
{selectedRelease.tag_name}
</span>
</div>
<div>
<span className="text-sm font-medium">File: </span>
<span className="text-sm">{selectedAsset.name}</span>
</div>
</div>
)}
<div className="flex justify-end mt-4">
<Button onClick={() => handleModalConfirm()}>
{t('common.confirm')}
</Button>
</div>
</div>
)}
{/* Installing state */}
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
<div>
<p className="text-sm">{t('plugins.installing')}</p>
</div>
)}
{/* Error state */}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<div>
<p className="text-sm mb-1">{t('plugins.installFailed')}</p>
<p className="text-sm text-destructive">{installError}</p>
<div className="flex justify-end mt-4">
<Button
variant="default"
onClick={() => {
setShowGithubInstall(false);
resetGithubState();
setInstallError(null);
}}
>
{t('common.close')}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)}
{/* Installed plugins grid */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<PluginInstalledComponent <PluginInstalledComponent
ref={pluginInstalledRef} ref={pluginInstalledRef}
@@ -918,17 +292,6 @@ function PluginListView() {
groupByType={groupByType} groupByType={groupByType}
/> />
</div> </div>
{isDragOver && (
<div className="fixed inset-0 bg-foreground/40 flex items-center justify-center z-50 pointer-events-none">
<Card className="border-2 border-dashed">
<CardContent className="flex flex-col items-center justify-center px-8 py-6">
<UploadIcon className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium">{t('plugins.dragToUpload')}</p>
</CardContent>
</Card>
</div>
)}
</div> </div>
); );
} }
@@ -1,645 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { ChevronLeft, Github, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { httpClient } from '@/app/infra/http/HttpClient';
import type { Skill } from '@/app/infra/entities/api';
interface GithubRelease {
id: number;
tag_name: string;
name: string;
published_at: string;
prerelease: boolean;
draft: boolean;
source_type?: 'release' | 'tag' | 'branch';
archive_url?: string;
}
interface GithubAsset {
id: number;
name: string;
size: number;
download_url: string;
content_type: string;
}
interface PreviewSkill extends Skill {
source_path?: string;
entry_file?: string;
}
interface SkillGithubImportPanelProps {
onImported: (skillNames: string[]) => void;
/** Which section to display. Defaults to 'all' (both GitHub and upload). */
mode?: 'all' | 'github' | 'upload';
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
function previewPath(skill: PreviewSkill): string {
return skill.source_path || '';
}
export default function SkillGithubImportPanel({
onImported,
mode = 'all',
}: SkillGithubImportPanelProps) {
const { t } = useTranslation();
const [githubURL, setGithubURL] = useState('');
const [githubOwner, setGithubOwner] = useState('');
const [githubRepo, setGithubRepo] = useState('');
const [githubSourceSubdir, setGithubSourceSubdir] = useState('');
const [githubReleases, setGithubReleases] = useState<GithubRelease[]>([]);
const [selectedRelease, setSelectedRelease] = useState<GithubRelease | null>(
null,
);
const [githubAssets, setGithubAssets] = useState<GithubAsset[]>([]);
const [selectedAsset, setSelectedAsset] = useState<GithubAsset | null>(null);
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
const [selectedPreviewPaths, setSelectedPreviewPaths] = useState<string[]>(
[],
);
const [activePreviewPath, setActivePreviewPath] = useState('');
const [fetchingReleases, setFetchingReleases] = useState(false);
const [fetchingAssets, setFetchingAssets] = useState(false);
const [previewingGithub, setPreviewingGithub] = useState(false);
const [installingGithub, setInstallingGithub] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadPreviewSkills, setUploadPreviewSkills] = useState<
PreviewSkill[]
>([]);
const [selectedUploadPreviewPaths, setSelectedUploadPreviewPaths] = useState<
string[]
>([]);
const [activeUploadPreviewPath, setActiveUploadPreviewPath] = useState('');
const [previewingUpload, setPreviewingUpload] = useState(false);
const [installingUpload, setInstallingUpload] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const activePreviewSkill =
previewSkills.find((skill) => previewPath(skill) === activePreviewPath) ||
null;
const activeUploadPreviewSkill =
uploadPreviewSkills.find(
(skill) => previewPath(skill) === activeUploadPreviewPath,
) || null;
function initializeSelection(
skills: PreviewSkill[],
setSelectedPaths: (paths: string[]) => void,
setActivePath: (path: string) => void,
) {
const paths = skills.map(previewPath);
setSelectedPaths(paths);
setActivePath(paths[0] || '');
}
function toggleSelection(
targetPath: string,
selectedPaths: string[],
setSelectedPaths: (paths: string[]) => void,
setActivePath: (path: string) => void,
) {
if (selectedPaths.includes(targetPath)) {
const nextPaths = selectedPaths.filter((path) => path !== targetPath);
setSelectedPaths(nextPaths);
if (!nextPaths.includes(targetPath)) {
setActivePath(nextPaths[0] || targetPath);
}
return;
}
setSelectedPaths([...selectedPaths, targetPath]);
setActivePath(targetPath);
}
function buildSourceArchiveAsset(release: GithubRelease): GithubAsset | null {
if (!release.archive_url) return null;
return {
id: 0,
name: t('skills.sourceArchive'),
size: 0,
download_url: release.archive_url,
content_type: 'application/zip',
};
}
async function fetchReleases() {
if (!githubURL.trim()) return;
setFetchingReleases(true);
setErrorMessage(null);
setPreviewSkills([]);
setSelectedPreviewPaths([]);
setActivePreviewPath('');
try {
const result = await httpClient.getGithubReleases(githubURL);
setGithubReleases(result.releases);
setGithubOwner(result.owner);
setGithubRepo(result.repo);
setGithubSourceSubdir(result.source_subdir || '');
if (result.releases.length === 0) {
toast.warning(t('skills.noReleasesFound'));
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message || t('skills.fetchReleasesError'));
} finally {
setFetchingReleases(false);
}
}
async function handleReleaseSelect(release: GithubRelease) {
setSelectedRelease(release);
setSelectedAsset(null);
setPreviewSkills([]);
setSelectedPreviewPaths([]);
setActivePreviewPath('');
setErrorMessage(null);
setFetchingAssets(true);
try {
if (release.source_type && release.source_type !== 'release') {
const archiveAsset = buildSourceArchiveAsset(release);
setGithubAssets(archiveAsset ? [archiveAsset] : []);
if (!archiveAsset) {
toast.warning(t('skills.noAssetsFound'));
}
return;
}
const result = await httpClient.getGithubReleaseAssets(
githubOwner,
githubRepo,
release.id,
release.tag_name,
release.source_type,
release.archive_url,
);
let assets = result.assets;
if (assets.length === 0) {
const archiveAsset = buildSourceArchiveAsset(release);
if (archiveAsset) {
assets = [archiveAsset];
}
}
setGithubAssets(assets);
if (assets.length === 0) {
toast.warning(t('skills.noAssetsFound'));
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message || t('skills.fetchAssetsError'));
} finally {
setFetchingAssets(false);
}
}
async function handleGithubPreview(asset: GithubAsset) {
if (!selectedRelease) return;
setSelectedAsset(asset);
setPreviewSkills([]);
setSelectedPreviewPaths([]);
setActivePreviewPath('');
setErrorMessage(null);
setPreviewingGithub(true);
try {
const resp = await httpClient.previewSkillInstallFromGithub(
asset.download_url,
githubOwner,
githubRepo,
selectedRelease.tag_name,
githubSourceSubdir,
);
const skills = resp.skills as PreviewSkill[];
setPreviewSkills(skills);
initializeSelection(
skills,
setSelectedPreviewPaths,
setActivePreviewPath,
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message || t('skills.installError'));
} finally {
setPreviewingGithub(false);
}
}
async function handleGithubImport() {
if (!selectedAsset || !selectedRelease || selectedPreviewPaths.length === 0)
return;
setInstallingGithub(true);
setErrorMessage(null);
try {
const resp = await httpClient.installSkillFromGithub(
selectedAsset.download_url,
githubOwner,
githubRepo,
selectedRelease.tag_name,
selectedPreviewPaths,
githubSourceSubdir,
);
toast.success(t('skills.installSuccess'));
onImported(resp.skills.map((skill) => skill.name));
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message || t('skills.installError'));
} finally {
setInstallingGithub(false);
}
}
async function handleUploadPreview() {
if (!uploadFile) return;
if (!uploadFile.name.toLowerCase().endsWith('.zip')) {
setErrorMessage(t('skills.uploadZipOnly'));
return;
}
setPreviewingUpload(true);
setUploadPreviewSkills([]);
setSelectedUploadPreviewPaths([]);
setActiveUploadPreviewPath('');
setErrorMessage(null);
try {
const resp = await httpClient.previewSkillInstallFromUpload(uploadFile);
const skills = resp.skills as PreviewSkill[];
setUploadPreviewSkills(skills);
initializeSelection(
skills,
setSelectedUploadPreviewPaths,
setActiveUploadPreviewPath,
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message || t('skills.installError'));
} finally {
setPreviewingUpload(false);
}
}
async function handleUploadImport() {
if (!uploadFile || selectedUploadPreviewPaths.length === 0) return;
setInstallingUpload(true);
setErrorMessage(null);
try {
const resp = await httpClient.installSkillFromUpload(
uploadFile,
selectedUploadPreviewPaths,
);
toast.success(t('skills.installSuccess'));
onImported(resp.skills.map((skill) => skill.name));
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message || t('skills.installError'));
} finally {
setInstallingUpload(false);
}
}
function renderCandidateSelector(
skills: PreviewSkill[],
selectedPaths: string[],
activePath: string,
setSelectedPaths: (paths: string[]) => void,
setActivePath: (path: string) => void,
) {
if (skills.length <= 1) {
return null;
}
return (
<div className="space-y-2">
{skills.map((skill) => {
const path = previewPath(skill);
const selected = selectedPaths.includes(path);
const active = path === activePath;
return (
<div
key={path || skill.name}
className={`w-full rounded-lg border p-3 transition-colors ${
active ? 'border-primary bg-accent/50' : 'hover:bg-accent/50'
}`}
>
<div className="flex items-start gap-3">
<Checkbox
checked={selected}
onCheckedChange={() =>
toggleSelection(
path,
selectedPaths,
setSelectedPaths,
setActivePath,
)
}
/>
<button
type="button"
className="flex-1 text-left"
onClick={() => setActivePath(path)}
>
<div className="font-medium">
{skill.display_name || skill.name}
</div>
<div className="text-sm text-muted-foreground">
{(skill.source_path || '.') + ' · ' + skill.name}
</div>
</button>
</div>
</div>
);
})}
</div>
);
}
function renderPreviewDetail(skill: PreviewSkill | null) {
if (!skill) return null;
return (
<>
<div className="space-y-2 text-sm">
<div>
<span className="font-medium">{t('skills.displayName')}:</span>{' '}
{skill.display_name || '-'}
</div>
<div>
<span className="font-medium">{t('skills.skillSlug')}:</span>{' '}
{skill.name}
</div>
<div>
<span className="font-medium">{t('skills.skillDescription')}:</span>{' '}
{skill.description}
</div>
<div>
<span className="font-medium">{t('skills.packageRoot')}:</span>{' '}
{skill.package_root}
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">
{t('skills.skillInstructions')}
</div>
<pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs">
{skill.instructions || ''}
</pre>
</div>
</>
);
}
return (
<div className="space-y-6">
{(mode === 'all' || mode === 'github') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Github className="size-5" />
<span>{t('skills.importFromGithub')}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{githubReleases.length === 0 && (
<div className="flex gap-2">
<Input
placeholder={t('skills.repoUrlPlaceholder')}
value={githubURL}
onChange={(e) => setGithubURL(e.target.value)}
/>
<Button
type="button"
onClick={fetchReleases}
disabled={!githubURL.trim() || fetchingReleases}
>
{fetchingReleases ? t('skills.loading') : t('common.confirm')}
</Button>
</div>
)}
{githubReleases.length > 0 && !selectedRelease && (
<div className="space-y-2">
{githubReleases.map((release) => (
<button
key={release.id}
type="button"
className="w-full rounded-lg border p-3 text-left hover:bg-accent/50 transition-colors"
onClick={() => handleReleaseSelect(release)}
>
<div className="font-medium">
{release.name || release.tag_name}
</div>
<div className="text-sm text-muted-foreground">
{t('skills.releaseTag', { tag: release.tag_name })}
</div>
</button>
))}
</div>
)}
{selectedRelease && previewSkills.length === 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="font-medium">
{selectedRelease.name || selectedRelease.tag_name}
</div>
<div className="text-sm text-muted-foreground">
{t('skills.releaseTag', {
tag: selectedRelease.tag_name,
})}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setSelectedRelease(null);
setGithubAssets([]);
setSelectedAsset(null);
setPreviewSkills([]);
setSelectedPreviewPaths([]);
setActivePreviewPath('');
setErrorMessage(null);
}}
>
<ChevronLeft className="size-4 mr-1" />
{t('skills.backToReleases')}
</Button>
</div>
{fetchingAssets && (
<div className="text-sm text-muted-foreground">
{t('skills.loading')}
</div>
)}
{!fetchingAssets && githubAssets.length > 0 && (
<div className="space-y-2">
{githubAssets.map((asset) => (
<button
key={asset.id}
type="button"
className="w-full rounded-lg border p-3 text-left hover:bg-accent/50 transition-colors"
onClick={() => handleGithubPreview(asset)}
disabled={previewingGithub}
>
<div className="font-medium">{asset.name}</div>
<div className="text-sm text-muted-foreground">
{t('skills.assetSize', {
size: formatFileSize(asset.size),
})}
</div>
</button>
))}
</div>
)}
</div>
)}
{previewSkills.length > 0 && selectedRelease && selectedAsset && (
<div className="space-y-4 rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="font-medium">{t('skills.preview')}</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setPreviewSkills([]);
setSelectedPreviewPaths([]);
setActivePreviewPath('');
setSelectedAsset(null);
}}
>
<ChevronLeft className="size-4 mr-1" />
{t('skills.backToAssets')}
</Button>
</div>
{renderCandidateSelector(
previewSkills,
selectedPreviewPaths,
activePreviewPath,
setSelectedPreviewPaths,
setActivePreviewPath,
)}
{renderPreviewDetail(activePreviewSkill)}
<div className="flex justify-end">
<Button
type="button"
onClick={handleGithubImport}
disabled={
installingGithub || selectedPreviewPaths.length === 0
}
>
{installingGithub
? t('skills.installing')
: t('skills.confirmInstall')}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
{(mode === 'all' || mode === 'upload') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="size-5" />
<span>{t('skills.uploadZip')}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input
type="file"
accept=".zip,application/zip"
onChange={(e) => {
const file = e.target.files?.[0] ?? null;
setUploadFile(file);
setUploadPreviewSkills([]);
setSelectedUploadPreviewPaths([]);
setActiveUploadPreviewPath('');
setErrorMessage(null);
}}
/>
{uploadFile && (
<div className="text-sm text-muted-foreground">
{uploadFile.name}
</div>
)}
<div className="flex justify-end">
<Button
type="button"
onClick={handleUploadPreview}
disabled={!uploadFile || previewingUpload}
>
{previewingUpload ? t('skills.loading') : t('skills.preview')}
</Button>
</div>
{uploadPreviewSkills.length > 0 && uploadFile && (
<div className="space-y-4 rounded-lg border p-4">
<div className="font-medium">{t('skills.preview')}</div>
{renderCandidateSelector(
uploadPreviewSkills,
selectedUploadPreviewPaths,
activeUploadPreviewPath,
setSelectedUploadPreviewPaths,
setActiveUploadPreviewPath,
)}
{renderPreviewDetail(activeUploadPreviewSkill)}
<div className="flex justify-end">
<Button
type="button"
onClick={handleUploadImport}
disabled={
installingUpload ||
selectedUploadPreviewPaths.length === 0
}
>
{installingUpload
? t('skills.installing')
: t('skills.confirmInstall')}
</Button>
</div>
</div>
)}
{errorMessage && (
<div className="text-sm text-destructive">{errorMessage}</div>
)}
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,277 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { BookOpen, FileArchive, Loader2, PackageOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { httpClient } from '@/app/infra/http/HttpClient';
import type { Skill } from '@/app/infra/entities/api';
import { cn } from '@/lib/utils';
interface PreviewSkill extends Skill {
source_path?: string;
}
interface SkillZipPreviewPanelProps {
file: File;
onImported: (skillNames: string[]) => void;
onCancel?: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
function previewPath(skill: PreviewSkill): string {
return skill.source_path ?? '';
}
function displayPreviewPath(skill: PreviewSkill): string {
return previewPath(skill) || skill.name;
}
function truncateInstructions(instructions?: string): string {
if (!instructions) return '';
const trimmed = instructions.trim();
if (trimmed.length <= 900) return trimmed;
return trimmed.slice(0, 900).trimEnd() + '\n...';
}
export default function SkillZipPreviewPanel({
file,
onImported,
onCancel,
}: SkillZipPreviewPanelProps) {
const { t } = useTranslation();
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
const [activePath, setActivePath] = useState('');
const [previewing, setPreviewing] = useState(false);
const [installing, setInstalling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const lastPreviewSignatureRef = useRef('');
const previewFileSignature = `${file.name}:${file.size}:${file.lastModified}`;
const activeSkill = useMemo(
() =>
previewSkills.find((skill) => previewPath(skill) === activePath) ||
previewSkills[0] ||
null,
[activePath, previewSkills],
);
const loadPreview = useCallback(async () => {
setPreviewing(true);
setPreviewSkills([]);
setSelectedPaths([]);
setActivePath('');
setErrorMessage(null);
try {
const resp = await httpClient.previewSkillInstallFromUpload(file);
const skills = (resp.skills || []) as PreviewSkill[];
setPreviewSkills(skills);
const paths = skills.map(previewPath);
setSelectedPaths(paths);
setActivePath(paths[0] || '');
if (skills.length === 0) {
setErrorMessage(t('skills.noSkillMdInDirectory'));
} else {
setErrorMessage(null);
}
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('skills.previewLoadError'));
} finally {
setPreviewing(false);
}
}, [file, t]);
useEffect(() => {
if (lastPreviewSignatureRef.current === previewFileSignature) return;
lastPreviewSignatureRef.current = previewFileSignature;
void loadPreview();
}, [loadPreview, previewFileSignature]);
function toggleSelection(path: string) {
setSelectedPaths((current) => {
if (current.includes(path)) {
const next = current.filter((item) => item !== path);
if (activePath === path) {
setActivePath(next[0] || path);
}
return next;
}
setActivePath(path);
return [...current, path];
});
}
async function handleInstall() {
if (selectedPaths.length === 0) return;
setInstalling(true);
setErrorMessage(null);
try {
const resp = await httpClient.installSkillFromUpload(file, selectedPaths);
toast.success(t('skills.installSuccess'));
onImported(resp.skills.map((skill) => skill.name));
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('skills.installError'));
} finally {
setInstalling(false);
}
}
const activeInstructions = truncateInstructions(activeSkill?.instructions);
return (
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-md bg-muted/40 px-3 py-3">
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground">
{previewing ? (
<Loader2 className="size-4 animate-spin" />
) : (
<FileArchive className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{previewing ? t('skills.loading') : t('skills.preview')}
</div>
<div className="mt-1 break-all text-xs text-muted-foreground">
{file.name} · {formatFileSize(file.size)}
</div>
</div>
</div>
{previewSkills.length > 0 && (
<div
className={cn(
'grid gap-4',
previewSkills.length > 1 && 'md:grid-cols-[240px_minmax(0,1fr)]',
)}
>
{previewSkills.length > 1 && (
<div className="space-y-2">
{previewSkills.map((skill) => {
const path = previewPath(skill);
const displayPath = displayPreviewPath(skill);
const selected = selectedPaths.includes(path);
const active = activePath === path;
return (
<button
key={`${path}:${skill.name}`}
type="button"
className={cn(
'flex w-full items-start gap-2 rounded-md px-3 py-2 text-left transition-colors',
active ? 'bg-accent' : 'bg-muted/30 hover:bg-accent/70',
)}
onClick={() => setActivePath(path)}
>
<Checkbox
checked={selected}
onCheckedChange={() => toggleSelection(path)}
onClick={(event) => event.stopPropagation()}
className="mt-0.5"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{skill.display_name || skill.name}
</span>
{path && (
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{displayPath}
</span>
)}
</span>
</button>
);
})}
</div>
)}
{activeSkill && (
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<BookOpen className="size-4 text-muted-foreground" />
<h3 className="min-w-0 truncate text-base font-semibold">
{activeSkill.display_name || activeSkill.name}
</h3>
</div>
{activeSkill.description && (
<p className="text-sm leading-6 text-muted-foreground">
{activeSkill.description}
</p>
)}
{activeInstructions && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t('skills.previewInstructions')}
</div>
<div className="max-h-56 overflow-y-auto rounded-md bg-muted/40 p-3 font-mono text-xs leading-5 whitespace-pre-wrap">
{activeInstructions}
</div>
</div>
)}
</div>
)}
</div>
)}
{errorMessage && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
<div className="flex justify-end gap-2">
{onCancel && (
<Button variant="outline" onClick={onCancel} disabled={installing}>
{t('common.cancel')}
</Button>
)}
<Button
type="button"
onClick={handleInstall}
disabled={
previewing ||
installing ||
previewSkills.length === 0 ||
selectedPaths.length === 0
}
>
{installing ? (
<>
<Loader2 className="size-4 animate-spin" />
{t('skills.installing')}
</>
) : (
<>
<PackageOpen className="size-4" />
{t('skills.confirmInstall')}
</>
)}
</Button>
</div>
</div>
);
}
+37 -95
View File
@@ -1,131 +1,73 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import SkillDetailContent from '@/app/home/skills/SkillDetailContent'; import SkillDetailContent from '@/app/home/skills/SkillDetailContent';
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm'; import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import SkillGithubImportPanel from '@/app/home/skills/components/SkillGithubImportPanel'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import {
useSidebarData,
type SkillInstallAction,
} from '@/app/home/components/home-sidebar/SidebarDataContext';
export default function SkillsPage() { export default function SkillsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const detailId = searchParams.get('id'); const detailId = searchParams.get('id');
const actionParam = searchParams.get('action') as SkillInstallAction | null; const actionParam = searchParams.get('action');
const { const { refreshSkills } = useSidebarData();
refreshSkills,
pendingSkillInstallAction,
setPendingSkillInstallAction,
} = useSidebarData();
const [activeView, setActiveView] = useState<SkillInstallAction>(null); const isCreateView = actionParam === 'create';
useEffect(() => { useEffect(() => {
if (actionParam && ['create', 'github', 'upload'].includes(actionParam)) { if (!detailId && !isCreateView) {
setActiveView(actionParam); navigate('/home/add-extension', { replace: true });
return;
} }
if (!pendingSkillInstallAction) return; }, [detailId, isCreateView, navigate]);
const action = pendingSkillInstallAction;
setPendingSkillInstallAction(null);
setActiveView(action);
}, [actionParam, pendingSkillInstallAction, setPendingSkillInstallAction]);
if (detailId) { if (detailId) {
return <SkillDetailContent id={detailId} />; return <SkillDetailContent id={detailId} />;
} }
function handleImportedSkills(_skillNames: string[]) { function handleCreatedSkill(skillName: string) {
void refreshSkills(); void refreshSkills();
setActiveView(null); navigate(`/home/skills?id=${encodeURIComponent(skillName)}`, {
navigate('/home/add-extension'); replace: true,
});
} }
function handleCancel() { function handleCancel() {
setActiveView(null);
navigate('/home/add-extension'); navigate('/home/add-extension');
} }
if (activeView === 'create') { if (!isCreateView) {
return ( return null;
<div className="flex h-full flex-col">
<div className="flex items-center justify-between pb-4 shrink-0">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 className="text-xl font-semibold">{t('skills.createSkill')}</h1>
<p className="text-sm text-muted-foreground">
{t('skills.createSkillDescription')}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}
</Button>
<Button type="submit" form="skill-form">
{t('common.save')}
</Button>
</div>
</div>
<div className="min-h-0 flex-1">
<SkillForm
key="new-skill"
initSkillName={undefined}
layout="split"
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
onSkillUpdated={() => {}}
/>
</div>
</div>
);
} }
if (activeView === 'github') { return (
return ( <div className="flex h-full flex-col">
<div className="flex h-full flex-col"> <div className="flex items-center justify-between pb-4 shrink-0">
<div className="flex items-center justify-between pb-4 shrink-0"> <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">{t('skills.createSkill')}</h1>
{t('skills.importFromGithub')} <p className="text-sm text-muted-foreground">
</h1> {t('skills.createSkillDescription')}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}> <Button variant="outline" onClick={handleCancel}>
{t('common.cancel')} {t('common.cancel')}
</Button> </Button>
</div> <Button type="submit" form="skill-form">
<div className="flex-1 overflow-y-auto min-h-0"> {t('common.save')}
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<SkillGithubImportPanel
mode="github"
onImported={handleImportedSkills}
/>
</div>
</div>
</div>
);
}
if (activeView === 'upload') {
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('skills.uploadZip')}</h1>
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<SkillGithubImportPanel
mode="upload"
onImported={handleImportedSkills}
/>
</div>
</div>
</div> </div>
); <div className="min-h-0 flex-1">
} <SkillForm
key="new-skill"
navigate('/home/add-extension'); initSkillName={undefined}
return null; layout="split"
onNewSkillCreated={handleCreatedSkill}
onSkillUpdated={() => {}}
/>
</div>
</div>
);
} }
+23
View File
@@ -55,6 +55,7 @@ import {
ApiRespSkill, ApiRespSkill,
} from '@/app/infra/entities/api'; } from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin'; 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 { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse'; 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); return this.postFile('/api/v1/plugins/install/local', formData);
} }
public previewPluginInstallFromLocal(file: File): Promise<{
filename: string;
size: number;
manifest: Record<string, unknown>;
metadata: {
author?: string;
name?: string;
version?: string;
label?: I18nObject;
description?: I18nObject;
repository?: string;
};
component_types: string[];
component_counts: Record<string, number>;
requirements: string[];
file_count: number;
}> {
const formData = new FormData();
formData.append('file', file);
return this.postFile('/api/v1/plugins/install/local/preview', formData);
}
// ============ Skill Install API ============ // ============ Skill Install API ============
public installSkillFromGithub( public installSkillFromGithub(
assetUrl: string, assetUrl: string,
+16 -1
View File
@@ -520,6 +520,21 @@ const enUS = {
uploadLocal: 'Upload Local', uploadLocal: 'Upload Local',
debugging: 'Debugging', debugging: 'Debugging',
uploadLocalPlugin: 'Upload Local Plugin', 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', uploadPluginOnly: 'Only .lbpkg files are supported',
dragToUpload: 'Drag plugin file here to upload', dragToUpload: 'Drag plugin file here to upload',
unsupportedFileType: unsupportedFileType:
@@ -1370,7 +1385,7 @@ const enUS = {
scanError: 'Failed to scan directory: ', scanError: 'Failed to scan directory: ',
noSkills: 'No skills configured', noSkills: 'No skills configured',
preview: 'Preview', preview: 'Preview',
previewInstructions: 'Preview Instructions', previewInstructions: 'SKILL.md Content Preview',
instructionsPlaceholder: 'Enter skill instructions in Markdown format...', instructionsPlaceholder: 'Enter skill instructions in Markdown format...',
descriptionPlaceholder: descriptionPlaceholder:
'A brief description of what this skill does (shown to the LLM)', 'A brief description of what this skill does (shown to the LLM)',
+17 -1
View File
@@ -532,6 +532,22 @@ const esES = {
uploadLocal: 'Subir local', uploadLocal: 'Subir local',
debugging: 'Depuración', debugging: 'Depuración',
uploadLocalPlugin: 'Subir plugin local', 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', dragToUpload: 'Arrastra el archivo del plugin aquí para subirlo',
unsupportedFileType: unsupportedFileType:
'Tipo de archivo no soportado, solo se admiten archivos .lbpkg y .zip', 'Tipo de archivo no soportado, solo se admiten archivos .lbpkg y .zip',
@@ -1486,7 +1502,7 @@ const esES = {
scanError: 'Error al escanear el directorio: ', scanError: 'Error al escanear el directorio: ',
noSkills: 'No hay skills configuradas', noSkills: 'No hay skills configuradas',
preview: 'Vista previa', preview: 'Vista previa',
previewInstructions: 'Vista previa de instrucciones', previewInstructions: 'Vista previa del contenido de SKILL.md',
instructionsPlaceholder: instructionsPlaceholder:
'Introduce las instrucciones de la skill en formato Markdown...', 'Introduce las instrucciones de la skill en formato Markdown...',
descriptionPlaceholder: descriptionPlaceholder:
+17 -1
View File
@@ -524,6 +524,22 @@ const jaJP = {
uploadLocal: 'ローカルアップロード', uploadLocal: 'ローカルアップロード',
debugging: 'デバッグ中', debugging: 'デバッグ中',
uploadLocalPlugin: 'ローカルプラグインのアップロード', uploadLocalPlugin: 'ローカルプラグインのアップロード',
localPreview: {
title: 'ローカルプラグインパッケージをプレビュー',
unpacking: 'パッケージを展開してプレビュー中...',
unpackComplete: 'パッケージのプレビュー準備完了',
failed: 'パッケージのプレビューに失敗しました',
pluginInfo: 'プラグイン情報',
packageInfo: 'パッケージ情報',
name: '名前',
author: '作者',
version: 'バージョン',
fileCount: 'ファイル数',
dependencies: '依存関係',
components: 'コンポーネント',
ready:
'プラグインパッケージを展開しました。確認するとインストールを開始します。',
},
dragToUpload: 'ファイルをここにドラッグしてアップロード', dragToUpload: 'ファイルをここにドラッグしてアップロード',
unsupportedFileType: unsupportedFileType:
'サポートされていないファイルタイプです。.lbpkg と .zip ファイルのみサポートされています', 'サポートされていないファイルタイプです。.lbpkg と .zip ファイルのみサポートされています',
@@ -1476,7 +1492,7 @@ const jaJP = {
scanError: 'ディレクトリのスキャンに失敗しました: ', scanError: 'ディレクトリのスキャンに失敗しました: ',
noSkills: '設定済みのスキルはありません', noSkills: '設定済みのスキルはありません',
preview: 'プレビュー', preview: 'プレビュー',
previewInstructions: '指示内容プレビュー', previewInstructions: 'SKILL.md 内容プレビュー',
instructionsPlaceholder: 'Markdown 形式でスキルの指示を入力...', instructionsPlaceholder: 'Markdown 形式でスキルの指示を入力...',
descriptionPlaceholder: 'このスキルの概要(LLM に表示されます)', descriptionPlaceholder: 'このスキルの概要(LLM に表示されます)',
packageRoot: 'パッケージディレクトリ', packageRoot: 'パッケージディレクトリ',
+16 -1
View File
@@ -530,6 +530,21 @@ const ruRU = {
uploadLocal: 'Загрузить локально', uploadLocal: 'Загрузить локально',
debugging: 'Отладка', debugging: 'Отладка',
uploadLocalPlugin: 'Загрузить локальный плагин', uploadLocalPlugin: 'Загрузить локальный плагин',
localPreview: {
title: 'Предпросмотр локального пакета плагина',
unpacking: 'Распаковка пакета для предпросмотра...',
unpackComplete: 'Предпросмотр пакета готов',
failed: 'Не удалось выполнить предпросмотр пакета',
pluginInfo: 'Информация о плагине',
packageInfo: 'Информация о пакете',
name: 'Название',
author: 'Автор',
version: 'Версия',
fileCount: 'Файлы',
dependencies: 'Зависимости',
components: 'Компоненты',
ready: 'Пакет плагина распакован. Подтвердите, чтобы начать установку.',
},
dragToUpload: 'Перетащите файл плагина сюда для загрузки', dragToUpload: 'Перетащите файл плагина сюда для загрузки',
unsupportedFileType: unsupportedFileType:
'Неподдерживаемый тип файла, поддерживаются только файлы .lbpkg и .zip', 'Неподдерживаемый тип файла, поддерживаются только файлы .lbpkg и .zip',
@@ -1459,7 +1474,7 @@ const ruRU = {
scanError: 'Не удалось просканировать каталог: ', scanError: 'Не удалось просканировать каталог: ',
noSkills: 'Навыки не настроены', noSkills: 'Навыки не настроены',
preview: 'Предпросмотр', preview: 'Предпросмотр',
previewInstructions: 'Предпросмотр инструкций', previewInstructions: 'Предпросмотр содержимого SKILL.md',
instructionsPlaceholder: 'Введите инструкции навыка в формате Markdown...', instructionsPlaceholder: 'Введите инструкции навыка в формате Markdown...',
descriptionPlaceholder: descriptionPlaceholder:
'Краткое описание того, что делает этот навык (показывается LLM)', 'Краткое описание того, что делает этот навык (показывается LLM)',
+16 -1
View File
@@ -514,6 +514,21 @@ const thTH = {
uploadLocal: 'อัปโหลดจากเครื่อง', uploadLocal: 'อัปโหลดจากเครื่อง',
debugging: 'ดีบัก', debugging: 'ดีบัก',
uploadLocalPlugin: 'อัปโหลดปลั๊กอินจากเครื่อง', uploadLocalPlugin: 'อัปโหลดปลั๊กอินจากเครื่อง',
localPreview: {
title: 'ดูตัวอย่างแพ็กเกจปลั๊กอินในเครื่อง',
unpacking: 'กำลังแตกแพ็กเกจเพื่อดูตัวอย่าง...',
unpackComplete: 'พร้อมดูตัวอย่างแพ็กเกจแล้ว',
failed: 'ดูตัวอย่างแพ็กเกจไม่สำเร็จ',
pluginInfo: 'ข้อมูลปลั๊กอิน',
packageInfo: 'ข้อมูลแพ็กเกจ',
name: 'ชื่อ',
author: 'ผู้เขียน',
version: 'เวอร์ชัน',
fileCount: 'จำนวนไฟล์',
dependencies: 'แพ็กเกจที่ต้องใช้',
components: 'คอมโพเนนต์',
ready: 'แตกแพ็กเกจปลั๊กอินแล้ว ยืนยันเพื่อเริ่มติดตั้ง',
},
dragToUpload: 'ลากไฟล์ปลั๊กอินมาวางที่นี่เพื่ออัปโหลด', dragToUpload: 'ลากไฟล์ปลั๊กอินมาวางที่นี่เพื่ออัปโหลด',
unsupportedFileType: 'ประเภทไฟล์ไม่รองรับ รองรับเฉพาะไฟล์ .lbpkg และ .zip', unsupportedFileType: 'ประเภทไฟล์ไม่รองรับ รองรับเฉพาะไฟล์ .lbpkg และ .zip',
uploadingPlugin: 'กำลังอัปโหลดปลั๊กอิน...', uploadingPlugin: 'กำลังอัปโหลดปลั๊กอิน...',
@@ -1425,7 +1440,7 @@ const thTH = {
scanError: 'สแกนไดเรกทอรีไม่สำเร็จ: ', scanError: 'สแกนไดเรกทอรีไม่สำเร็จ: ',
noSkills: 'ยังไม่มีสกิลที่ตั้งค่าไว้', noSkills: 'ยังไม่มีสกิลที่ตั้งค่าไว้',
preview: 'ดูตัวอย่าง', preview: 'ดูตัวอย่าง',
previewInstructions: 'ดูตัวอย่างคำสั่ง', previewInstructions: 'ตัวอย่างเนื้อหา SKILL.md',
instructionsPlaceholder: 'ป้อนคำสั่งของสกิลในรูปแบบ Markdown...', instructionsPlaceholder: 'ป้อนคำสั่งของสกิลในรูปแบบ Markdown...',
descriptionPlaceholder: descriptionPlaceholder:
'คำอธิบายสั้น ๆ ว่าสกิลนี้ทำอะไร (แสดงให้ LLM เห็น)', 'คำอธิบายสั้น ๆ ว่าสกิลนี้ทำอะไร (แสดงให้ LLM เห็น)',
+16 -1
View File
@@ -525,6 +525,21 @@ const viVN = {
uploadLocal: 'Tải lên cục bộ', uploadLocal: 'Tải lên cục bộ',
debugging: 'Gỡ lỗi', debugging: 'Gỡ lỗi',
uploadLocalPlugin: 'Tải lên Plugin cục bộ', 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', dragToUpload: 'Kéo tệp plugin vào đây để tải lên',
unsupportedFileType: unsupportedFileType:
'Loại tệp không được hỗ trợ, chỉ hỗ trợ tệp .lbpkg và .zip', '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: ', scanError: 'Quét thư mục thất bại: ',
noSkills: 'Chưa cấu hình kỹ năng nào', noSkills: 'Chưa cấu hình kỹ năng nào',
preview: 'Xem trước', preview: 'Xem trước',
previewInstructions: 'Xem trước hướng dẫn', previewInstructions: 'Xem trước nội dung SKILL.md',
instructionsPlaceholder: instructionsPlaceholder:
'Nhập hướng dẫn kỹ năng theo định dạng Markdown...', 'Nhập hướng dẫn kỹ năng theo định dạng Markdown...',
descriptionPlaceholder: descriptionPlaceholder:
+16 -1
View File
@@ -497,6 +497,21 @@ const zhHans = {
uploadLocal: '本地上传', uploadLocal: '本地上传',
debugging: '调试中', debugging: '调试中',
uploadLocalPlugin: '上传本地插件', uploadLocalPlugin: '上传本地插件',
localPreview: {
title: '预览本地插件包',
unpacking: '正在解包预览...',
unpackComplete: '解包预览完成',
failed: '解包预览失败',
pluginInfo: '插件信息',
packageInfo: '包信息',
name: '名称',
author: '作者',
version: '版本',
fileCount: '文件数',
dependencies: '依赖',
components: '组件',
ready: '插件包已解包,确认后开始安装。',
},
uploadPluginOnly: '仅支持 .lbpkg 文件', uploadPluginOnly: '仅支持 .lbpkg 文件',
dragToUpload: '拖拽文件到此处上传', dragToUpload: '拖拽文件到此处上传',
unsupportedFileType: '不支持的文件类型,仅支持 .lbpkg 文件', unsupportedFileType: '不支持的文件类型,仅支持 .lbpkg 文件',
@@ -1313,7 +1328,7 @@ const zhHans = {
scanError: '扫描目录失败:', scanError: '扫描目录失败:',
noSkills: '暂未配置任何技能', noSkills: '暂未配置任何技能',
preview: '预览', preview: '预览',
previewInstructions: '预览指令', previewInstructions: 'SKILL.md 内容预览',
instructionsPlaceholder: '使用 Markdown 格式输入技能指令...', instructionsPlaceholder: '使用 Markdown 格式输入技能指令...',
descriptionPlaceholder: '简短描述此技能的功能(会展示给 LLM)', descriptionPlaceholder: '简短描述此技能的功能(会展示给 LLM)',
packageRoot: '技能目录', packageRoot: '技能目录',
+16 -1
View File
@@ -498,6 +498,21 @@ const zhHant = {
uploadLocal: '本地上傳', uploadLocal: '本地上傳',
debugging: '調試中', debugging: '調試中',
uploadLocalPlugin: '上傳本地插件', uploadLocalPlugin: '上傳本地插件',
localPreview: {
title: '預覽本地外掛包',
unpacking: '正在解包預覽...',
unpackComplete: '解包預覽完成',
failed: '解包預覽失敗',
pluginInfo: '外掛資訊',
packageInfo: '包資訊',
name: '名稱',
author: '作者',
version: '版本',
fileCount: '檔案數',
dependencies: '依賴',
components: '元件',
ready: '外掛包已解包,確認後開始安裝。',
},
dragToUpload: '拖拽文件到此處上傳', dragToUpload: '拖拽文件到此處上傳',
unsupportedFileType: '不支持的文件類型,僅支持 .lbpkg 和 .zip 文件', unsupportedFileType: '不支持的文件類型,僅支持 .lbpkg 和 .zip 文件',
uploadingPlugin: '正在上傳插件...', uploadingPlugin: '正在上傳插件...',
@@ -1405,7 +1420,7 @@ const zhHant = {
scanError: '掃描目錄失敗:', scanError: '掃描目錄失敗:',
noSkills: '暫未配置任何技能', noSkills: '暫未配置任何技能',
preview: '預覽', preview: '預覽',
previewInstructions: '預覽指令', previewInstructions: 'SKILL.md 內容預覽',
instructionsPlaceholder: '使用 Markdown 格式輸入技能指令...', instructionsPlaceholder: '使用 Markdown 格式輸入技能指令...',
descriptionPlaceholder: '簡短描述此技能的功能', descriptionPlaceholder: '簡短描述此技能的功能',
packageRoot: '技能目錄', packageRoot: '技能目錄',
-22
View File
@@ -19,8 +19,6 @@ import BotsPage from '@/app/home/bots/page';
import PipelinesPage from '@/app/home/pipelines/page'; import PipelinesPage from '@/app/home/pipelines/page';
import PluginsPage from '@/app/home/plugins/page'; import PluginsPage from '@/app/home/plugins/page';
import AddExtensionPage from '@/app/home/add-extension/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 MCPPage from '@/app/home/mcp/page';
import KnowledgePage from '@/app/home/knowledge/page'; import KnowledgePage from '@/app/home/knowledge/page';
import SkillsPage from '@/app/home/skills/page'; import SkillsPage from '@/app/home/skills/page';
@@ -134,26 +132,6 @@ export const router = createBrowserRouter([
</Suspense> </Suspense>
), ),
}, },
{
path: '/home/add-plugin',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<AddPluginPage />
</HomeLayout>
</Suspense>
),
},
{
path: '/home/market',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<MarketPage />
</HomeLayout>
</Suspense>
),
},
{ {
path: '/home/mcp', path: '/home/mcp',
element: ( element: (