diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index f432212e..c4d28bb4 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -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, ) diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index c68a3837..8f211537 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -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/', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _(task_id: str) -> str: diff --git a/src/langbot/pkg/core/taskmgr.py b/src/langbot/pkg/core/taskmgr.py index 6b5f9a32..c6846594 100644 --- a/src/langbot/pkg/core/taskmgr.py +++ b/src/langbot/pkg/core/taskmgr.py @@ -215,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, } diff --git a/web/src/app/home/market/page.tsx b/web/src/app/home/market/page.tsx index c2b95f7f..88161973 100644 --- a/web/src/app/home/market/page.tsx +++ b/web/src/app/home/market/page.tsx @@ -10,7 +10,7 @@ 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'; @@ -42,7 +42,12 @@ export default function MarketplacePage() { function MarketplaceContent() { const { t } = useTranslation(); const { refreshPlugins } = useSidebarData(); - const { addTask, setSelectedTaskId } = usePluginInstallTasks(); + const { + addTask, + setSelectedTaskId, + registerOnTaskComplete, + unregisterOnTaskComplete, + } = usePluginInstallTasks(); const [modalOpen, setModalOpen] = useState(false); const [installInfo, setInstallInfo] = useState>({}); const [pluginInstallStatus, setPluginInstallStatus] = @@ -71,26 +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) { - // Error is shown via task queue progress dialog - } else { - if (!alreadySuccess) { - toast.success(t('plugins.installSuccess')); - alreadySuccess = true; - } - 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) => { @@ -126,7 +124,6 @@ function MarketplaceContent() { }); setSelectedTaskId(taskKey); setModalOpen(false); - watchTask(taskId); }) .catch((err) => { setInstallError(err.msg); diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx index 8fbf8347..3d29dc9f 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx @@ -11,9 +11,9 @@ import { Progress } from '@/components/ui/progress'; import { Button } from '@/components/ui/button'; import { Download, - FolderOpen, Package, - FlaskConical, + Settings, + Rocket, CheckCircle2, XCircle, Loader2, @@ -36,20 +36,20 @@ const STAGES: { icon: Download, i18nKey: 'plugins.installProgress.downloading', }, - { - key: InstallStage.EXTRACTING, - icon: FolderOpen, - i18nKey: 'plugins.installProgress.extracting', - }, { key: InstallStage.INSTALLING_DEPS, icon: Package, i18nKey: 'plugins.installProgress.installingDeps', }, { - key: InstallStage.TESTING, - icon: FlaskConical, - i18nKey: 'plugins.installProgress.testing', + key: InstallStage.INITIALIZING, + icon: Settings, + i18nKey: 'plugins.installProgress.initializing', + }, + { + key: InstallStage.LAUNCHING, + icon: Rocket, + i18nKey: 'plugins.installProgress.launching', }, ]; @@ -88,18 +88,29 @@ function StageRow({
{/* Left: status indicator */}
{isCompleted ? ( @@ -119,7 +130,10 @@ function StageRow({
{detail && ( -
+
{detail}
)} @@ -259,20 +280,28 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) { {/* Overall progress bar — always blue */}
- + {isDone ? t('plugins.installProgress.completed') : isError ? t('plugins.installProgress.failed') : t('plugins.installProgress.overallProgress')} - + {isDone ? '100%' : `${task.overallProgress}%`}
@@ -282,52 +311,52 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) { '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', + 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', )} />
{/* Stage display */}
- {isDone ? ( - /* When done: show all stages with completed style */ - STAGES.map((stageConfig) => ( - - )) - ) : isError ? ( - /* Error: show the failed stage */ - currentStageIndex >= 0 && ( - - ) - ) : ( - /* In progress: only show the current active stage */ - currentStageIndex >= 0 && ( - - ) - )} + {isDone + ? /* When done: show all stages with completed style */ + STAGES.map((stageConfig) => ( + + )) + : isError + ? /* Error: show the failed stage */ + currentStageIndex >= 0 && ( + + ) + : /* In progress: only show the current active stage */ + currentStageIndex >= 0 && ( + + )}
{/* Done banner */} diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx index a80a8236..0330bc8a 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx @@ -16,9 +16,9 @@ import { AsyncTask } from '@/app/infra/entities/api'; */ export enum InstallStage { DOWNLOADING = 'downloading', - EXTRACTING = 'extracting', INSTALLING_DEPS = 'installing_deps', - TESTING = 'testing', + INITIALIZING = 'initializing', + LAUNCHING = 'launching', DONE = 'done', ERROR = 'error', } @@ -47,6 +47,8 @@ export interface PluginInstallTask { currentAction: string; // raw backend action string } +type OnTaskCompleteCallback = (taskId: number, success: boolean) => void; + interface PluginInstallTaskContextValue { tasks: PluginInstallTask[]; addTask: (params: { @@ -59,6 +61,9 @@ interface PluginInstallTaskContextValue { 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 = @@ -81,16 +86,11 @@ function mapActionToStage(action: string): InstallStage { if (!action) return InstallStage.DOWNLOADING; const lower = action.toLowerCase(); if (lower.includes('download')) return InstallStage.DOWNLOADING; - if (lower.includes('extract') || lower.includes('unzip')) - return InstallStage.EXTRACTING; if (lower.includes('dependencies') || lower.includes('requirements')) return InstallStage.INSTALLING_DEPS; - if ( - lower.includes('initializ') || - lower.includes('launch') || - lower.includes('test') - ) - return InstallStage.TESTING; + 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; @@ -103,12 +103,12 @@ function stageToProgress(stage: InstallStage): number { switch (stage) { case InstallStage.DOWNLOADING: return 10; - case InstallStage.EXTRACTING: - return 35; case InstallStage.INSTALLING_DEPS: - return 55; - case InstallStage.TESTING: - return 80; + return 40; + case InstallStage.INITIALIZING: + return 70; + case InstallStage.LAUNCHING: + return 85; case InstallStage.DONE: return 100; case InstallStage.ERROR: @@ -118,6 +118,79 @@ function stageToProgress(stage: InstallStage): number { } } +/** + * 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; + 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, }: { @@ -126,16 +199,46 @@ export function PluginInstallTaskProvider({ const [tasks, setTasks] = useState([]); const [selectedTaskId, setSelectedTaskId] = useState(null); const intervalRefs = useRef>(new Map()); + const syncIntervalRef = useRef(null); + const onTaskCompleteCallbacks = useRef>( + new Set(), + ); + // Track tasks that have already been marked as completed/failed (to avoid duplicate callbacks) + const notifiedTaskIds = useRef>(new Set()); + // Track task IDs that the user has explicitly dismissed + const dismissedTaskIds = useRef>(new Set()); // Cleanup all intervals on unmount useEffect(() => { return () => { - intervalRefs.current.forEach((interval) => clearInterval(interval)); + 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) @@ -143,7 +246,10 @@ export function PluginInstallTaskProvider({ 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; + const md = (res.task_context?.metadata ?? {}) as Record< + string, + unknown + >; // Extract progress fields from metadata const num = (v: unknown) => (typeof v === 'number' ? v : undefined); @@ -171,7 +277,8 @@ export function PluginInstallTaskProvider({ depsInstalled: depsInstalled ?? t.depsInstalled, depsRemaining: depsRemaining ?? t.depsRemaining, currentDep: currentDep ?? t.currentDep, - depsDownloadedSize: depsDownloadedSize ?? t.depsDownloadedSize, + depsDownloadedSize: + depsDownloadedSize ?? t.depsDownloadedSize, depsSpeed: depsSpeed ?? t.depsSpeed, }; @@ -184,6 +291,7 @@ export function PluginInstallTaskProvider({ } if (exception) { + notifyTaskComplete(taskId, false); return { ...t, stage: InstallStage.ERROR, @@ -194,6 +302,7 @@ export function PluginInstallTaskProvider({ }; } + notifyTaskComplete(taskId, true); return { ...t, stage: InstallStage.DONE, @@ -233,9 +342,76 @@ export function PluginInstallTaskProvider({ 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; @@ -244,6 +420,10 @@ export function PluginInstallTaskProvider({ 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, @@ -256,7 +436,11 @@ export function PluginInstallTaskProvider({ currentAction: '', }; - setTasks((prev) => [...prev, newTask]); + setTasks((prev) => { + // Avoid duplicate + if (prev.some((t) => t.taskId === params.taskId)) return prev; + return [...prev, newTask]; + }); pollTask(taskKey, params.taskId); }, [pollTask], @@ -268,15 +452,28 @@ export function PluginInstallTaskProvider({ clearInterval(iv); intervalRefs.current.delete(id); } - setTasks((prev) => prev.filter((t) => t.id !== 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) => - prev.filter( + 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 ( @@ -288,6 +485,8 @@ export function PluginInstallTaskProvider({ clearCompletedTasks, selectedTaskId, setSelectedTaskId, + registerOnTaskComplete, + unregisterOnTaskComplete, }} > {children} diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx index 33c88605..86d8c6f0 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx @@ -5,9 +5,9 @@ import { useTranslation } from 'react-i18next'; import { Progress } from '@/components/ui/progress'; import { Download, - FolderOpen, Package, - FlaskConical, + Settings, + Rocket, CheckCircle2, XCircle, Loader2, @@ -30,9 +30,9 @@ import { cn } from '@/lib/utils'; const STAGE_ICONS: Record = { [InstallStage.DOWNLOADING]: Download, - [InstallStage.EXTRACTING]: FolderOpen, [InstallStage.INSTALLING_DEPS]: Package, - [InstallStage.TESTING]: FlaskConical, + [InstallStage.INITIALIZING]: Settings, + [InstallStage.LAUNCHING]: Rocket, [InstallStage.DONE]: CheckCircle2, [InstallStage.ERROR]: XCircle, }; @@ -56,12 +56,12 @@ function TaskQueueItem({ switch (task.stage) { case InstallStage.DOWNLOADING: return t('plugins.installProgress.downloading'); - case InstallStage.EXTRACTING: - return t('plugins.installProgress.extracting'); case InstallStage.INSTALLING_DEPS: return t('plugins.installProgress.installingDeps'); - case InstallStage.TESTING: - return t('plugins.installProgress.testing'); + 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: @@ -79,9 +79,12 @@ function TaskQueueItem({
{isRunning ? ( @@ -138,10 +141,7 @@ export default function PluginInstallTaskQueue() { return ( - diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index cbf68ee1..502c6c53 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -103,7 +103,12 @@ function PluginListView() { pendingPluginInstallAction, setPendingPluginInstallAction, } = useSidebarData(); - const { addTask, setSelectedTaskId } = usePluginInstallTasks(); + const { + addTask, + setSelectedTaskId, + registerOnTaskComplete, + unregisterOnTaskComplete, + } = usePluginInstallTasks(); const [modalOpen, setModalOpen] = useState(false); const [installSource, setInstallSource] = useState('local'); const [installInfo] = useState>({}); // eslint-disable-line @typescript-eslint/no-explicit-any @@ -160,27 +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) { - // Error is shown via task queue progress dialog - } else { - if (!alreadySuccess) { - toast.success(t('plugins.installSuccess')); - alreadySuccess = true; - } - 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(null); @@ -323,7 +321,6 @@ function PluginListView() { setSelectedTaskId(taskKey); resetGithubState(); setModalOpen(false); - watchTask(taskId); }) .catch((err) => { setInstallError(err.msg); @@ -345,7 +342,6 @@ function PluginListView() { }); setSelectedTaskId(taskKey); setModalOpen(false); - watchTask(taskId); }) .catch((err) => { setInstallError(err.msg); diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 413e11c2..2819133a 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -306,6 +306,7 @@ export interface AsyncTask { id: number; kind: string; name: string; + label: string; task_type: string; // system or user runtime: AsyncTaskRuntimeInfo; task_context: AsyncTaskTaskContext; diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 95193180..fb8648b5 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -715,8 +715,15 @@ export class BackendClient extends BaseHttpClient { return this.put('/api/v1/system/wizard/progress', progress); } - public getAsyncTasks(): Promise { - return this.get('/api/v1/system/tasks'); + public getAsyncTasks(params?: { + type?: string; + kind?: string; + }): Promise { + 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 { diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index bf15fb60..86b08376 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -498,14 +498,15 @@ const enUS = { titleGeneric: 'Plugin Installation', overallProgress: 'Overall Progress', downloading: 'Downloading Plugin', - extracting: 'Extracting Plugin', installingDeps: 'Installing Dependencies', - testing: 'Testing Plugin', + 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', + depsProgress: + '{{installed}}/{{total}} installed · {{remaining}} remaining', installComplete: 'Plugin installed successfully', dismiss: 'Dismiss', background: 'Run in Background', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index bb3205f3..484c57df 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -508,14 +508,15 @@ const esES = { titleGeneric: 'Instalación de Plugin', overallProgress: 'Progreso general', downloading: 'Descargando Plugin', - extracting: 'Extrayendo Plugin', installingDeps: 'Instalando dependencias', - testing: 'Probando Plugin', + 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', + depsProgress: + '{{installed}}/{{total}} instaladas · {{remaining}} restantes', installComplete: 'Plugin instalado correctamente', dismiss: 'Descartar', background: 'Ejecutar en segundo plano', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 0c45a30c..a9b00e5b 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -498,14 +498,15 @@ titleGeneric: 'プラグインのインストール', overallProgress: '全体の進捗', downloading: 'プラグインをダウンロード中', - extracting: 'プラグインを展開中', installingDeps: '依存関係をインストール中', - testing: 'プラグインをテスト中', + initializing: '設定を初期化中', + launching: 'プラグインを起動中', completed: '完了', failed: '失敗', downloadSize: 'パッケージサイズ: {{size}}', depsInfo: '{{count}} 個の依存関係をインストール', - depsProgress: '{{installed}}/{{total}} インストール済み · 残り {{remaining}} 個', + depsProgress: + '{{installed}}/{{total}} インストール済み · 残り {{remaining}} 個', installComplete: 'プラグインのインストール完了', dismiss: '閉じる', background: 'バックグラウンドで実行', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index a88da57d..ae222eb8 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -490,9 +490,9 @@ const thTH = { titleGeneric: 'การติดตั้งปลั๊กอิน', overallProgress: 'ความคืบหน้าโดยรวม', downloading: 'กำลังดาวน์โหลดปลั๊กอิน', - extracting: 'กำลังแตกไฟล์ปลั๊กอิน', installingDeps: 'กำลังติดตั้งแพ็กเกจ', - testing: 'กำลังทดสอบปลั๊กอิน', + initializing: 'กำลังเริ่มต้นการตั้งค่า', + launching: 'กำลังเปิดใช้งานปลั๊กอิน', completed: 'เสร็จสมบูรณ์', failed: 'ล้มเหลว', downloadSize: 'ขนาดแพ็กเกจ: {{size}}', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 2daa9618..4e9a4e23 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -502,9 +502,9 @@ const viVN = { titleGeneric: 'Cài đặt Plugin', overallProgress: 'Tiến độ tổng thể', downloading: 'Đang tải Plugin', - extracting: 'Đang giải nén Plugin', installingDeps: 'Đang cài đặt phụ thuộc', - testing: 'Đang kiểm tra Plugin', + 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}}', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index eb2e98ca..5cb9b318 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -474,9 +474,9 @@ const zhHans = { titleGeneric: '插件安装', overallProgress: '总体进度', downloading: '下载插件', - extracting: '解压插件', installingDeps: '安装依赖', - testing: '测试插件', + initializing: '初始化配置', + launching: '启动插件', completed: '已完成', failed: '安装失败', downloadSize: '包大小: {{size}}', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index a710ce13..f5140a42 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -467,9 +467,9 @@ const zhHant = { titleGeneric: '外掛安裝', overallProgress: '整體進度', downloading: '下載外掛', - extracting: '解壓外掛', installingDeps: '安裝依賴', - testing: '測試外掛', + initializing: '初始化設定', + launching: '啟動外掛', completed: '已完成', failed: '安裝失敗', downloadSize: '檔案大小: {{size}}',