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:
Typer_Body
2026-03-29 23:58:34 +08:00
committed by GitHub
parent b0a9be77b0
commit 1c419e3591
24 changed files with 2619 additions and 3339 deletions

View File

@@ -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>
);
}

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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';

View File

@@ -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

View File

@@ -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;

View File

@@ -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> {