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