mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat: polish extension import flow
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import quart
|
||||
import re
|
||||
import httpx
|
||||
import uuid
|
||||
import os
|
||||
import zipfile
|
||||
import yaml
|
||||
from urllib.parse import urlparse
|
||||
import posixpath
|
||||
|
||||
@@ -42,6 +45,60 @@ def _normalize_plugin_asset_path(filepath: str) -> str | None:
|
||||
|
||||
@group.group_class('plugins', '/api/v1/plugins')
|
||||
class PluginsRouterGroup(group.RouterGroup):
|
||||
@staticmethod
|
||||
def _normalize_archive_path(path: str) -> str:
|
||||
normalized = str(path or '').replace('\\', '/').strip('/')
|
||||
return posixpath.normpath(normalized) if normalized else ''
|
||||
|
||||
@classmethod
|
||||
def _component_source_path(cls, entry) -> str:
|
||||
if isinstance(entry, dict):
|
||||
return cls._normalize_archive_path(entry.get('path') or '')
|
||||
return cls._normalize_archive_path(str(entry or ''))
|
||||
|
||||
@classmethod
|
||||
def _count_component_configs(cls, component_config, archive_names: list[str]) -> int:
|
||||
normalized_names = [cls._normalize_archive_path(name) for name in archive_names]
|
||||
component_files: set[str] = set()
|
||||
|
||||
if isinstance(component_config, list):
|
||||
return len(component_config)
|
||||
if not isinstance(component_config, dict):
|
||||
return 1 if component_config else 0
|
||||
|
||||
for entry in component_config.get('fromFiles') or []:
|
||||
source_path = cls._component_source_path(entry)
|
||||
if source_path and source_path in normalized_names:
|
||||
component_files.add(source_path)
|
||||
|
||||
for entry in component_config.get('fromDirs') or []:
|
||||
source_dir = cls._component_source_path(entry).rstrip('/')
|
||||
if not source_dir:
|
||||
continue
|
||||
prefix = f'{source_dir}/'
|
||||
for archive_name in normalized_names:
|
||||
if not archive_name.startswith(prefix):
|
||||
continue
|
||||
if archive_name.lower().endswith(('.yaml', '.yml')):
|
||||
component_files.add(archive_name)
|
||||
|
||||
if component_files:
|
||||
return len(component_files)
|
||||
|
||||
return 1 if any(key in component_config for key in ('path', 'name', 'kind')) else 0
|
||||
|
||||
@classmethod
|
||||
def _count_plugin_components(cls, components, archive_names: list[str]) -> dict[str, int]:
|
||||
if not isinstance(components, dict):
|
||||
return {}
|
||||
|
||||
component_counts: dict[str, int] = {}
|
||||
for kind, component_config in components.items():
|
||||
count = cls._count_component_configs(component_config, archive_names)
|
||||
if count > 0:
|
||||
component_counts[str(kind)] = count
|
||||
return component_counts
|
||||
|
||||
@staticmethod
|
||||
def _parse_github_repo_url(repo_url: str) -> dict | None:
|
||||
raw_url = str(repo_url or '').strip()
|
||||
@@ -490,6 +547,62 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
@self.route('/install/local/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
file = (await quart.request.files).get('file')
|
||||
if file is None:
|
||||
return self.http_status(400, -1, 'file is required')
|
||||
|
||||
file_bytes = file.read()
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
||||
names = [name for name in zf.namelist() if not name.endswith('/')]
|
||||
manifest_name = next(
|
||||
(
|
||||
name
|
||||
for name in names
|
||||
if name.replace('\\', '/').strip('/').lower() in ('manifest.yaml', 'manifest.yml')
|
||||
),
|
||||
None,
|
||||
)
|
||||
if manifest_name is None:
|
||||
return self.http_status(400, -1, 'manifest.yaml is required')
|
||||
|
||||
manifest = yaml.safe_load(zf.read(manifest_name).decode('utf-8')) or {}
|
||||
requirements: list[str] = []
|
||||
requirements_name = next(
|
||||
(name for name in names if name.replace('\\', '/').strip('/').lower() == 'requirements.txt'),
|
||||
None,
|
||||
)
|
||||
if requirements_name is not None:
|
||||
requirements = [
|
||||
line.strip()
|
||||
for line in zf.read(requirements_name).decode('utf-8', errors='ignore').splitlines()
|
||||
if line.strip() and not line.strip().startswith('#')
|
||||
]
|
||||
|
||||
spec = manifest.get('spec') or {}
|
||||
components = spec.get('components') or {}
|
||||
component_counts = self._count_plugin_components(components, names)
|
||||
component_types = list(component_counts.keys())
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'filename': file.filename or 'local plugin',
|
||||
'size': len(file_bytes),
|
||||
'manifest': manifest,
|
||||
'metadata': manifest.get('metadata') or {},
|
||||
'component_types': component_types,
|
||||
'component_counts': component_counts,
|
||||
'requirements': requirements,
|
||||
'file_count': len(names),
|
||||
}
|
||||
)
|
||||
except zipfile.BadZipFile:
|
||||
return self.http_status(400, -1, 'invalid .lbpkg file')
|
||||
except Exception as exc:
|
||||
return self.http_status(500, -1, f'Failed to preview plugin package: {exc}')
|
||||
|
||||
@self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""Upload a file for plugin configuration"""
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
BookOpen,
|
||||
FileArchive,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
CircleHelp,
|
||||
} from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -33,7 +31,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -46,8 +44,8 @@ import type {
|
||||
MCPFormDraft,
|
||||
MCPFormHandle,
|
||||
} from '@/app/home/mcp/components/mcp-form/MCPForm';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel';
|
||||
import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel';
|
||||
|
||||
type PopoverView = 'menu' | 'mcp' | 'github';
|
||||
|
||||
@@ -151,6 +149,7 @@ export default function AddExtensionPage() {
|
||||
function AddExtensionContent() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
||||
const {
|
||||
addTask,
|
||||
@@ -170,11 +169,12 @@ function AddExtensionContent() {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [popoverView, setPopoverView] = useState<PopoverView>('menu');
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [skillUploadState, setSkillUploadState] = useState<
|
||||
'idle' | 'uploading' | 'done' | 'error'
|
||||
>('idle');
|
||||
const [skillUploadFileName, setSkillUploadFileName] = useState('');
|
||||
const [skillUploadError, setSkillUploadError] = useState<string | null>(null);
|
||||
const [skillUploadPreviewOpen, setSkillUploadPreviewOpen] = useState(false);
|
||||
const [skillUploadPreviewFile, setSkillUploadPreviewFile] =
|
||||
useState<File | null>(null);
|
||||
const [pluginUploadPreviewOpen, setPluginUploadPreviewOpen] = useState(false);
|
||||
const [pluginUploadPreviewFile, setPluginUploadPreviewFile] =
|
||||
useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const mcpFormRef = useRef<MCPFormHandle>(null);
|
||||
const [mcpTesting, setMcpTesting] = useState(false);
|
||||
@@ -209,6 +209,21 @@ function AddExtensionContent() {
|
||||
clearCompletedTasks();
|
||||
}, [clearCompletedTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('manual') !== '1') return;
|
||||
|
||||
setPopoverView('menu');
|
||||
setPopoverOpen(true);
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.delete('manual');
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const onComplete = (_taskId: number, success: boolean) => {
|
||||
if (success) {
|
||||
@@ -282,49 +297,20 @@ function AddExtensionContent() {
|
||||
}
|
||||
|
||||
const extType = getExtensionTypeFromFile(file);
|
||||
const fileName = file.name;
|
||||
const fileSize = file.size;
|
||||
|
||||
setPopoverOpen(false);
|
||||
// Clear any selected task to avoid showing stale dialogs
|
||||
setSelectedTaskId(null);
|
||||
|
||||
if (extType === 'plugin') {
|
||||
httpClient
|
||||
.installPluginFromLocal(file)
|
||||
.then((resp) => {
|
||||
const taskId = resp.task_id;
|
||||
const taskKey = `local-${taskId}`;
|
||||
addTask({
|
||||
taskId,
|
||||
pluginName: fileName,
|
||||
source: 'local',
|
||||
extensionType: 'plugin',
|
||||
fileSize,
|
||||
});
|
||||
setSelectedTaskId(taskKey);
|
||||
})
|
||||
.catch((err: { msg?: string }) => {
|
||||
toast.error(t('plugins.installFailed') + (err.msg || ''));
|
||||
});
|
||||
setPluginUploadPreviewFile(file);
|
||||
setPluginUploadPreviewOpen(true);
|
||||
} else {
|
||||
setSkillUploadFileName(fileName);
|
||||
setSkillUploadState('uploading');
|
||||
setSkillUploadError(null);
|
||||
httpClient
|
||||
.installSkillFromUpload(file)
|
||||
.then(() => {
|
||||
setSkillUploadState('done');
|
||||
refreshPlugins();
|
||||
refreshSkills();
|
||||
})
|
||||
.catch((err: { msg?: string }) => {
|
||||
setSkillUploadState('error');
|
||||
setSkillUploadError(err.msg || null);
|
||||
});
|
||||
setSkillUploadPreviewFile(file);
|
||||
setSkillUploadPreviewOpen(true);
|
||||
}
|
||||
},
|
||||
[t, addTask, setSelectedTaskId, refreshPlugins, refreshSkills],
|
||||
[t, setSelectedTaskId],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(() => {
|
||||
@@ -1211,170 +1197,76 @@ function AddExtensionContent() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Skill Upload Progress Dialog */}
|
||||
{/* Plugin Upload Preview Dialog */}
|
||||
<Dialog
|
||||
open={skillUploadState !== 'idle'}
|
||||
open={pluginUploadPreviewOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && skillUploadState !== 'uploading') {
|
||||
setSkillUploadState('idle');
|
||||
setSkillUploadError(null);
|
||||
setPluginUploadPreviewOpen(open);
|
||||
if (!open) {
|
||||
setPluginUploadPreviewFile(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
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'}
|
||||
>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-xl max-h-[85vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-start gap-3">
|
||||
<Download className="size-5 shrink-0 mt-0.5" />
|
||||
<span className="break-words">
|
||||
{t('plugins.installProgress.title', {
|
||||
name: skillUploadFileName,
|
||||
})}
|
||||
</span>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileArchive className="size-5" />
|
||||
<span>{t('plugins.localPreview.title')}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{pluginUploadPreviewFile && (
|
||||
<PluginLocalPreviewPanel
|
||||
file={pluginUploadPreviewFile}
|
||||
onCancel={() => {
|
||||
setPluginUploadPreviewOpen(false);
|
||||
setPluginUploadPreviewFile(null);
|
||||
}}
|
||||
onInstallStarted={() => {
|
||||
setPluginUploadPreviewOpen(false);
|
||||
setPluginUploadPreviewFile(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Overall progress bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium shrink-0',
|
||||
skillUploadState === 'done'
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: skillUploadState === 'error'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: 'text-blue-700 dark:text-blue-300',
|
||||
)}
|
||||
>
|
||||
{skillUploadState === 'done'
|
||||
? t('plugins.installProgress.completed')
|
||||
: skillUploadState === 'error'
|
||||
? t('plugins.installProgress.failed')
|
||||
: t('plugins.installProgress.overallProgress')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
skillUploadState === 'done'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: skillUploadState === 'error'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-blue-600 dark:text-blue-400',
|
||||
)}
|
||||
>
|
||||
{skillUploadState === 'done'
|
||||
? '100%'
|
||||
: 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);
|
||||
{/* Skill Upload Preview Dialog */}
|
||||
<Dialog
|
||||
open={skillUploadPreviewOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSkillUploadPreviewOpen(open);
|
||||
if (!open) {
|
||||
setSkillUploadPreviewFile(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-3xl max-h-[85vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileArchive className="size-5" />
|
||||
<span>{t('skills.uploadZip')}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{skillUploadPreviewFile && (
|
||||
<SkillZipPreviewPanel
|
||||
file={skillUploadPreviewFile}
|
||||
onCancel={() => {
|
||||
setSkillUploadPreviewOpen(false);
|
||||
setSkillUploadPreviewFile(null);
|
||||
}}
|
||||
onImported={(skillNames) => {
|
||||
setSkillUploadPreviewOpen(false);
|
||||
setSkillUploadPreviewFile(null);
|
||||
void refreshSkills();
|
||||
const firstSkillName = skillNames[0];
|
||||
if (firstSkillName) {
|
||||
navigate(
|
||||
`/home/skills?id=${encodeURIComponent(firstSkillName)}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={skillUploadState === 'uploading'}
|
||||
>
|
||||
{skillUploadState === 'done' || skillUploadState === 'error'
|
||||
? t('common.close')
|
||||
: t('plugins.installProgress.background')}
|
||||
</Button>
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
})}{' '}
|
||||
•{' '}
|
||||
{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 [searchParams] = useSearchParams();
|
||||
const sidebarData = useSidebarData();
|
||||
const { setPendingPluginInstallAction, setPendingSkillInstallAction } =
|
||||
sidebarData;
|
||||
const { state: sidebarState, isMobile } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
// Track which entity categories have their full list expanded
|
||||
@@ -814,7 +812,7 @@ function NavItems({
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/market');
|
||||
navigate('/home/add-extension');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
@@ -828,8 +826,7 @@ function NavItems({
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPluginInstallAction('local');
|
||||
navigate('/home/extensions');
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
@@ -842,8 +839,7 @@ function NavItems({
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPluginInstallAction('github');
|
||||
navigate('/home/extensions');
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
@@ -869,8 +865,7 @@ function NavItems({
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('create');
|
||||
navigate('/home/skills');
|
||||
navigate('/home/skills?action=create');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
@@ -883,8 +878,7 @@ function NavItems({
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('upload');
|
||||
navigate('/home/skills');
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
@@ -897,8 +891,7 @@ function NavItems({
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('github');
|
||||
navigate('/home/skills');
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
@@ -992,7 +985,7 @@ function NavItems({
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/market');
|
||||
navigate('/home/add-extension');
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
@@ -1002,8 +995,7 @@ function NavItems({
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPluginInstallAction('local');
|
||||
navigate('/home/extensions');
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
@@ -1012,8 +1004,7 @@ function NavItems({
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPluginInstallAction('github');
|
||||
navigate('/home/extensions');
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
@@ -1036,8 +1027,7 @@ function NavItems({
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('create');
|
||||
navigate('/home/skills');
|
||||
navigate('/home/skills?action=create');
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
@@ -1046,8 +1036,7 @@ function NavItems({
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('upload');
|
||||
navigate('/home/skills');
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
@@ -1056,8 +1045,7 @@ function NavItems({
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('github');
|
||||
navigate('/home/skills');
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<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(
|
||||
(childConfig) => childConfig.id === 'add-extension',
|
||||
);
|
||||
|
||||
@@ -30,10 +30,6 @@ export interface SidebarEntityItem {
|
||||
extensionType?: 'plugin' | 'mcp' | 'skill';
|
||||
}
|
||||
|
||||
// Install action types that can be triggered from sidebar
|
||||
export type PluginInstallAction = 'local' | 'github' | null;
|
||||
export type SkillInstallAction = 'create' | 'github' | 'upload' | null;
|
||||
|
||||
// Plugin page registered by a plugin
|
||||
export interface PluginPageItem {
|
||||
id: string; // "author/name/pageId"
|
||||
@@ -65,12 +61,6 @@ export interface SidebarDataContextValue {
|
||||
// Breadcrumb: entity name shown when viewing a detail page
|
||||
detailEntityName: string | null;
|
||||
setDetailEntityName: (name: string | null) => void;
|
||||
// Pending plugin install action triggered from sidebar
|
||||
pendingPluginInstallAction: PluginInstallAction;
|
||||
setPendingPluginInstallAction: (action: PluginInstallAction) => void;
|
||||
// Pending skill install action triggered from sidebar
|
||||
pendingSkillInstallAction: SkillInstallAction;
|
||||
setPendingSkillInstallAction: (action: SkillInstallAction) => void;
|
||||
// Whether the extensions list is grouped by type (shared between page and sidebar)
|
||||
extensionsGroupByType: boolean;
|
||||
setExtensionsGroupByType: (enabled: boolean) => void;
|
||||
@@ -91,10 +81,6 @@ export function SidebarDataProvider({
|
||||
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
|
||||
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
|
||||
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
||||
const [pendingPluginInstallAction, setPendingPluginInstallAction] =
|
||||
useState<PluginInstallAction>(null);
|
||||
const [pendingSkillInstallAction, setPendingSkillInstallAction] =
|
||||
useState<SkillInstallAction>(null);
|
||||
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
|
||||
useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
@@ -320,10 +306,6 @@ export function SidebarDataProvider({
|
||||
refreshAll,
|
||||
detailEntityName,
|
||||
setDetailEntityName,
|
||||
pendingPluginInstallAction,
|
||||
setPendingPluginInstallAction,
|
||||
pendingSkillInstallAction,
|
||||
setPendingSkillInstallAction,
|
||||
extensionsGroupByType,
|
||||
setExtensionsGroupByType,
|
||||
}}
|
||||
|
||||
@@ -222,7 +222,7 @@ export default function FileUploadZone({
|
||||
{t('knowledge.documentsTab.noParserAvailable')}
|
||||
</p>
|
||||
<Link
|
||||
to="/home/market?category=Parser"
|
||||
to="/home/add-extension"
|
||||
className="text-sm text-primary hover:underline mt-1 inline-block"
|
||||
>
|
||||
{t('knowledge.documentsTab.installParserHint')}
|
||||
|
||||
@@ -304,7 +304,7 @@ export default function KBForm({
|
||||
{t('knowledge.noEnginesAvailable')}
|
||||
</p>
|
||||
<Link
|
||||
to="/home/market?category=KnowledgeEngine"
|
||||
to="/home/add-extension"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{t('knowledge.installEngineHint')}
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
// Routes that belong to the "Extensions" section
|
||||
const EXTENSIONS_ROUTES = [
|
||||
'/home/extensions',
|
||||
'/home/market',
|
||||
'/home/add-extension',
|
||||
'/home/mcp',
|
||||
'/home/skills',
|
||||
'/home/plugin-pages',
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
203
web/src/app/home/plugins/components/PluginLocalPreviewPanel.tsx
Normal file
203
web/src/app/home/plugins/components/PluginLocalPreviewPanel.tsx
Normal file
@@ -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 {
|
||||
Download,
|
||||
Package,
|
||||
Settings,
|
||||
Rocket,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
@@ -39,16 +37,6 @@ const STAGES: {
|
||||
icon: Package,
|
||||
i18nKey: 'plugins.installProgress.installingDeps',
|
||||
},
|
||||
{
|
||||
key: InstallStage.INITIALIZING,
|
||||
icon: Settings,
|
||||
i18nKey: 'plugins.installProgress.initializing',
|
||||
},
|
||||
{
|
||||
key: InstallStage.LAUNCHING,
|
||||
icon: Rocket,
|
||||
i18nKey: 'plugins.installProgress.launching',
|
||||
},
|
||||
];
|
||||
|
||||
function getStageIndex(stage: InstallStage): number {
|
||||
|
||||
@@ -89,8 +89,8 @@ function mapActionToStage(action: string): InstallStage {
|
||||
if (lower.includes('dependencies') || lower.includes('requirements'))
|
||||
return InstallStage.INSTALLING_DEPS;
|
||||
if (lower.includes('initializ') || lower.includes('setting'))
|
||||
return InstallStage.INITIALIZING;
|
||||
if (lower.includes('launch')) return InstallStage.LAUNCHING;
|
||||
return InstallStage.INSTALLING_DEPS;
|
||||
if (lower.includes('launch')) return InstallStage.INSTALLING_DEPS;
|
||||
if (lower.includes('installed') || lower.includes('complete'))
|
||||
return InstallStage.DONE;
|
||||
return InstallStage.DOWNLOADING;
|
||||
@@ -104,7 +104,7 @@ function stageToProgress(stage: InstallStage): number {
|
||||
case InstallStage.DOWNLOADING:
|
||||
return 10;
|
||||
case InstallStage.INSTALLING_DEPS:
|
||||
return 40;
|
||||
return 70;
|
||||
case InstallStage.INITIALIZING:
|
||||
return 70;
|
||||
case InstallStage.LAUNCHING:
|
||||
@@ -133,7 +133,11 @@ function extractSourceFromName(
|
||||
* Check if a backend task name is a plugin install task.
|
||||
*/
|
||||
function isPluginInstallTask(name: string): boolean {
|
||||
return name.startsWith('plugin-install-') || name.startsWith('mcp-install-') || name.startsWith('skill-install-');
|
||||
return (
|
||||
name.startsWith('plugin-install-') ||
|
||||
name.startsWith('mcp-install-') ||
|
||||
name.startsWith('skill-install-')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,8 +222,9 @@ export function PluginInstallTaskProvider({
|
||||
|
||||
// Cleanup all intervals on unmount
|
||||
useEffect(() => {
|
||||
const intervals = intervalRefs.current;
|
||||
return () => {
|
||||
intervalRefs.current.forEach((interval) => {
|
||||
intervals.forEach((interval) => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
if (syncIntervalRef.current) clearInterval(syncIntervalRef.current);
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Download,
|
||||
Package,
|
||||
Settings,
|
||||
Rocket,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
@@ -32,8 +30,6 @@ import { cn } from '@/lib/utils';
|
||||
const STAGE_ICONS: Record<string, React.ElementType> = {
|
||||
[InstallStage.DOWNLOADING]: Download,
|
||||
[InstallStage.INSTALLING_DEPS]: Package,
|
||||
[InstallStage.INITIALIZING]: Settings,
|
||||
[InstallStage.LAUNCHING]: Rocket,
|
||||
[InstallStage.DONE]: CheckCircle2,
|
||||
[InstallStage.ERROR]: XCircle,
|
||||
};
|
||||
@@ -99,12 +95,10 @@ function TaskQueueItem({
|
||||
return t('plugins.installProgress.downloading');
|
||||
case InstallStage.INSTALLING_DEPS:
|
||||
return t('plugins.installProgress.installingDeps');
|
||||
case InstallStage.INITIALIZING:
|
||||
return t('plugins.installProgress.initializing');
|
||||
case InstallStage.LAUNCHING:
|
||||
return t('plugins.installProgress.launching');
|
||||
case InstallStage.DONE:
|
||||
return isDone ? getInstallCompleteMessage() : t('plugins.installProgress.completed');
|
||||
return isDone
|
||||
? getInstallCompleteMessage()
|
||||
: t('plugins.installProgress.completed');
|
||||
case InstallStage.ERROR:
|
||||
return t('plugins.installProgress.failed');
|
||||
default:
|
||||
@@ -140,7 +134,10 @@ function TaskQueueItem({
|
||||
<div className="text-sm font-medium truncate">{task.pluginName}</div>
|
||||
<Badge
|
||||
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" />
|
||||
{getTypeLabel()}
|
||||
|
||||
@@ -9,40 +9,21 @@ import { Label } from '@/components/ui/label';
|
||||
import PluginDetailContent from './PluginDetailContent';
|
||||
import styles from './plugins.module.css';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
UploadIcon,
|
||||
Power,
|
||||
Github,
|
||||
ChevronLeft,
|
||||
Code,
|
||||
Copy,
|
||||
Check,
|
||||
Bug,
|
||||
Unlink,
|
||||
} from 'lucide-react';
|
||||
import { Power, Code, Copy, Check, Bug, Unlink } from 'lucide-react';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from '@/components/ui/card';
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import {
|
||||
@@ -50,39 +31,10 @@ import {
|
||||
usePluginInstallTasks,
|
||||
} from '@/app/home/plugins/components/plugin-install-task';
|
||||
|
||||
enum PluginInstallStatus {
|
||||
WAIT_INPUT = 'wait_input',
|
||||
SELECT_RELEASE = 'select_release',
|
||||
SELECT_ASSET = 'select_asset',
|
||||
ASK_CONFIRM = 'ask_confirm',
|
||||
INSTALLING = 'installing',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
interface GithubRelease {
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
published_at: string;
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
source_type?: 'release' | 'tag' | 'branch';
|
||||
archive_url?: string;
|
||||
}
|
||||
|
||||
interface GithubAsset {
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
download_url: string;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
export default function PluginConfigPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
|
||||
// Show plugin detail view when ?id= query param is present
|
||||
if (detailId) {
|
||||
return <PluginDetailContent id={detailId} />;
|
||||
}
|
||||
@@ -92,40 +44,16 @@ export default function PluginConfigPage() {
|
||||
|
||||
function PluginListView() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
refreshPlugins,
|
||||
pendingPluginInstallAction,
|
||||
setPendingPluginInstallAction,
|
||||
extensionsGroupByType: groupByType,
|
||||
setExtensionsGroupByType: setGroupByType,
|
||||
} = useSidebarData();
|
||||
const {
|
||||
addTask,
|
||||
setSelectedTaskId,
|
||||
registerOnTaskComplete,
|
||||
unregisterOnTaskComplete,
|
||||
} = usePluginInstallTasks();
|
||||
const [showGithubInstall, setShowGithubInstall] = useState(false);
|
||||
const [installSource, setInstallSource] = useState<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 { registerOnTaskComplete, unregisterOnTaskComplete } =
|
||||
usePluginInstallTasks();
|
||||
const [pluginSystemStatus, setPluginSystemStatus] =
|
||||
useState<ApiRespPluginSystemStatus | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(true);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [debugInfo, setDebugInfo] = useState<{
|
||||
debug_url: string;
|
||||
plugin_debug_key: string;
|
||||
@@ -134,8 +62,7 @@ function PluginListView() {
|
||||
const [copiedDebugUrl, setCopiedDebugUrl] = useState(false);
|
||||
const [copiedDebugKey, setCopiedDebugKey] = useState(false);
|
||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||
const groupByType = useSidebarData().extensionsGroupByType;
|
||||
const setGroupByType = useSidebarData().setExtensionsGroupByType;
|
||||
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPluginSystemStatus = async () => {
|
||||
@@ -151,19 +78,9 @@ function PluginListView() {
|
||||
}
|
||||
};
|
||||
|
||||
fetchPluginSystemStatus();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
void fetchPluginSystemStatus();
|
||||
}, [t]);
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Register task completion callback for toast and plugin list refresh
|
||||
useEffect(() => {
|
||||
const onComplete = (_taskId: number, success: boolean) => {
|
||||
if (success) {
|
||||
@@ -178,285 +95,6 @@ function PluginListView() {
|
||||
};
|
||||
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
|
||||
|
||||
const pluginInstalledRef = useRef<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 () => {
|
||||
try {
|
||||
const info = await httpClient.getPluginDebugInfo();
|
||||
@@ -520,23 +158,7 @@ function PluginListView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
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={`${styles.pageContainer} h-full flex flex-col`}>
|
||||
<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">
|
||||
<Tabs
|
||||
@@ -588,7 +210,6 @@ function PluginListView() {
|
||||
align="end"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* Header with icon and title */}
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Bug className="w-4 h-4" />
|
||||
<h4 className="font-semibold text-sm">
|
||||
@@ -596,7 +217,6 @@ function PluginListView() {
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* Debug URL row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
|
||||
{t('plugins.debugUrl')}:
|
||||
@@ -622,7 +242,6 @@ function PluginListView() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Debug Key row */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
|
||||
@@ -666,251 +285,6 @@ function PluginListView() {
|
||||
</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,
|
||||
})}{' '}
|
||||
•{' '}
|
||||
{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">
|
||||
<PluginInstalledComponent
|
||||
ref={pluginInstalledRef}
|
||||
@@ -918,17 +292,6 @@ function PluginListView() {
|
||||
groupByType={groupByType}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
277
web/src/app/home/skills/components/SkillZipPreviewPanel.tsx
Normal file
277
web/src/app/home/skills/components/SkillZipPreviewPanel.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +1,73 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import SkillDetailContent from '@/app/home/skills/SkillDetailContent';
|
||||
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
||||
import SkillGithubImportPanel from '@/app/home/skills/components/SkillGithubImportPanel';
|
||||
import {
|
||||
useSidebarData,
|
||||
type SkillInstallAction,
|
||||
} from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
const actionParam = searchParams.get('action') as SkillInstallAction | null;
|
||||
const {
|
||||
refreshSkills,
|
||||
pendingSkillInstallAction,
|
||||
setPendingSkillInstallAction,
|
||||
} = useSidebarData();
|
||||
const actionParam = searchParams.get('action');
|
||||
const { refreshSkills } = useSidebarData();
|
||||
|
||||
const [activeView, setActiveView] = useState<SkillInstallAction>(null);
|
||||
const isCreateView = actionParam === 'create';
|
||||
|
||||
useEffect(() => {
|
||||
if (actionParam && ['create', 'github', 'upload'].includes(actionParam)) {
|
||||
setActiveView(actionParam);
|
||||
return;
|
||||
if (!detailId && !isCreateView) {
|
||||
navigate('/home/add-extension', { replace: true });
|
||||
}
|
||||
if (!pendingSkillInstallAction) return;
|
||||
const action = pendingSkillInstallAction;
|
||||
setPendingSkillInstallAction(null);
|
||||
setActiveView(action);
|
||||
}, [actionParam, pendingSkillInstallAction, setPendingSkillInstallAction]);
|
||||
}, [detailId, isCreateView, navigate]);
|
||||
|
||||
if (detailId) {
|
||||
return <SkillDetailContent id={detailId} />;
|
||||
}
|
||||
|
||||
function handleImportedSkills(_skillNames: string[]) {
|
||||
function handleCreatedSkill(skillName: string) {
|
||||
void refreshSkills();
|
||||
setActiveView(null);
|
||||
navigate('/home/add-extension');
|
||||
navigate(`/home/skills?id=${encodeURIComponent(skillName)}`, {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setActiveView(null);
|
||||
navigate('/home/add-extension');
|
||||
}
|
||||
|
||||
if (activeView === 'create') {
|
||||
return (
|
||||
<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 (!isCreateView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeView === 'github') {
|
||||
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.importFromGithub')}
|
||||
</h1>
|
||||
return (
|
||||
<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>
|
||||
</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="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 type="submit" form="skill-form">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
navigate('/home/add-extension');
|
||||
return null;
|
||||
<div className="min-h-0 flex-1">
|
||||
<SkillForm
|
||||
key="new-skill"
|
||||
initSkillName={undefined}
|
||||
layout="split"
|
||||
onNewSkillCreated={handleCreatedSkill}
|
||||
onSkillUpdated={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
ApiRespSkill,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { Plugin } from '@/app/infra/entities/plugin';
|
||||
import type { I18nObject } from '@/app/infra/entities/common';
|
||||
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
|
||||
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||
|
||||
@@ -710,6 +711,28 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.postFile('/api/v1/plugins/install/local', formData);
|
||||
}
|
||||
|
||||
public previewPluginInstallFromLocal(file: File): Promise<{
|
||||
filename: string;
|
||||
size: number;
|
||||
manifest: Record<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 ============
|
||||
public installSkillFromGithub(
|
||||
assetUrl: string,
|
||||
|
||||
@@ -520,6 +520,21 @@ const enUS = {
|
||||
uploadLocal: 'Upload Local',
|
||||
debugging: 'Debugging',
|
||||
uploadLocalPlugin: 'Upload Local Plugin',
|
||||
localPreview: {
|
||||
title: 'Preview Local Plugin Package',
|
||||
unpacking: 'Unpacking package preview...',
|
||||
unpackComplete: 'Package preview ready',
|
||||
failed: 'Failed to preview package',
|
||||
pluginInfo: 'Plugin Info',
|
||||
packageInfo: 'Package Info',
|
||||
name: 'Name',
|
||||
author: 'Author',
|
||||
version: 'Version',
|
||||
fileCount: 'Files',
|
||||
dependencies: 'Dependencies',
|
||||
components: 'Components',
|
||||
ready: 'The plugin package is unpacked. Confirm to start installation.',
|
||||
},
|
||||
uploadPluginOnly: 'Only .lbpkg files are supported',
|
||||
dragToUpload: 'Drag plugin file here to upload',
|
||||
unsupportedFileType:
|
||||
@@ -1370,7 +1385,7 @@ const enUS = {
|
||||
scanError: 'Failed to scan directory: ',
|
||||
noSkills: 'No skills configured',
|
||||
preview: 'Preview',
|
||||
previewInstructions: 'Preview Instructions',
|
||||
previewInstructions: 'SKILL.md Content Preview',
|
||||
instructionsPlaceholder: 'Enter skill instructions in Markdown format...',
|
||||
descriptionPlaceholder:
|
||||
'A brief description of what this skill does (shown to the LLM)',
|
||||
|
||||
@@ -532,6 +532,22 @@ const esES = {
|
||||
uploadLocal: 'Subir local',
|
||||
debugging: 'Depuración',
|
||||
uploadLocalPlugin: 'Subir plugin local',
|
||||
localPreview: {
|
||||
title: 'Previsualizar paquete de plugin local',
|
||||
unpacking: 'Descomprimiendo vista previa del paquete...',
|
||||
unpackComplete: 'Vista previa del paquete lista',
|
||||
failed: 'No se pudo previsualizar el paquete',
|
||||
pluginInfo: 'Información del plugin',
|
||||
packageInfo: 'Información del paquete',
|
||||
name: 'Nombre',
|
||||
author: 'Autor',
|
||||
version: 'Versión',
|
||||
fileCount: 'Archivos',
|
||||
dependencies: 'Dependencias',
|
||||
components: 'Componentes',
|
||||
ready:
|
||||
'El paquete del plugin está descomprimido. Confirma para iniciar la instalación.',
|
||||
},
|
||||
dragToUpload: 'Arrastra el archivo del plugin aquí para subirlo',
|
||||
unsupportedFileType:
|
||||
'Tipo de archivo no soportado, solo se admiten archivos .lbpkg y .zip',
|
||||
@@ -1486,7 +1502,7 @@ const esES = {
|
||||
scanError: 'Error al escanear el directorio: ',
|
||||
noSkills: 'No hay skills configuradas',
|
||||
preview: 'Vista previa',
|
||||
previewInstructions: 'Vista previa de instrucciones',
|
||||
previewInstructions: 'Vista previa del contenido de SKILL.md',
|
||||
instructionsPlaceholder:
|
||||
'Introduce las instrucciones de la skill en formato Markdown...',
|
||||
descriptionPlaceholder:
|
||||
|
||||
@@ -524,6 +524,22 @@ const jaJP = {
|
||||
uploadLocal: 'ローカルアップロード',
|
||||
debugging: 'デバッグ中',
|
||||
uploadLocalPlugin: 'ローカルプラグインのアップロード',
|
||||
localPreview: {
|
||||
title: 'ローカルプラグインパッケージをプレビュー',
|
||||
unpacking: 'パッケージを展開してプレビュー中...',
|
||||
unpackComplete: 'パッケージのプレビュー準備完了',
|
||||
failed: 'パッケージのプレビューに失敗しました',
|
||||
pluginInfo: 'プラグイン情報',
|
||||
packageInfo: 'パッケージ情報',
|
||||
name: '名前',
|
||||
author: '作者',
|
||||
version: 'バージョン',
|
||||
fileCount: 'ファイル数',
|
||||
dependencies: '依存関係',
|
||||
components: 'コンポーネント',
|
||||
ready:
|
||||
'プラグインパッケージを展開しました。確認するとインストールを開始します。',
|
||||
},
|
||||
dragToUpload: 'ファイルをここにドラッグしてアップロード',
|
||||
unsupportedFileType:
|
||||
'サポートされていないファイルタイプです。.lbpkg と .zip ファイルのみサポートされています',
|
||||
@@ -1476,7 +1492,7 @@ const jaJP = {
|
||||
scanError: 'ディレクトリのスキャンに失敗しました: ',
|
||||
noSkills: '設定済みのスキルはありません',
|
||||
preview: 'プレビュー',
|
||||
previewInstructions: '指示内容のプレビュー',
|
||||
previewInstructions: 'SKILL.md 内容プレビュー',
|
||||
instructionsPlaceholder: 'Markdown 形式でスキルの指示を入力...',
|
||||
descriptionPlaceholder: 'このスキルの概要(LLM に表示されます)',
|
||||
packageRoot: 'パッケージディレクトリ',
|
||||
|
||||
@@ -530,6 +530,21 @@ const ruRU = {
|
||||
uploadLocal: 'Загрузить локально',
|
||||
debugging: 'Отладка',
|
||||
uploadLocalPlugin: 'Загрузить локальный плагин',
|
||||
localPreview: {
|
||||
title: 'Предпросмотр локального пакета плагина',
|
||||
unpacking: 'Распаковка пакета для предпросмотра...',
|
||||
unpackComplete: 'Предпросмотр пакета готов',
|
||||
failed: 'Не удалось выполнить предпросмотр пакета',
|
||||
pluginInfo: 'Информация о плагине',
|
||||
packageInfo: 'Информация о пакете',
|
||||
name: 'Название',
|
||||
author: 'Автор',
|
||||
version: 'Версия',
|
||||
fileCount: 'Файлы',
|
||||
dependencies: 'Зависимости',
|
||||
components: 'Компоненты',
|
||||
ready: 'Пакет плагина распакован. Подтвердите, чтобы начать установку.',
|
||||
},
|
||||
dragToUpload: 'Перетащите файл плагина сюда для загрузки',
|
||||
unsupportedFileType:
|
||||
'Неподдерживаемый тип файла, поддерживаются только файлы .lbpkg и .zip',
|
||||
@@ -1459,7 +1474,7 @@ const ruRU = {
|
||||
scanError: 'Не удалось просканировать каталог: ',
|
||||
noSkills: 'Навыки не настроены',
|
||||
preview: 'Предпросмотр',
|
||||
previewInstructions: 'Предпросмотр инструкций',
|
||||
previewInstructions: 'Предпросмотр содержимого SKILL.md',
|
||||
instructionsPlaceholder: 'Введите инструкции навыка в формате Markdown...',
|
||||
descriptionPlaceholder:
|
||||
'Краткое описание того, что делает этот навык (показывается LLM)',
|
||||
|
||||
@@ -514,6 +514,21 @@ const thTH = {
|
||||
uploadLocal: 'อัปโหลดจากเครื่อง',
|
||||
debugging: 'ดีบัก',
|
||||
uploadLocalPlugin: 'อัปโหลดปลั๊กอินจากเครื่อง',
|
||||
localPreview: {
|
||||
title: 'ดูตัวอย่างแพ็กเกจปลั๊กอินในเครื่อง',
|
||||
unpacking: 'กำลังแตกแพ็กเกจเพื่อดูตัวอย่าง...',
|
||||
unpackComplete: 'พร้อมดูตัวอย่างแพ็กเกจแล้ว',
|
||||
failed: 'ดูตัวอย่างแพ็กเกจไม่สำเร็จ',
|
||||
pluginInfo: 'ข้อมูลปลั๊กอิน',
|
||||
packageInfo: 'ข้อมูลแพ็กเกจ',
|
||||
name: 'ชื่อ',
|
||||
author: 'ผู้เขียน',
|
||||
version: 'เวอร์ชัน',
|
||||
fileCount: 'จำนวนไฟล์',
|
||||
dependencies: 'แพ็กเกจที่ต้องใช้',
|
||||
components: 'คอมโพเนนต์',
|
||||
ready: 'แตกแพ็กเกจปลั๊กอินแล้ว ยืนยันเพื่อเริ่มติดตั้ง',
|
||||
},
|
||||
dragToUpload: 'ลากไฟล์ปลั๊กอินมาวางที่นี่เพื่ออัปโหลด',
|
||||
unsupportedFileType: 'ประเภทไฟล์ไม่รองรับ รองรับเฉพาะไฟล์ .lbpkg และ .zip',
|
||||
uploadingPlugin: 'กำลังอัปโหลดปลั๊กอิน...',
|
||||
@@ -1425,7 +1440,7 @@ const thTH = {
|
||||
scanError: 'สแกนไดเรกทอรีไม่สำเร็จ: ',
|
||||
noSkills: 'ยังไม่มีสกิลที่ตั้งค่าไว้',
|
||||
preview: 'ดูตัวอย่าง',
|
||||
previewInstructions: 'ดูตัวอย่างคำสั่ง',
|
||||
previewInstructions: 'ตัวอย่างเนื้อหา SKILL.md',
|
||||
instructionsPlaceholder: 'ป้อนคำสั่งของสกิลในรูปแบบ Markdown...',
|
||||
descriptionPlaceholder:
|
||||
'คำอธิบายสั้น ๆ ว่าสกิลนี้ทำอะไร (แสดงให้ LLM เห็น)',
|
||||
|
||||
@@ -525,6 +525,21 @@ const viVN = {
|
||||
uploadLocal: 'Tải lên cục bộ',
|
||||
debugging: 'Gỡ lỗi',
|
||||
uploadLocalPlugin: 'Tải lên Plugin cục bộ',
|
||||
localPreview: {
|
||||
title: 'Xem trước gói plugin cục bộ',
|
||||
unpacking: 'Đang giải nén để xem trước gói...',
|
||||
unpackComplete: 'Bản xem trước gói đã sẵn sàng',
|
||||
failed: 'Không thể xem trước gói',
|
||||
pluginInfo: 'Thông tin plugin',
|
||||
packageInfo: 'Thông tin gói',
|
||||
name: 'Tên',
|
||||
author: 'Tác giả',
|
||||
version: 'Phiên bản',
|
||||
fileCount: 'Tệp',
|
||||
dependencies: 'Phụ thuộc',
|
||||
components: 'Thành phần',
|
||||
ready: 'Gói plugin đã được giải nén. Xác nhận để bắt đầu cài đặt.',
|
||||
},
|
||||
dragToUpload: 'Kéo tệp plugin vào đây để tải lên',
|
||||
unsupportedFileType:
|
||||
'Loại tệp không được hỗ trợ, chỉ hỗ trợ tệp .lbpkg và .zip',
|
||||
@@ -1451,7 +1466,7 @@ const viVN = {
|
||||
scanError: 'Quét thư mục thất bại: ',
|
||||
noSkills: 'Chưa cấu hình kỹ năng nào',
|
||||
preview: 'Xem trước',
|
||||
previewInstructions: 'Xem trước hướng dẫn',
|
||||
previewInstructions: 'Xem trước nội dung SKILL.md',
|
||||
instructionsPlaceholder:
|
||||
'Nhập hướng dẫn kỹ năng theo định dạng Markdown...',
|
||||
descriptionPlaceholder:
|
||||
|
||||
@@ -497,6 +497,21 @@ const zhHans = {
|
||||
uploadLocal: '本地上传',
|
||||
debugging: '调试中',
|
||||
uploadLocalPlugin: '上传本地插件',
|
||||
localPreview: {
|
||||
title: '预览本地插件包',
|
||||
unpacking: '正在解包预览...',
|
||||
unpackComplete: '解包预览完成',
|
||||
failed: '解包预览失败',
|
||||
pluginInfo: '插件信息',
|
||||
packageInfo: '包信息',
|
||||
name: '名称',
|
||||
author: '作者',
|
||||
version: '版本',
|
||||
fileCount: '文件数',
|
||||
dependencies: '依赖',
|
||||
components: '组件',
|
||||
ready: '插件包已解包,确认后开始安装。',
|
||||
},
|
||||
uploadPluginOnly: '仅支持 .lbpkg 文件',
|
||||
dragToUpload: '拖拽文件到此处上传',
|
||||
unsupportedFileType: '不支持的文件类型,仅支持 .lbpkg 文件',
|
||||
@@ -1313,7 +1328,7 @@ const zhHans = {
|
||||
scanError: '扫描目录失败:',
|
||||
noSkills: '暂未配置任何技能',
|
||||
preview: '预览',
|
||||
previewInstructions: '预览指令',
|
||||
previewInstructions: 'SKILL.md 内容预览',
|
||||
instructionsPlaceholder: '使用 Markdown 格式输入技能指令...',
|
||||
descriptionPlaceholder: '简短描述此技能的功能(会展示给 LLM)',
|
||||
packageRoot: '技能目录',
|
||||
|
||||
@@ -498,6 +498,21 @@ const zhHant = {
|
||||
uploadLocal: '本地上傳',
|
||||
debugging: '調試中',
|
||||
uploadLocalPlugin: '上傳本地插件',
|
||||
localPreview: {
|
||||
title: '預覽本地外掛包',
|
||||
unpacking: '正在解包預覽...',
|
||||
unpackComplete: '解包預覽完成',
|
||||
failed: '解包預覽失敗',
|
||||
pluginInfo: '外掛資訊',
|
||||
packageInfo: '包資訊',
|
||||
name: '名稱',
|
||||
author: '作者',
|
||||
version: '版本',
|
||||
fileCount: '檔案數',
|
||||
dependencies: '依賴',
|
||||
components: '元件',
|
||||
ready: '外掛包已解包,確認後開始安裝。',
|
||||
},
|
||||
dragToUpload: '拖拽文件到此處上傳',
|
||||
unsupportedFileType: '不支持的文件類型,僅支持 .lbpkg 和 .zip 文件',
|
||||
uploadingPlugin: '正在上傳插件...',
|
||||
@@ -1405,7 +1420,7 @@ const zhHant = {
|
||||
scanError: '掃描目錄失敗:',
|
||||
noSkills: '暫未配置任何技能',
|
||||
preview: '預覽',
|
||||
previewInstructions: '預覽指令',
|
||||
previewInstructions: 'SKILL.md 內容預覽',
|
||||
instructionsPlaceholder: '使用 Markdown 格式輸入技能指令...',
|
||||
descriptionPlaceholder: '簡短描述此技能的功能',
|
||||
packageRoot: '技能目錄',
|
||||
|
||||
@@ -19,8 +19,6 @@ import BotsPage from '@/app/home/bots/page';
|
||||
import PipelinesPage from '@/app/home/pipelines/page';
|
||||
import PluginsPage from '@/app/home/plugins/page';
|
||||
import AddExtensionPage from '@/app/home/add-extension/page';
|
||||
import AddPluginPage from '@/app/home/add-plugin/page';
|
||||
import MarketPage from '@/app/home/market/page';
|
||||
import MCPPage from '@/app/home/mcp/page';
|
||||
import KnowledgePage from '@/app/home/knowledge/page';
|
||||
import SkillsPage from '@/app/home/skills/page';
|
||||
@@ -134,26 +132,6 @@ export const router = createBrowserRouter([
|
||||
</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',
|
||||
element: (
|
||||
|
||||
Reference in New Issue
Block a user