mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Optimize the plugin system (#2090)
* Optimize the plugin system * feat: enhance plugin installation process and improve task management * fix: linter err --------- Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
@@ -265,6 +265,8 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, -1, 'Missing asset_url parameter')
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
ctx.metadata['plugin_name'] = f'{owner}/{repo}'
|
||||
ctx.metadata['install_source'] = 'github'
|
||||
install_info = {
|
||||
'asset_url': asset_url,
|
||||
'owner': owner,
|
||||
@@ -295,12 +297,17 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
|
||||
data = await quart.request.json
|
||||
|
||||
plugin_author = data.get('plugin_author', '')
|
||||
plugin_name = data.get('plugin_name', '')
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
ctx.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||
ctx.metadata['install_source'] = 'marketplace'
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
|
||||
kind='plugin-operation',
|
||||
name='plugin-install-marketplace',
|
||||
label=f'Installing plugin from marketplace ...{data}',
|
||||
label=f'Installing plugin from marketplace {plugin_author}/{plugin_name}',
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
@@ -323,11 +330,13 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
}
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
ctx.metadata['plugin_name'] = file.filename or 'local plugin'
|
||||
ctx.metadata['install_source'] = 'local'
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
|
||||
kind='plugin-operation',
|
||||
name='plugin-install-local',
|
||||
label=f'Installing plugin from local ...{file.filename}',
|
||||
label=f'Installing plugin from local {file.filename}',
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
|
||||
@@ -118,11 +118,14 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
@self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
task_type = quart.request.args.get('type')
|
||||
task_kind = quart.request.args.get('kind')
|
||||
|
||||
if task_type == '':
|
||||
task_type = None
|
||||
if task_kind == '':
|
||||
task_kind = None
|
||||
|
||||
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type))
|
||||
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type, task_kind))
|
||||
|
||||
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(task_id: str) -> str:
|
||||
|
||||
@@ -17,9 +17,13 @@ class TaskContext:
|
||||
log: str
|
||||
"""Log"""
|
||||
|
||||
metadata: dict
|
||||
"""Structured metadata for progress reporting"""
|
||||
|
||||
def __init__(self):
|
||||
self.current_action = 'default'
|
||||
self.log = ''
|
||||
self.metadata = {}
|
||||
|
||||
def _log(self, msg: str):
|
||||
self.log += msg + '\n'
|
||||
@@ -38,7 +42,7 @@ class TaskContext:
|
||||
self._log(f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {self.current_action} | {msg}')
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {'current_action': self.current_action, 'log': self.log}
|
||||
return {'current_action': self.current_action, 'log': self.log, 'metadata': self.metadata}
|
||||
|
||||
@staticmethod
|
||||
def new() -> TaskContext:
|
||||
@@ -211,9 +215,14 @@ class AsyncTaskManager:
|
||||
def get_tasks_dict(
|
||||
self,
|
||||
type: str = None,
|
||||
kind: str = None,
|
||||
) -> dict:
|
||||
return {
|
||||
'tasks': [t.to_dict() for t in self.tasks if type is None or t.task_type == type],
|
||||
'tasks': [
|
||||
t.to_dict()
|
||||
for t in self.tasks
|
||||
if (type is None or t.task_type == type) and (kind is None or t.kind == kind)
|
||||
],
|
||||
'id_index': TaskWrapper._id_index,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import time
|
||||
import zipfile
|
||||
from typing import Any
|
||||
import typing
|
||||
import os
|
||||
@@ -192,6 +195,30 @@ class PluginRuntimeConnector:
|
||||
|
||||
return await self.handler.ping()
|
||||
|
||||
def _extract_deps_metadata(
|
||||
self,
|
||||
file_bytes: bytes,
|
||||
task_context: taskmgr.TaskContext | None,
|
||||
):
|
||||
"""Extract dependency count from requirements.txt inside plugin zip."""
|
||||
if task_context is None:
|
||||
return
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith('requirements.txt'):
|
||||
content = zf.read(name).decode('utf-8', errors='ignore')
|
||||
deps = [
|
||||
line.strip()
|
||||
for line in content.splitlines()
|
||||
if line.strip() and not line.strip().startswith('#')
|
||||
]
|
||||
task_context.metadata['deps_total'] = len(deps)
|
||||
task_context.metadata['deps_list'] = deps
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def install_plugin(
|
||||
self,
|
||||
install_source: PluginInstallSource,
|
||||
@@ -201,23 +228,44 @@ class PluginRuntimeConnector:
|
||||
if install_source == PluginInstallSource.LOCAL:
|
||||
# transfer file before install
|
||||
file_bytes = install_info['plugin_file']
|
||||
self._extract_deps_metadata(file_bytes, task_context)
|
||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||
install_info['plugin_file_key'] = file_key
|
||||
del install_info['plugin_file']
|
||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||
elif install_source == PluginInstallSource.GITHUB:
|
||||
# download and transfer file
|
||||
# download and transfer file with streaming progress
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
follow_redirects=True,
|
||||
timeout=20,
|
||||
timeout=60,
|
||||
) as client:
|
||||
response = await client.get(
|
||||
install_info['asset_url'],
|
||||
)
|
||||
response.raise_for_status()
|
||||
file_bytes = response.content
|
||||
async with client.stream('GET', install_info['asset_url']) as response:
|
||||
response.raise_for_status()
|
||||
total = int(response.headers.get('content-length', 0))
|
||||
downloaded = 0
|
||||
chunks: list[bytes] = []
|
||||
start_time = time.time()
|
||||
|
||||
if task_context is not None:
|
||||
task_context.set_current_action('downloading plugin package')
|
||||
task_context.metadata['download_total'] = total
|
||||
task_context.metadata['download_current'] = 0
|
||||
task_context.metadata['download_speed'] = 0
|
||||
|
||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||
chunks.append(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
if task_context is not None:
|
||||
elapsed = time.time() - start_time
|
||||
task_context.metadata['download_current'] = downloaded
|
||||
task_context.metadata['download_total'] = total
|
||||
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
|
||||
|
||||
file_bytes = b''.join(chunks)
|
||||
self._extract_deps_metadata(file_bytes, task_context)
|
||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||
install_info['plugin_file_key'] = file_key
|
||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||
@@ -236,6 +284,11 @@ class PluginRuntimeConnector:
|
||||
if task_context is not None:
|
||||
task_context.trace(trace)
|
||||
|
||||
# Forward structured metadata from runtime
|
||||
metadata = ret.get('metadata', None)
|
||||
if metadata is not None and task_context is not None:
|
||||
task_context.metadata.update(metadata)
|
||||
|
||||
async def upgrade_plugin(
|
||||
self,
|
||||
plugin_author: str,
|
||||
|
||||
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:5300
|
||||
NEXT_PUBLIC_API_BASE_URL=http://192.168.1.97:5300
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@radix-ui/react-hover-card": "^1.1.13",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.4",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
|
||||
4394
web/pnpm-lock.yaml
generated
4394
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,10 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import {
|
||||
PluginInstallTaskProvider,
|
||||
PluginInstallProgressDialog,
|
||||
} from '@/app/home/plugins/components/plugin-install-task';
|
||||
|
||||
// Routes that belong to the "Extensions" section
|
||||
const EXTENSIONS_ROUTES = ['/home/plugins', '/home/market', '/home/mcp'];
|
||||
@@ -82,7 +86,10 @@ export default function HomeLayout({
|
||||
|
||||
return (
|
||||
<SidebarDataProvider>
|
||||
<HomeLayoutInner>{children}</HomeLayoutInner>
|
||||
<PluginInstallTaskProvider>
|
||||
<HomeLayoutInner>{children}</HomeLayoutInner>
|
||||
<PluginInstallProgressDialog />
|
||||
</PluginInstallTaskProvider>
|
||||
</SidebarDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Download } from 'lucide-react';
|
||||
import React, { useState, useCallback } from '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',
|
||||
@@ -41,6 +42,12 @@ export default function MarketplacePage() {
|
||||
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 [pluginInstallStatus, setPluginInstallStatus] =
|
||||
@@ -69,28 +76,19 @@ function MarketplaceContent() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function watchTask(taskId: number) {
|
||||
let alreadySuccess = false;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
httpClient.getAsyncTask(taskId).then((resp) => {
|
||||
if (resp.runtime.done) {
|
||||
clearInterval(interval);
|
||||
if (resp.runtime.exception) {
|
||||
setInstallError(resp.runtime.exception);
|
||||
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
||||
} else {
|
||||
if (!alreadySuccess) {
|
||||
toast.success(t('plugins.installSuccess'));
|
||||
alreadySuccess = true;
|
||||
}
|
||||
setModalOpen(false);
|
||||
refreshPlugins();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
// 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) => {
|
||||
@@ -109,6 +107,7 @@ function MarketplaceContent() {
|
||||
|
||||
function handleModalConfirm() {
|
||||
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
||||
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
|
||||
httpClient
|
||||
.installPluginFromMarketplace(
|
||||
installInfo.plugin_author,
|
||||
@@ -117,7 +116,14 @@ function MarketplaceContent() {
|
||||
)
|
||||
.then((resp) => {
|
||||
const taskId = resp.task_id;
|
||||
watchTask(taskId);
|
||||
const taskKey = `marketplace-${taskId}`;
|
||||
addTask({
|
||||
taskId,
|
||||
pluginName: pluginDisplayName,
|
||||
source: 'marketplace',
|
||||
});
|
||||
setSelectedTaskId(taskKey);
|
||||
setModalOpen(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setInstallError(err.msg);
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Download,
|
||||
Package,
|
||||
Settings,
|
||||
Rocket,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
usePluginInstallTasks,
|
||||
InstallStage,
|
||||
PluginInstallTask,
|
||||
} from './PluginInstallTaskContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STAGES: {
|
||||
key: InstallStage;
|
||||
icon: React.ElementType;
|
||||
i18nKey: string;
|
||||
}[] = [
|
||||
{
|
||||
key: InstallStage.DOWNLOADING,
|
||||
icon: Download,
|
||||
i18nKey: 'plugins.installProgress.downloading',
|
||||
},
|
||||
{
|
||||
key: InstallStage.INSTALLING_DEPS,
|
||||
icon: Package,
|
||||
i18nKey: 'plugins.installProgress.installingDeps',
|
||||
},
|
||||
{
|
||||
key: InstallStage.INITIALIZING,
|
||||
icon: Settings,
|
||||
i18nKey: 'plugins.installProgress.initializing',
|
||||
},
|
||||
{
|
||||
key: InstallStage.LAUNCHING,
|
||||
icon: Rocket,
|
||||
i18nKey: 'plugins.installProgress.launching',
|
||||
},
|
||||
];
|
||||
|
||||
function getStageIndex(stage: InstallStage): number {
|
||||
const idx = STAGES.findIndex((s) => s.key === stage);
|
||||
return idx >= 0 ? idx : -1;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* A single stage row — used in both active (single) and completed (all) views.
|
||||
*/
|
||||
function StageRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
isActive,
|
||||
isCompleted,
|
||||
isError,
|
||||
detail,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
isCompleted: boolean;
|
||||
isError: boolean;
|
||||
detail?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-300',
|
||||
isActive &&
|
||||
!isError &&
|
||||
'bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800',
|
||||
isCompleted &&
|
||||
'bg-green-50/50 dark:bg-green-950/15 border border-green-100 dark:border-green-900/50',
|
||||
isError &&
|
||||
isActive &&
|
||||
'bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900',
|
||||
)}
|
||||
>
|
||||
{/* Left: status indicator */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-7 h-7 rounded-full shrink-0',
|
||||
isCompleted &&
|
||||
'bg-green-100 dark:bg-green-900/40 text-green-600 dark:text-green-400',
|
||||
isActive &&
|
||||
!isError &&
|
||||
!isCompleted &&
|
||||
'bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400',
|
||||
isError &&
|
||||
isActive &&
|
||||
'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
) : isError && isActive ? (
|
||||
<XCircle className="w-4 h-4" />
|
||||
) : isActive ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Icon className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Middle: label + detail */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
isActive &&
|
||||
!isError &&
|
||||
!isCompleted &&
|
||||
'text-blue-700 dark:text-blue-300',
|
||||
isCompleted && 'text-green-600 dark:text-green-400',
|
||||
isError && isActive && 'text-red-600 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/* Small icon after text */}
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 shrink-0',
|
||||
isActive &&
|
||||
!isError &&
|
||||
!isCompleted &&
|
||||
'text-blue-400 dark:text-blue-500',
|
||||
isCompleted && 'text-green-400 dark:text-green-500',
|
||||
isError && isActive && 'text-red-400 dark:text-red-500',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{detail && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs mt-0.5',
|
||||
isCompleted
|
||||
? 'text-green-600/70 dark:text-green-400/70'
|
||||
: 'text-blue-600/70 dark:text-blue-400/70',
|
||||
)}
|
||||
>
|
||||
{detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSec: number): string {
|
||||
if (bytesPerSec === 0) return '0 B/s';
|
||||
const k = 1024;
|
||||
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||
const i = Math.floor(Math.log(bytesPerSec) / Math.log(k));
|
||||
return (bytesPerSec / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const currentStageIndex = getStageIndex(task.stage);
|
||||
const isDone = task.stage === InstallStage.DONE;
|
||||
const isError = task.stage === InstallStage.ERROR;
|
||||
|
||||
/** Build detail node for a stage */
|
||||
const getStageDetail = (
|
||||
stageKey: InstallStage,
|
||||
isCompletedView: boolean,
|
||||
): React.ReactNode | undefined => {
|
||||
if (stageKey === InstallStage.DOWNLOADING) {
|
||||
// Show download progress: current / total + speed
|
||||
const dlTotal = task.downloadTotal || task.fileSize;
|
||||
const dlCurrent = task.downloadCurrent;
|
||||
const dlSpeed = task.downloadSpeed;
|
||||
|
||||
if (isCompletedView && dlTotal) {
|
||||
// Done view: just show total size
|
||||
return t('plugins.installProgress.downloadSize', {
|
||||
size: formatFileSize(dlTotal),
|
||||
});
|
||||
}
|
||||
|
||||
if (dlTotal && dlCurrent != null) {
|
||||
const parts: string[] = [];
|
||||
parts.push(`${formatFileSize(dlCurrent)} / ${formatFileSize(dlTotal)}`);
|
||||
if (dlSpeed && dlSpeed > 0) {
|
||||
parts.push(formatSpeed(dlSpeed));
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
if (dlTotal) {
|
||||
return t('plugins.installProgress.downloadSize', {
|
||||
size: formatFileSize(dlTotal),
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (stageKey === InstallStage.INSTALLING_DEPS) {
|
||||
const total = task.depsTotal;
|
||||
const installed = task.depsInstalled;
|
||||
const remaining = task.depsRemaining;
|
||||
const currentDep = task.currentDep;
|
||||
const dlSize = task.depsDownloadedSize;
|
||||
const speed = task.depsSpeed;
|
||||
|
||||
if (isCompletedView && total != null) {
|
||||
const parts: string[] = [];
|
||||
parts.push(t('plugins.installProgress.depsInfo', { count: total }));
|
||||
if (dlSize && dlSize > 0) {
|
||||
parts.push(formatFileSize(dlSize));
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
if (total != null && installed != null) {
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
t('plugins.installProgress.depsProgress', {
|
||||
installed,
|
||||
total,
|
||||
remaining: remaining ?? total - installed,
|
||||
}),
|
||||
);
|
||||
if (dlSize && dlSize > 0) {
|
||||
parts.push(formatFileSize(dlSize));
|
||||
}
|
||||
if (speed && speed > 0) {
|
||||
parts.push(formatSpeed(speed));
|
||||
}
|
||||
if (currentDep) {
|
||||
return (
|
||||
<>
|
||||
<span>{parts.join(' · ')}</span>
|
||||
<br />
|
||||
<span className="opacity-70">{currentDep}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
if (total != null) {
|
||||
return t('plugins.installProgress.depsInfo', { count: total });
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Overall progress bar — always blue */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
isDone
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-blue-700 dark:text-blue-300',
|
||||
)}
|
||||
>
|
||||
{isDone
|
||||
? t('plugins.installProgress.completed')
|
||||
: isError
|
||||
? t('plugins.installProgress.failed')
|
||||
: t('plugins.installProgress.overallProgress')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
isDone
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-blue-600 dark:text-blue-400',
|
||||
)}
|
||||
>
|
||||
{isDone ? '100%' : `${task.overallProgress}%`}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={isDone ? 100 : task.overallProgress}
|
||||
className={cn(
|
||||
'h-2.5',
|
||||
'[&>div]:bg-blue-500 dark:[&>div]:bg-blue-400',
|
||||
'bg-blue-100 dark:bg-blue-900/30',
|
||||
isDone &&
|
||||
'[&>div]:bg-green-500 dark:[&>div]:bg-green-400 bg-green-100 dark:bg-green-900/30',
|
||||
isError &&
|
||||
'[&>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">
|
||||
{isDone
|
||||
? /* When done: show all stages with completed style */
|
||||
STAGES.map((stageConfig) => (
|
||||
<StageRow
|
||||
key={stageConfig.key}
|
||||
icon={stageConfig.icon}
|
||||
label={t(stageConfig.i18nKey)}
|
||||
isActive={false}
|
||||
isCompleted={true}
|
||||
isError={false}
|
||||
detail={getStageDetail(stageConfig.key, true)}
|
||||
/>
|
||||
))
|
||||
: isError
|
||||
? /* Error: show the failed stage */
|
||||
currentStageIndex >= 0 && (
|
||||
<StageRow
|
||||
icon={STAGES[currentStageIndex].icon}
|
||||
label={t(STAGES[currentStageIndex].i18nKey)}
|
||||
isActive={true}
|
||||
isCompleted={false}
|
||||
isError={true}
|
||||
detail={task.error}
|
||||
/>
|
||||
)
|
||||
: /* In progress: only show the current active stage */
|
||||
currentStageIndex >= 0 && (
|
||||
<StageRow
|
||||
icon={STAGES[currentStageIndex].icon}
|
||||
label={t(STAGES[currentStageIndex].i18nKey)}
|
||||
isActive={true}
|
||||
isCompleted={false}
|
||||
isError={false}
|
||||
detail={getStageDetail(STAGES[currentStageIndex].key, false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Done banner */}
|
||||
{isDone && (
|
||||
<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 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm text-green-700 dark:text-green-300 font-medium">
|
||||
{t('plugins.installProgress.installComplete')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error detail */}
|
||||
{isError && task.error && (
|
||||
<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">
|
||||
{task.error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PluginInstallProgressDialog() {
|
||||
const { t } = useTranslation();
|
||||
const { tasks, selectedTaskId, setSelectedTaskId, removeTask } =
|
||||
usePluginInstallTasks();
|
||||
|
||||
const selectedTask = tasks.find((t) => t.id === selectedTaskId) || null;
|
||||
const open = !!selectedTask;
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedTaskId(null);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (selectedTask) {
|
||||
if (
|
||||
selectedTask.stage === InstallStage.DONE ||
|
||||
selectedTask.stage === InstallStage.ERROR
|
||||
) {
|
||||
removeTask(selectedTask.id);
|
||||
}
|
||||
}
|
||||
setSelectedTaskId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
<DialogContent className="w-[460px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<Download className="size-5" />
|
||||
<span className="truncate">
|
||||
{selectedTask
|
||||
? t('plugins.installProgress.title', {
|
||||
name: selectedTask.pluginName,
|
||||
})
|
||||
: t('plugins.installProgress.titleGeneric')}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedTask && <TaskProgressContent task={selectedTask} />}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
{selectedTask &&
|
||||
(selectedTask.stage === InstallStage.DONE ||
|
||||
selectedTask.stage === InstallStage.ERROR) && (
|
||||
<Button variant="outline" size="sm" onClick={handleDismiss}>
|
||||
{t('plugins.installProgress.dismiss')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" size="sm" onClick={handleClose}>
|
||||
{selectedTask?.stage === InstallStage.DONE ||
|
||||
selectedTask?.stage === InstallStage.ERROR
|
||||
? t('common.close')
|
||||
: t('plugins.installProgress.background')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { AsyncTask } from '@/app/infra/entities/api';
|
||||
|
||||
/**
|
||||
* Installation stages mapped from backend current_action strings.
|
||||
*/
|
||||
export enum InstallStage {
|
||||
DOWNLOADING = 'downloading',
|
||||
INSTALLING_DEPS = 'installing_deps',
|
||||
INITIALIZING = 'initializing',
|
||||
LAUNCHING = 'launching',
|
||||
DONE = 'done',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export interface PluginInstallTask {
|
||||
id: string; // unique key: `${source}-${taskId}`
|
||||
taskId: number; // backend async task id
|
||||
pluginName: string; // display name
|
||||
source: 'github' | 'marketplace' | 'local';
|
||||
stage: InstallStage;
|
||||
overallProgress: number; // 0-100
|
||||
fileSize?: number; // bytes, if known
|
||||
// Download progress
|
||||
downloadCurrent?: number; // bytes downloaded so far
|
||||
downloadTotal?: number; // total bytes to download
|
||||
downloadSpeed?: number; // bytes per second
|
||||
// Dependency progress
|
||||
depsTotal?: number; // total dependency count
|
||||
depsInstalled?: number; // deps installed so far
|
||||
depsRemaining?: number; // remaining
|
||||
currentDep?: string; // currently installing dep name
|
||||
depsDownloadedSize?: number; // total bytes of downloaded deps
|
||||
depsSpeed?: number; // deps download speed bytes/s
|
||||
error?: string;
|
||||
startedAt: number; // timestamp
|
||||
currentAction: string; // raw backend action string
|
||||
}
|
||||
|
||||
type OnTaskCompleteCallback = (taskId: number, success: boolean) => void;
|
||||
|
||||
interface PluginInstallTaskContextValue {
|
||||
tasks: PluginInstallTask[];
|
||||
addTask: (params: {
|
||||
taskId: number;
|
||||
pluginName: string;
|
||||
source: 'github' | 'marketplace' | 'local';
|
||||
fileSize?: number;
|
||||
}) => void;
|
||||
removeTask: (id: string) => void;
|
||||
clearCompletedTasks: () => void;
|
||||
selectedTaskId: string | null;
|
||||
setSelectedTaskId: (id: string | null) => void;
|
||||
/** Register a callback for when a task completes (for toast/refresh). Cleared on unmount. */
|
||||
registerOnTaskComplete: (cb: OnTaskCompleteCallback) => void;
|
||||
unregisterOnTaskComplete: (cb: OnTaskCompleteCallback) => void;
|
||||
}
|
||||
|
||||
const PluginInstallTaskContext =
|
||||
createContext<PluginInstallTaskContextValue | null>(null);
|
||||
|
||||
export function usePluginInstallTasks() {
|
||||
const ctx = useContext(PluginInstallTaskContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'usePluginInstallTasks must be used within PluginInstallTaskProvider',
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map backend `current_action` to our InstallStage.
|
||||
*/
|
||||
function mapActionToStage(action: string): InstallStage {
|
||||
if (!action) return InstallStage.DOWNLOADING;
|
||||
const lower = action.toLowerCase();
|
||||
if (lower.includes('download')) return InstallStage.DOWNLOADING;
|
||||
if (lower.includes('dependencies') || lower.includes('requirements'))
|
||||
return InstallStage.INSTALLING_DEPS;
|
||||
if (lower.includes('initializ') || lower.includes('setting'))
|
||||
return InstallStage.INITIALIZING;
|
||||
if (lower.includes('launch')) return InstallStage.LAUNCHING;
|
||||
if (lower.includes('installed') || lower.includes('complete'))
|
||||
return InstallStage.DONE;
|
||||
return InstallStage.DOWNLOADING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall progress percentage from a stage.
|
||||
*/
|
||||
function stageToProgress(stage: InstallStage): number {
|
||||
switch (stage) {
|
||||
case InstallStage.DOWNLOADING:
|
||||
return 10;
|
||||
case InstallStage.INSTALLING_DEPS:
|
||||
return 40;
|
||||
case InstallStage.INITIALIZING:
|
||||
return 70;
|
||||
case InstallStage.LAUNCHING:
|
||||
return 85;
|
||||
case InstallStage.DONE:
|
||||
return 100;
|
||||
case InstallStage.ERROR:
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract install source from backend task name.
|
||||
*/
|
||||
function extractSourceFromName(
|
||||
name: string,
|
||||
): 'github' | 'marketplace' | 'local' {
|
||||
if (name.includes('github')) return 'github';
|
||||
if (name.includes('marketplace')) return 'marketplace';
|
||||
return 'local';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a backend task name is a plugin install task.
|
||||
*/
|
||||
function isPluginInstallTask(name: string): boolean {
|
||||
return name.startsWith('plugin-install-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a backend AsyncTask to our PluginInstallTask.
|
||||
*/
|
||||
function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
||||
const source = extractSourceFromName(task.name);
|
||||
const md = (task.task_context?.metadata ?? {}) as Record<string, unknown>;
|
||||
const action = task.task_context?.current_action || '';
|
||||
const done = task.runtime.done;
|
||||
const exception = task.runtime.exception;
|
||||
|
||||
const num = (v: unknown) => (typeof v === 'number' ? v : undefined);
|
||||
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
|
||||
|
||||
let stage: InstallStage;
|
||||
let overallProgress: number;
|
||||
let error: string | undefined;
|
||||
|
||||
if (done) {
|
||||
if (exception) {
|
||||
stage = InstallStage.ERROR;
|
||||
overallProgress = 0;
|
||||
error = exception;
|
||||
} else {
|
||||
stage = InstallStage.DONE;
|
||||
overallProgress = 100;
|
||||
}
|
||||
} else {
|
||||
stage = mapActionToStage(action);
|
||||
overallProgress = Math.min(95, stageToProgress(stage));
|
||||
}
|
||||
|
||||
const pluginName = str(md.plugin_name) || task.label || `${source} plugin`;
|
||||
|
||||
return {
|
||||
id: `${source}-${task.id}`,
|
||||
taskId: task.id,
|
||||
pluginName,
|
||||
source,
|
||||
stage,
|
||||
overallProgress,
|
||||
downloadCurrent: num(md.download_current),
|
||||
downloadTotal: num(md.download_total),
|
||||
downloadSpeed: num(md.download_speed),
|
||||
depsTotal: num(md.deps_total),
|
||||
depsInstalled: num(md.deps_installed),
|
||||
depsRemaining: num(md.deps_remaining),
|
||||
currentDep: str(md.current_dep),
|
||||
depsDownloadedSize: num(md.deps_downloaded_size),
|
||||
depsSpeed: num(md.deps_speed),
|
||||
error,
|
||||
startedAt: Date.now(),
|
||||
currentAction: action,
|
||||
};
|
||||
}
|
||||
|
||||
export function PluginInstallTaskProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [tasks, setTasks] = useState<PluginInstallTask[]>([]);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const intervalRefs = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const syncIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const onTaskCompleteCallbacks = useRef<Set<OnTaskCompleteCallback>>(
|
||||
new Set(),
|
||||
);
|
||||
// Track tasks that have already been marked as completed/failed (to avoid duplicate callbacks)
|
||||
const notifiedTaskIds = useRef<Set<number>>(new Set());
|
||||
// Track task IDs that the user has explicitly dismissed
|
||||
const dismissedTaskIds = useRef<Set<number>>(new Set());
|
||||
|
||||
// Cleanup all intervals on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
intervalRefs.current.forEach((interval) => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
if (syncIntervalRef.current) clearInterval(syncIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const registerOnTaskComplete = useCallback((cb: OnTaskCompleteCallback) => {
|
||||
onTaskCompleteCallbacks.current.add(cb);
|
||||
}, []);
|
||||
|
||||
const unregisterOnTaskComplete = useCallback((cb: OnTaskCompleteCallback) => {
|
||||
onTaskCompleteCallbacks.current.delete(cb);
|
||||
}, []);
|
||||
|
||||
const notifyTaskComplete = useCallback((taskId: number, success: boolean) => {
|
||||
if (notifiedTaskIds.current.has(taskId)) return;
|
||||
notifiedTaskIds.current.add(taskId);
|
||||
onTaskCompleteCallbacks.current.forEach((cb) => {
|
||||
cb(taskId, success);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pollTask = useCallback(
|
||||
(taskKey: string, taskId: number) => {
|
||||
// Don't start duplicate polling for the same task
|
||||
if (intervalRefs.current.has(taskKey)) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
httpClient
|
||||
.getAsyncTask(taskId)
|
||||
.then((res: AsyncTask) => {
|
||||
const action = res.task_context?.current_action || '';
|
||||
const done = res.runtime.done;
|
||||
const exception = res.runtime.exception;
|
||||
const md = (res.task_context?.metadata ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// Extract progress fields from metadata
|
||||
const num = (v: unknown) => (typeof v === 'number' ? v : undefined);
|
||||
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
|
||||
|
||||
const downloadCurrent = num(md.download_current);
|
||||
const downloadTotal = num(md.download_total);
|
||||
const downloadSpeed = num(md.download_speed);
|
||||
const depsTotal = num(md.deps_total);
|
||||
const depsInstalled = num(md.deps_installed);
|
||||
const depsRemaining = num(md.deps_remaining);
|
||||
const currentDep = str(md.current_dep);
|
||||
const depsDownloadedSize = num(md.deps_downloaded_size);
|
||||
const depsSpeed = num(md.deps_speed);
|
||||
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== taskKey) return t;
|
||||
|
||||
const progressFields = {
|
||||
downloadCurrent: downloadCurrent ?? t.downloadCurrent,
|
||||
downloadTotal: downloadTotal ?? t.downloadTotal,
|
||||
downloadSpeed: downloadSpeed ?? t.downloadSpeed,
|
||||
depsTotal: depsTotal ?? t.depsTotal,
|
||||
depsInstalled: depsInstalled ?? t.depsInstalled,
|
||||
depsRemaining: depsRemaining ?? t.depsRemaining,
|
||||
currentDep: currentDep ?? t.currentDep,
|
||||
depsDownloadedSize:
|
||||
depsDownloadedSize ?? t.depsDownloadedSize,
|
||||
depsSpeed: depsSpeed ?? t.depsSpeed,
|
||||
};
|
||||
|
||||
if (done) {
|
||||
// Stop polling
|
||||
const iv = intervalRefs.current.get(taskKey);
|
||||
if (iv) {
|
||||
clearInterval(iv);
|
||||
intervalRefs.current.delete(taskKey);
|
||||
}
|
||||
|
||||
if (exception) {
|
||||
notifyTaskComplete(taskId, false);
|
||||
return {
|
||||
...t,
|
||||
stage: InstallStage.ERROR,
|
||||
error: exception,
|
||||
overallProgress: 0,
|
||||
currentAction: action,
|
||||
...progressFields,
|
||||
};
|
||||
}
|
||||
|
||||
notifyTaskComplete(taskId, true);
|
||||
return {
|
||||
...t,
|
||||
stage: InstallStage.DONE,
|
||||
overallProgress: 100,
|
||||
currentAction: action,
|
||||
...progressFields,
|
||||
};
|
||||
}
|
||||
|
||||
const stage = mapActionToStage(action);
|
||||
const baseProgress = stageToProgress(stage);
|
||||
// Add small time-based increment within stage
|
||||
const elapsed = (Date.now() - t.startedAt) / 1000;
|
||||
const withinStageIncrement = Math.min(
|
||||
15,
|
||||
Math.floor(elapsed / 2),
|
||||
);
|
||||
const progress = Math.min(
|
||||
95,
|
||||
baseProgress + withinStageIncrement,
|
||||
);
|
||||
|
||||
return {
|
||||
...t,
|
||||
stage,
|
||||
overallProgress: progress,
|
||||
currentAction: action,
|
||||
...progressFields,
|
||||
};
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore polling errors
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
intervalRefs.current.set(taskKey, interval);
|
||||
},
|
||||
[notifyTaskComplete],
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch all plugin-operation tasks from backend and sync state.
|
||||
* This is called on mount and periodically to recover tasks after refresh.
|
||||
*/
|
||||
const syncTasksFromBackend = useCallback(async () => {
|
||||
try {
|
||||
const resp = await httpClient.getAsyncTasks({ kind: 'plugin-operation' });
|
||||
const backendTasks = (resp.tasks || []).filter((t: AsyncTask) =>
|
||||
isPluginInstallTask(t.name),
|
||||
);
|
||||
|
||||
setTasks((prevTasks) => {
|
||||
const existingTaskIds = new Set(prevTasks.map((t) => t.taskId));
|
||||
const updatedTasks = [...prevTasks];
|
||||
|
||||
for (const bt of backendTasks) {
|
||||
// Skip tasks that the user has dismissed
|
||||
if (dismissedTaskIds.current.has(bt.id)) continue;
|
||||
|
||||
if (!existingTaskIds.has(bt.id)) {
|
||||
// New task from backend (e.g. after page refresh) — add it
|
||||
const newTask = asyncTaskToPluginInstallTask(bt);
|
||||
updatedTasks.push(newTask);
|
||||
|
||||
// If not done, start polling for progress
|
||||
if (!bt.runtime.done) {
|
||||
pollTask(newTask.id, bt.id);
|
||||
} else {
|
||||
// Mark as already notified so we don't re-trigger toasts for old completed tasks
|
||||
notifiedTaskIds.current.add(bt.id);
|
||||
}
|
||||
} else {
|
||||
// Already tracking — if it's done in backend but still active locally, update it
|
||||
const idx = updatedTasks.findIndex((t) => t.taskId === bt.id);
|
||||
if (idx !== -1) {
|
||||
const existing = updatedTasks[idx];
|
||||
if (
|
||||
bt.runtime.done &&
|
||||
existing.stage !== InstallStage.DONE &&
|
||||
existing.stage !== InstallStage.ERROR
|
||||
) {
|
||||
const converted = asyncTaskToPluginInstallTask(bt);
|
||||
converted.startedAt = existing.startedAt;
|
||||
converted.pluginName = existing.pluginName;
|
||||
converted.fileSize = existing.fileSize;
|
||||
updatedTasks[idx] = converted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedTasks;
|
||||
});
|
||||
} catch {
|
||||
// Silently ignore sync errors
|
||||
}
|
||||
}, [pollTask]);
|
||||
|
||||
// Initial sync on mount + periodic sync every 3s
|
||||
useEffect(() => {
|
||||
syncTasksFromBackend();
|
||||
syncIntervalRef.current = setInterval(syncTasksFromBackend, 3000);
|
||||
return () => {
|
||||
if (syncIntervalRef.current) clearInterval(syncIntervalRef.current);
|
||||
};
|
||||
}, [syncTasksFromBackend]);
|
||||
|
||||
const addTask = useCallback(
|
||||
(params: {
|
||||
taskId: number;
|
||||
pluginName: string;
|
||||
source: 'github' | 'marketplace' | 'local';
|
||||
fileSize?: number;
|
||||
}) => {
|
||||
const taskKey = `${params.source}-${params.taskId}`;
|
||||
|
||||
// Remove from dismissed set if re-added
|
||||
dismissedTaskIds.current.delete(params.taskId);
|
||||
|
||||
const newTask: PluginInstallTask = {
|
||||
id: taskKey,
|
||||
taskId: params.taskId,
|
||||
pluginName: params.pluginName,
|
||||
source: params.source,
|
||||
stage: InstallStage.DOWNLOADING,
|
||||
overallProgress: 5,
|
||||
fileSize: params.fileSize,
|
||||
startedAt: Date.now(),
|
||||
currentAction: '',
|
||||
};
|
||||
|
||||
setTasks((prev) => {
|
||||
// Avoid duplicate
|
||||
if (prev.some((t) => t.taskId === params.taskId)) return prev;
|
||||
return [...prev, newTask];
|
||||
});
|
||||
pollTask(taskKey, params.taskId);
|
||||
},
|
||||
[pollTask],
|
||||
);
|
||||
|
||||
const removeTask = useCallback((id: string) => {
|
||||
const iv = intervalRefs.current.get(id);
|
||||
if (iv) {
|
||||
clearInterval(iv);
|
||||
intervalRefs.current.delete(id);
|
||||
}
|
||||
|
||||
setTasks((prev) => {
|
||||
const task = prev.find((t) => t.id === id);
|
||||
if (task) {
|
||||
dismissedTaskIds.current.add(task.taskId);
|
||||
}
|
||||
return prev.filter((t) => t.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearCompletedTasks = useCallback(() => {
|
||||
setTasks((prev) => {
|
||||
const completed = prev.filter(
|
||||
(t) => t.stage === InstallStage.DONE || t.stage === InstallStage.ERROR,
|
||||
);
|
||||
completed.forEach((t) => {
|
||||
dismissedTaskIds.current.add(t.taskId);
|
||||
});
|
||||
return prev.filter(
|
||||
(t) => t.stage !== InstallStage.DONE && t.stage !== InstallStage.ERROR,
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PluginInstallTaskContext.Provider
|
||||
value={{
|
||||
tasks,
|
||||
addTask,
|
||||
removeTask,
|
||||
clearCompletedTasks,
|
||||
selectedTaskId,
|
||||
setSelectedTaskId,
|
||||
registerOnTaskComplete,
|
||||
unregisterOnTaskComplete,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PluginInstallTaskContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Download,
|
||||
Package,
|
||||
Settings,
|
||||
Rocket,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
X,
|
||||
ListTodo,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
usePluginInstallTasks,
|
||||
InstallStage,
|
||||
PluginInstallTask,
|
||||
} from './PluginInstallTaskContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STAGE_ICONS: Record<string, React.ElementType> = {
|
||||
[InstallStage.DOWNLOADING]: Download,
|
||||
[InstallStage.INSTALLING_DEPS]: Package,
|
||||
[InstallStage.INITIALIZING]: Settings,
|
||||
[InstallStage.LAUNCHING]: Rocket,
|
||||
[InstallStage.DONE]: CheckCircle2,
|
||||
[InstallStage.ERROR]: XCircle,
|
||||
};
|
||||
|
||||
function TaskQueueItem({
|
||||
task,
|
||||
onClick,
|
||||
onRemove,
|
||||
}: {
|
||||
task: PluginInstallTask;
|
||||
onClick: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isDone = task.stage === InstallStage.DONE;
|
||||
const isError = task.stage === InstallStage.ERROR;
|
||||
const isRunning = !isDone && !isError;
|
||||
const StageIcon = STAGE_ICONS[task.stage] || Download;
|
||||
|
||||
const stageLabel = (() => {
|
||||
switch (task.stage) {
|
||||
case InstallStage.DOWNLOADING:
|
||||
return t('plugins.installProgress.downloading');
|
||||
case InstallStage.INSTALLING_DEPS:
|
||||
return t('plugins.installProgress.installingDeps');
|
||||
case InstallStage.INITIALIZING:
|
||||
return t('plugins.installProgress.initializing');
|
||||
case InstallStage.LAUNCHING:
|
||||
return t('plugins.installProgress.launching');
|
||||
case InstallStage.DONE:
|
||||
return t('plugins.installProgress.completed');
|
||||
case InstallStage.ERROR:
|
||||
return t('plugins.installProgress.failed');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-lg hover:bg-muted/60 cursor-pointer transition-colors group"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-7 h-7 rounded-full shrink-0',
|
||||
isDone &&
|
||||
'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
|
||||
isError &&
|
||||
'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
|
||||
isRunning &&
|
||||
'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
|
||||
)}
|
||||
>
|
||||
{isRunning ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<StageIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{task.pluginName}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{stageLabel}</span>
|
||||
{isRunning && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{task.overallProgress}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isRunning && (
|
||||
<Progress value={task.overallProgress} className="h-1 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(isDone || isError) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-6 h-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PluginInstallTaskQueue() {
|
||||
const { t } = useTranslation();
|
||||
const { tasks, setSelectedTaskId, removeTask, clearCompletedTasks } =
|
||||
usePluginInstallTasks();
|
||||
|
||||
const runningCount = tasks.filter(
|
||||
(t) => t.stage !== InstallStage.DONE && t.stage !== InstallStage.ERROR,
|
||||
).length;
|
||||
const hasCompleted = tasks.some(
|
||||
(t) => t.stage === InstallStage.DONE || t.stage === InstallStage.ERROR,
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="relative px-4 py-5 cursor-pointer">
|
||||
<ListTodo className="w-4 h-4 mr-2" />
|
||||
{t('plugins.installProgress.taskQueue')}
|
||||
{runningCount > 0 && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="ml-2 h-5 min-w-5 px-1.5 text-xs"
|
||||
>
|
||||
{runningCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[340px] p-2" align="end">
|
||||
<div className="flex items-center justify-between px-2 py-1.5 mb-1">
|
||||
<span className="text-sm font-semibold">
|
||||
{t('plugins.installProgress.taskQueue')}
|
||||
</span>
|
||||
{hasCompleted && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={clearCompletedTasks}
|
||||
>
|
||||
{t('plugins.installProgress.clearCompleted')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto space-y-0.5">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t('plugins.installProgress.noTasks')}
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskQueueItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
onRemove={() => removeTask(task.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
PluginInstallTaskProvider,
|
||||
usePluginInstallTasks,
|
||||
InstallStage,
|
||||
} from './PluginInstallTaskContext';
|
||||
export type { PluginInstallTask } from './PluginInstallTaskContext';
|
||||
export { default as PluginInstallProgressDialog } from './PluginInstallProgressDialog';
|
||||
export { default as PluginInstallTaskQueue } from './PluginInstallTaskQueue';
|
||||
@@ -52,6 +52,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import { systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import {
|
||||
PluginInstallTaskQueue,
|
||||
usePluginInstallTasks,
|
||||
} from '@/app/home/plugins/components/plugin-install-task';
|
||||
|
||||
enum PluginInstallStatus {
|
||||
WAIT_INPUT = 'wait_input',
|
||||
@@ -99,6 +103,12 @@ function PluginListView() {
|
||||
pendingPluginInstallAction,
|
||||
setPendingPluginInstallAction,
|
||||
} = useSidebarData();
|
||||
const {
|
||||
addTask,
|
||||
setSelectedTaskId,
|
||||
registerOnTaskComplete,
|
||||
unregisterOnTaskComplete,
|
||||
} = usePluginInstallTasks();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [installSource, setInstallSource] = useState<string>('local');
|
||||
const [installInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
@@ -155,30 +165,20 @@ function PluginListView() {
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function watchTask(taskId: number) {
|
||||
let alreadySuccess = false;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
httpClient.getAsyncTask(taskId).then((resp) => {
|
||||
if (resp.runtime.done) {
|
||||
clearInterval(interval);
|
||||
if (resp.runtime.exception) {
|
||||
setInstallError(resp.runtime.exception);
|
||||
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
||||
} else {
|
||||
if (!alreadySuccess) {
|
||||
toast.success(t('plugins.installSuccess'));
|
||||
alreadySuccess = true;
|
||||
}
|
||||
resetGithubState();
|
||||
setModalOpen(false);
|
||||
pluginInstalledRef.current?.refreshPluginList();
|
||||
refreshPlugins();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
// Register task completion callback for toast and plugin list refresh
|
||||
useEffect(() => {
|
||||
const onComplete = (_taskId: number, success: boolean) => {
|
||||
if (success) {
|
||||
toast.success(t('plugins.installSuccess'));
|
||||
pluginInstalledRef.current?.refreshPluginList();
|
||||
refreshPlugins();
|
||||
}
|
||||
};
|
||||
registerOnTaskComplete(onComplete);
|
||||
return () => {
|
||||
unregisterOnTaskComplete(onComplete);
|
||||
};
|
||||
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
|
||||
|
||||
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
|
||||
|
||||
@@ -300,6 +300,8 @@ function PluginListView() {
|
||||
) {
|
||||
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
||||
if (installSource === 'github') {
|
||||
const pluginDisplayName = `${installInfo.owner}/${installInfo.repo}`;
|
||||
const assetSize = selectedAsset?.size;
|
||||
httpClient
|
||||
.installPluginFromGithub(
|
||||
installInfo.asset_url,
|
||||
@@ -309,18 +311,37 @@ function PluginListView() {
|
||||
)
|
||||
.then((resp) => {
|
||||
const taskId = resp.task_id;
|
||||
watchTask(taskId);
|
||||
const taskKey = `github-${taskId}`;
|
||||
addTask({
|
||||
taskId,
|
||||
pluginName: pluginDisplayName,
|
||||
source: 'github',
|
||||
fileSize: assetSize,
|
||||
});
|
||||
setSelectedTaskId(taskKey);
|
||||
resetGithubState();
|
||||
setModalOpen(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;
|
||||
watchTask(taskId);
|
||||
const taskKey = `local-${taskId}`;
|
||||
addTask({
|
||||
taskId,
|
||||
pluginName: fileName,
|
||||
source: 'local',
|
||||
fileSize: fileSize,
|
||||
});
|
||||
setSelectedTaskId(taskKey);
|
||||
setModalOpen(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setInstallError(err.msg);
|
||||
@@ -546,8 +567,10 @@ function PluginListView() {
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Header bar with debug info and install button */}
|
||||
{/* Header bar with debug info, task queue, and install button */}
|
||||
<div className="flex flex-row justify-end items-center px-[0.8rem] pb-4 flex-shrink-0 gap-2">
|
||||
<PluginInstallTaskQueue />
|
||||
|
||||
<Popover open={debugPopoverOpen} onOpenChange={setDebugPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -299,12 +299,14 @@ export interface AsyncTaskRuntimeInfo {
|
||||
export interface AsyncTaskTaskContext {
|
||||
current_action: string;
|
||||
log: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AsyncTask {
|
||||
id: number;
|
||||
kind: string;
|
||||
name: string;
|
||||
label: string;
|
||||
task_type: string; // system or user
|
||||
runtime: AsyncTaskRuntimeInfo;
|
||||
task_context: AsyncTaskTaskContext;
|
||||
|
||||
@@ -715,8 +715,15 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.put('/api/v1/system/wizard/progress', progress);
|
||||
}
|
||||
|
||||
public getAsyncTasks(): Promise<ApiRespAsyncTasks> {
|
||||
return this.get('/api/v1/system/tasks');
|
||||
public getAsyncTasks(params?: {
|
||||
type?: string;
|
||||
kind?: string;
|
||||
}): Promise<ApiRespAsyncTasks> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.type) query.set('type', params.type);
|
||||
if (params?.kind) query.set('kind', params.kind);
|
||||
const qs = query.toString();
|
||||
return this.get(`/api/v1/system/tasks${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
public getAsyncTask(id: number): Promise<AsyncTask> {
|
||||
|
||||
28
web/src/components/ui/progress.tsx
Normal file
28
web/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ComponentRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative h-2 w-full overflow-hidden rounded-full bg-primary/20',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all duration-300"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
@@ -493,6 +493,27 @@ const enUS = {
|
||||
confirmInstall: 'Confirm Install',
|
||||
installFromGithubDesc: 'Install plugin from GitHub Release',
|
||||
goToMarketplace: 'Go to Marketplace',
|
||||
installProgress: {
|
||||
title: 'Installing {{name}}',
|
||||
titleGeneric: 'Plugin Installation',
|
||||
overallProgress: 'Overall Progress',
|
||||
downloading: 'Downloading Plugin',
|
||||
installingDeps: 'Installing Dependencies',
|
||||
initializing: 'Initializing Settings',
|
||||
launching: 'Launching Plugin',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
downloadSize: 'Package size: {{size}}',
|
||||
depsInfo: '{{count}} dependencies to install',
|
||||
depsProgress:
|
||||
'{{installed}}/{{total}} installed · {{remaining}} remaining',
|
||||
installComplete: 'Plugin installed successfully',
|
||||
dismiss: 'Dismiss',
|
||||
background: 'Run in Background',
|
||||
taskQueue: 'Install Tasks',
|
||||
clearCompleted: 'Clear Completed',
|
||||
noTasks: 'No install tasks',
|
||||
},
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'Search plugins...',
|
||||
|
||||
@@ -503,6 +503,27 @@ const esES = {
|
||||
confirmInstall: 'Confirmar instalación',
|
||||
installFromGithubDesc: 'Instalar plugin desde GitHub Release',
|
||||
goToMarketplace: 'Ir a la tienda',
|
||||
installProgress: {
|
||||
title: 'Instalando {{name}}',
|
||||
titleGeneric: 'Instalación de Plugin',
|
||||
overallProgress: 'Progreso general',
|
||||
downloading: 'Descargando Plugin',
|
||||
installingDeps: 'Instalando dependencias',
|
||||
initializing: 'Inicializando configuración',
|
||||
launching: 'Iniciando Plugin',
|
||||
completed: 'Completado',
|
||||
failed: 'Fallido',
|
||||
downloadSize: 'Tamaño del paquete: {{size}}',
|
||||
depsInfo: '{{count}} dependencias por instalar',
|
||||
depsProgress:
|
||||
'{{installed}}/{{total}} instaladas · {{remaining}} restantes',
|
||||
installComplete: 'Plugin instalado correctamente',
|
||||
dismiss: 'Descartar',
|
||||
background: 'Ejecutar en segundo plano',
|
||||
taskQueue: 'Tareas de instalación',
|
||||
clearCompleted: 'Limpiar completados',
|
||||
noTasks: 'No hay tareas de instalación',
|
||||
},
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'Buscar plugins...',
|
||||
|
||||
@@ -493,6 +493,27 @@
|
||||
confirmInstall: 'インストールを確認',
|
||||
installFromGithubDesc: 'GitHubリリースからプラグインをインストール',
|
||||
goToMarketplace: 'マーケットプレイスへ',
|
||||
installProgress: {
|
||||
title: '{{name}} をインストール中',
|
||||
titleGeneric: 'プラグインのインストール',
|
||||
overallProgress: '全体の進捗',
|
||||
downloading: 'プラグインをダウンロード中',
|
||||
installingDeps: '依存関係をインストール中',
|
||||
initializing: '設定を初期化中',
|
||||
launching: 'プラグインを起動中',
|
||||
completed: '完了',
|
||||
failed: '失敗',
|
||||
downloadSize: 'パッケージサイズ: {{size}}',
|
||||
depsInfo: '{{count}} 個の依存関係をインストール',
|
||||
depsProgress:
|
||||
'{{installed}}/{{total}} インストール済み · 残り {{remaining}} 個',
|
||||
installComplete: 'プラグインのインストール完了',
|
||||
dismiss: '閉じる',
|
||||
background: 'バックグラウンドで実行',
|
||||
taskQueue: 'インストールタスク',
|
||||
clearCompleted: '完了を消去',
|
||||
noTasks: 'インストールタスクはありません',
|
||||
},
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'プラグインを検索...',
|
||||
|
||||
@@ -485,6 +485,26 @@ const thTH = {
|
||||
confirmInstall: 'ยืนยันการติดตั้ง',
|
||||
installFromGithubDesc: 'ติดตั้งปลั๊กอินจาก GitHub Release',
|
||||
goToMarketplace: 'ไปที่ตลาดปลั๊กอิน',
|
||||
installProgress: {
|
||||
title: 'กำลังติดตั้ง {{name}}',
|
||||
titleGeneric: 'การติดตั้งปลั๊กอิน',
|
||||
overallProgress: 'ความคืบหน้าโดยรวม',
|
||||
downloading: 'กำลังดาวน์โหลดปลั๊กอิน',
|
||||
installingDeps: 'กำลังติดตั้งแพ็กเกจ',
|
||||
initializing: 'กำลังเริ่มต้นการตั้งค่า',
|
||||
launching: 'กำลังเปิดใช้งานปลั๊กอิน',
|
||||
completed: 'เสร็จสมบูรณ์',
|
||||
failed: 'ล้มเหลว',
|
||||
downloadSize: 'ขนาดแพ็กเกจ: {{size}}',
|
||||
depsInfo: '{{count}} แพ็กเกจที่ต้องติดตั้ง',
|
||||
depsProgress: 'ติดตั้งแล้ว {{installed}}/{{total}} · เหลือ {{remaining}}',
|
||||
installComplete: 'ติดตั้งปลั๊กอินสำเร็จ',
|
||||
dismiss: 'ปิด',
|
||||
background: 'ทำงานเบื้องหลัง',
|
||||
taskQueue: 'งานติดตั้ง',
|
||||
clearCompleted: 'ล้างที่เสร็จแล้ว',
|
||||
noTasks: 'ไม่มีงานติดตั้ง',
|
||||
},
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'ค้นหาปลั๊กอิน...',
|
||||
|
||||
@@ -497,6 +497,26 @@ const viVN = {
|
||||
confirmInstall: 'Xác nhận cài đặt',
|
||||
installFromGithubDesc: 'Cài đặt plugin từ GitHub Release',
|
||||
goToMarketplace: 'Đi đến chợ ứng dụng',
|
||||
installProgress: {
|
||||
title: 'Đang cài đặt {{name}}',
|
||||
titleGeneric: 'Cài đặt Plugin',
|
||||
overallProgress: 'Tiến độ tổng thể',
|
||||
downloading: 'Đang tải Plugin',
|
||||
installingDeps: 'Đang cài đặt phụ thuộc',
|
||||
initializing: 'Đang khởi tạo cài đặt',
|
||||
launching: 'Đang khởi chạy Plugin',
|
||||
completed: 'Hoàn thành',
|
||||
failed: 'Thất bại',
|
||||
downloadSize: 'Kích thước gói: {{size}}',
|
||||
depsInfo: '{{count}} phụ thuộc cần cài đặt',
|
||||
depsProgress: 'Đã cài {{installed}}/{{total}} · Còn lại {{remaining}}',
|
||||
installComplete: 'Cài đặt plugin thành công',
|
||||
dismiss: 'Bỏ qua',
|
||||
background: 'Chạy nền',
|
||||
taskQueue: 'Tác vụ cài đặt',
|
||||
clearCompleted: 'Xóa đã hoàn thành',
|
||||
noTasks: 'Không có tác vụ cài đặt',
|
||||
},
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'Tìm kiếm plugin...',
|
||||
|
||||
@@ -469,6 +469,26 @@ const zhHans = {
|
||||
confirmInstall: '确认安装',
|
||||
installFromGithubDesc: '从 GitHub Release 安装插件',
|
||||
goToMarketplace: '前往插件市场',
|
||||
installProgress: {
|
||||
title: '正在安装 {{name}}',
|
||||
titleGeneric: '插件安装',
|
||||
overallProgress: '总体进度',
|
||||
downloading: '下载插件',
|
||||
installingDeps: '安装依赖',
|
||||
initializing: '初始化配置',
|
||||
launching: '启动插件',
|
||||
completed: '已完成',
|
||||
failed: '安装失败',
|
||||
downloadSize: '包大小: {{size}}',
|
||||
depsInfo: '共 {{count}} 个依赖需要安装',
|
||||
depsProgress: '已安装 {{installed}}/{{total}} · 剩余 {{remaining}} 个',
|
||||
installComplete: '插件安装成功',
|
||||
dismiss: '关闭',
|
||||
background: '后台运行',
|
||||
taskQueue: '安装任务',
|
||||
clearCompleted: '清除已完成',
|
||||
noTasks: '暂无安装任务',
|
||||
},
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: '搜索插件...',
|
||||
|
||||
@@ -462,6 +462,26 @@ const zhHant = {
|
||||
confirmInstall: '確認安裝',
|
||||
installFromGithubDesc: '從 GitHub Release 安裝插件',
|
||||
goToMarketplace: '前往外掛市場',
|
||||
installProgress: {
|
||||
title: '正在安裝 {{name}}',
|
||||
titleGeneric: '外掛安裝',
|
||||
overallProgress: '整體進度',
|
||||
downloading: '下載外掛',
|
||||
installingDeps: '安裝依賴',
|
||||
initializing: '初始化設定',
|
||||
launching: '啟動外掛',
|
||||
completed: '已完成',
|
||||
failed: '安裝失敗',
|
||||
downloadSize: '檔案大小: {{size}}',
|
||||
depsInfo: '共 {{count}} 個依賴需要安裝',
|
||||
depsProgress: '已安裝 {{installed}}/{{total}} · 剩餘 {{remaining}} 個',
|
||||
installComplete: '外掛安裝成功',
|
||||
dismiss: '關閉',
|
||||
background: '背景執行',
|
||||
taskQueue: '安裝任務',
|
||||
clearCompleted: '清除已完成',
|
||||
noTasks: '暫無安裝任務',
|
||||
},
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: '搜尋插件...',
|
||||
|
||||
Reference in New Issue
Block a user