mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-25 09:55:25 +08:00
feat(web): improve marketplace install workflows
This commit is contained in:
+72
-52
@@ -15,6 +15,9 @@ import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
ShieldCheck,
|
||||
Rocket,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -24,25 +27,51 @@ import {
|
||||
} from './PluginInstallTaskContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STAGES: {
|
||||
type StageConfig = {
|
||||
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',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
function getStageIndex(stage: InstallStage): number {
|
||||
const idx = STAGES.findIndex((s) => s.key === stage);
|
||||
function getStages(task: PluginInstallTask): StageConfig[] {
|
||||
return [
|
||||
...(task.operation === 'upgrade'
|
||||
? [
|
||||
{
|
||||
key: InstallStage.CHECKING,
|
||||
icon: RefreshCcw,
|
||||
i18nKey: 'plugins.installProgress.checkingUpdate',
|
||||
} as StageConfig,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: InstallStage.DOWNLOADING,
|
||||
icon: Download,
|
||||
i18nKey: 'plugins.installProgress.downloading',
|
||||
},
|
||||
{
|
||||
key: InstallStage.VALIDATING,
|
||||
icon: ShieldCheck,
|
||||
i18nKey: 'plugins.installProgress.validating',
|
||||
},
|
||||
{
|
||||
key: InstallStage.INSTALLING_DEPS,
|
||||
icon: Package,
|
||||
i18nKey:
|
||||
task.operation === 'upgrade'
|
||||
? 'plugins.installProgress.applyingUpdate'
|
||||
: 'plugins.installProgress.installingDeps',
|
||||
},
|
||||
{
|
||||
key: InstallStage.ACTIVATING,
|
||||
icon: Rocket,
|
||||
i18nKey: 'plugins.installProgress.activating',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getStageIndex(stages: StageConfig[], stage: InstallStage): number {
|
||||
const idx = stages.findIndex((item) => item.key === stage);
|
||||
return idx >= 0 ? idx : -1;
|
||||
}
|
||||
|
||||
@@ -169,9 +198,13 @@ function formatSpeed(bytesPerSec: number): string {
|
||||
function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const currentStageIndex = getStageIndex(task.stage);
|
||||
const stages = getStages(task);
|
||||
const isDone = task.stage === InstallStage.DONE;
|
||||
const isError = task.stage === InstallStage.ERROR;
|
||||
const currentStageIndex = getStageIndex(
|
||||
stages,
|
||||
isError ? (task.failedStage ?? InstallStage.INSTALLING_DEPS) : task.stage,
|
||||
);
|
||||
|
||||
// MCP / Skill don't have the plugin's download + dependency-install stages;
|
||||
// show a single "installing → done/failed" row instead of plugin steps.
|
||||
@@ -334,43 +367,22 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
||||
isError={isError}
|
||||
detail={isError ? task.error : undefined}
|
||||
/>
|
||||
) : isDone ? (
|
||||
/* When done: show all stages with completed style */
|
||||
STAGES.map((stageConfig) => (
|
||||
) : (
|
||||
stages.map((stageConfig, index) => (
|
||||
<StageRow
|
||||
key={stageConfig.key}
|
||||
icon={stageConfig.icon}
|
||||
label={t(stageConfig.i18nKey)}
|
||||
isActive={false}
|
||||
isCompleted={true}
|
||||
isError={false}
|
||||
detail={getStageDetail(stageConfig.key, true)}
|
||||
isActive={!isDone && index === currentStageIndex}
|
||||
isCompleted={isDone || index < currentStageIndex}
|
||||
isError={isError && index === currentStageIndex}
|
||||
detail={
|
||||
isDone || index === currentStageIndex
|
||||
? getStageDetail(stageConfig.key, isDone)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : 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>
|
||||
|
||||
@@ -379,7 +391,9 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900">
|
||||
<CheckCircle2 className="w-5 h-5 shrink-0 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm text-green-700 dark:text-green-300 font-medium break-words">
|
||||
{t('plugins.installProgress.installComplete')}
|
||||
{task.operation === 'upgrade'
|
||||
? t('plugins.installProgress.updateComplete')
|
||||
: t('plugins.installProgress.installComplete')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -402,6 +416,8 @@ export default function PluginInstallProgressDialog() {
|
||||
usePluginInstallTasks();
|
||||
|
||||
const selectedTask = tasks.find((t) => t.id === selectedTaskId) || null;
|
||||
const TitleIcon =
|
||||
selectedTask?.operation === 'upgrade' ? RefreshCcw : Download;
|
||||
const open = !!selectedTask;
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -428,12 +444,16 @@ export default function PluginInstallProgressDialog() {
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-start gap-3">
|
||||
<Download className="size-5 shrink-0 mt-0.5" />
|
||||
<TitleIcon className="size-5 shrink-0 mt-0.5" />
|
||||
<span className="break-words">
|
||||
{selectedTask
|
||||
? t('plugins.installProgress.title', {
|
||||
name: selectedTask.pluginName,
|
||||
})
|
||||
? selectedTask.operation === 'upgrade'
|
||||
? t('plugins.installProgress.updateTitle', {
|
||||
name: selectedTask.pluginName,
|
||||
})
|
||||
: t('plugins.installProgress.title', {
|
||||
name: selectedTask.pluginName,
|
||||
})
|
||||
: t('plugins.installProgress.titleGeneric')}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
|
||||
+87
-23
@@ -13,20 +13,25 @@ import { AsyncTask } from '@/app/infra/entities/api';
|
||||
* Installation stages mapped from backend current_action strings.
|
||||
*/
|
||||
export enum InstallStage {
|
||||
CHECKING = 'checking',
|
||||
DOWNLOADING = 'downloading',
|
||||
VALIDATING = 'validating',
|
||||
INSTALLING_DEPS = 'installing_deps',
|
||||
INITIALIZING = 'initializing',
|
||||
LAUNCHING = 'launching',
|
||||
ACTIVATING = 'activating',
|
||||
DONE = 'done',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export type PluginTaskOperation = 'install' | 'upgrade';
|
||||
|
||||
export interface PluginInstallTask {
|
||||
id: string; // unique key: `${source}-${taskId}`
|
||||
taskId: number; // backend async task id
|
||||
pluginName: string; // display name
|
||||
source: 'github' | 'marketplace' | 'local';
|
||||
operation: PluginTaskOperation;
|
||||
stage: InstallStage;
|
||||
failedStage?: InstallStage;
|
||||
overallProgress: number; // 0-100
|
||||
extensionType: 'plugin' | 'mcp' | 'skill'; // type of extension being installed
|
||||
fileSize?: number; // bytes, if known
|
||||
@@ -50,6 +55,7 @@ type OnTaskCompleteCallback = (
|
||||
taskId: number,
|
||||
success: boolean,
|
||||
error?: string,
|
||||
operation?: PluginTaskOperation,
|
||||
) => void;
|
||||
|
||||
interface PluginInstallTaskContextValue {
|
||||
@@ -60,6 +66,7 @@ interface PluginInstallTaskContextValue {
|
||||
source: 'github' | 'marketplace' | 'local';
|
||||
extensionType: 'plugin' | 'mcp' | 'skill';
|
||||
fileSize?: number;
|
||||
operation?: PluginTaskOperation;
|
||||
}) => void;
|
||||
removeTask: (id: string) => void;
|
||||
clearCompletedTasks: () => void;
|
||||
@@ -89,13 +96,30 @@ export function usePluginInstallTasks() {
|
||||
function mapActionToStage(action: string): InstallStage {
|
||||
if (!action) return InstallStage.DOWNLOADING;
|
||||
const lower = action.toLowerCase();
|
||||
if (lower.includes('check')) return InstallStage.CHECKING;
|
||||
if (lower.includes('download')) return InstallStage.DOWNLOADING;
|
||||
if (lower.includes('validat') || lower.includes('inspect'))
|
||||
return InstallStage.VALIDATING;
|
||||
if (lower.includes('dependencies') || lower.includes('requirements'))
|
||||
return InstallStage.INSTALLING_DEPS;
|
||||
if (lower.includes('initializ') || lower.includes('setting'))
|
||||
if (
|
||||
lower.includes('preparing') ||
|
||||
lower.includes('applying') ||
|
||||
lower.includes('installing')
|
||||
)
|
||||
return InstallStage.INSTALLING_DEPS;
|
||||
if (lower.includes('launch')) return InstallStage.INSTALLING_DEPS;
|
||||
if (lower.includes('installed') || lower.includes('complete'))
|
||||
if (
|
||||
lower.includes('initializ') ||
|
||||
lower.includes('launch') ||
|
||||
lower.includes('refresh') ||
|
||||
lower.includes('waiting')
|
||||
)
|
||||
return InstallStage.ACTIVATING;
|
||||
if (
|
||||
lower.includes('installed') ||
|
||||
lower.includes('updated') ||
|
||||
lower.includes('complete')
|
||||
)
|
||||
return InstallStage.DONE;
|
||||
return InstallStage.DOWNLOADING;
|
||||
}
|
||||
@@ -105,13 +129,15 @@ function mapActionToStage(action: string): InstallStage {
|
||||
*/
|
||||
function stageToProgress(stage: InstallStage): number {
|
||||
switch (stage) {
|
||||
case InstallStage.CHECKING:
|
||||
return 5;
|
||||
case InstallStage.DOWNLOADING:
|
||||
return 10;
|
||||
return 18;
|
||||
case InstallStage.VALIDATING:
|
||||
return 35;
|
||||
case InstallStage.INSTALLING_DEPS:
|
||||
return 70;
|
||||
case InstallStage.INITIALIZING:
|
||||
return 70;
|
||||
case InstallStage.LAUNCHING:
|
||||
return 60;
|
||||
case InstallStage.ACTIVATING:
|
||||
return 85;
|
||||
case InstallStage.DONE:
|
||||
return 100;
|
||||
@@ -130,15 +156,27 @@ function extractSourceFromName(
|
||||
): 'github' | 'marketplace' | 'local' {
|
||||
if (name.includes('github')) return 'github';
|
||||
if (name.includes('marketplace')) return 'marketplace';
|
||||
if (name.startsWith('plugin-upgrade-')) return 'marketplace';
|
||||
return 'local';
|
||||
}
|
||||
|
||||
export function pluginTaskKey(
|
||||
taskId: number,
|
||||
source: 'github' | 'marketplace' | 'local',
|
||||
operation: PluginTaskOperation = 'install',
|
||||
) {
|
||||
return operation === 'upgrade'
|
||||
? `upgrade-${source}-${taskId}`
|
||||
: `${source}-${taskId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a backend task name is a plugin install task.
|
||||
*/
|
||||
function isPluginInstallTask(name: string): boolean {
|
||||
return (
|
||||
name.startsWith('plugin-install-') ||
|
||||
name.startsWith('plugin-upgrade-') ||
|
||||
name.startsWith('mcp-install-') ||
|
||||
name.startsWith('skill-install-')
|
||||
);
|
||||
@@ -151,6 +189,10 @@ 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 operation: PluginTaskOperation =
|
||||
md.operation === 'upgrade' || task.name.startsWith('plugin-upgrade-')
|
||||
? 'upgrade'
|
||||
: 'install';
|
||||
const done = task.runtime.done;
|
||||
const exception = task.runtime.exception;
|
||||
|
||||
@@ -160,11 +202,13 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
||||
let stage: InstallStage;
|
||||
let overallProgress: number;
|
||||
let error: string | undefined;
|
||||
let failedStage: InstallStage | undefined;
|
||||
|
||||
if (done) {
|
||||
if (exception) {
|
||||
stage = InstallStage.ERROR;
|
||||
overallProgress = 0;
|
||||
failedStage = mapActionToStage(action);
|
||||
overallProgress = stageToProgress(failedStage);
|
||||
error = exception;
|
||||
} else {
|
||||
stage = InstallStage.DONE;
|
||||
@@ -172,7 +216,10 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
||||
}
|
||||
} else {
|
||||
stage = mapActionToStage(action);
|
||||
overallProgress = Math.min(95, stageToProgress(stage));
|
||||
overallProgress = Math.min(
|
||||
95,
|
||||
num(md.progress_percent) ?? stageToProgress(stage),
|
||||
);
|
||||
}
|
||||
|
||||
const pluginName = str(md.plugin_name) || task.label || `${source} extension`;
|
||||
@@ -185,12 +232,14 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${source}-${task.id}`,
|
||||
id: pluginTaskKey(task.id, source, operation),
|
||||
taskId: task.id,
|
||||
pluginName,
|
||||
source,
|
||||
operation,
|
||||
extensionType,
|
||||
stage,
|
||||
failedStage,
|
||||
overallProgress,
|
||||
downloadCurrent: num(md.download_current),
|
||||
downloadTotal: num(md.download_total),
|
||||
@@ -202,7 +251,7 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
||||
depsDownloadedSize: num(md.deps_downloaded_size),
|
||||
depsSpeed: num(md.deps_speed),
|
||||
error,
|
||||
startedAt: Date.now(),
|
||||
startedAt: task.created_at ? task.created_at * 1000 : Date.now(),
|
||||
currentAction: action,
|
||||
};
|
||||
}
|
||||
@@ -244,11 +293,16 @@ export function PluginInstallTaskProvider({
|
||||
}, []);
|
||||
|
||||
const notifyTaskComplete = useCallback(
|
||||
(taskId: number, success: boolean, error?: string) => {
|
||||
(
|
||||
taskId: number,
|
||||
success: boolean,
|
||||
error?: string,
|
||||
operation?: PluginTaskOperation,
|
||||
) => {
|
||||
if (notifiedTaskIds.current.has(taskId)) return;
|
||||
notifiedTaskIds.current.add(taskId);
|
||||
onTaskCompleteCallbacks.current.forEach((cb) => {
|
||||
cb(taskId, success, error);
|
||||
cb(taskId, success, error, operation);
|
||||
});
|
||||
},
|
||||
[],
|
||||
@@ -284,6 +338,7 @@ export function PluginInstallTaskProvider({
|
||||
const currentDep = str(md.current_dep);
|
||||
const depsDownloadedSize = num(md.deps_downloaded_size);
|
||||
const depsSpeed = num(md.deps_speed);
|
||||
const reportedProgress = num(md.progress_percent);
|
||||
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
@@ -311,18 +366,21 @@ export function PluginInstallTaskProvider({
|
||||
}
|
||||
|
||||
if (exception) {
|
||||
notifyTaskComplete(taskId, false, exception);
|
||||
notifyTaskComplete(taskId, false, exception, t.operation);
|
||||
return {
|
||||
...t,
|
||||
stage: InstallStage.ERROR,
|
||||
failedStage: mapActionToStage(action),
|
||||
error: exception,
|
||||
overallProgress: 0,
|
||||
overallProgress: stageToProgress(
|
||||
mapActionToStage(action),
|
||||
),
|
||||
currentAction: action,
|
||||
...progressFields,
|
||||
};
|
||||
}
|
||||
|
||||
notifyTaskComplete(taskId, true);
|
||||
notifyTaskComplete(taskId, true, undefined, t.operation);
|
||||
return {
|
||||
...t,
|
||||
stage: InstallStage.DONE,
|
||||
@@ -342,7 +400,7 @@ export function PluginInstallTaskProvider({
|
||||
);
|
||||
const progress = Math.min(
|
||||
95,
|
||||
baseProgress + withinStageIncrement,
|
||||
reportedProgress ?? baseProgress + withinStageIncrement,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -458,8 +516,10 @@ export function PluginInstallTaskProvider({
|
||||
source: 'github' | 'marketplace' | 'local';
|
||||
extensionType: 'plugin' | 'mcp' | 'skill';
|
||||
fileSize?: number;
|
||||
operation?: PluginTaskOperation;
|
||||
}) => {
|
||||
const taskKey = `${params.source}-${params.taskId}`;
|
||||
const operation = params.operation ?? 'install';
|
||||
const taskKey = pluginTaskKey(params.taskId, params.source, operation);
|
||||
|
||||
// Remove from dismissed set if re-added
|
||||
dismissedTaskIds.current.delete(params.taskId);
|
||||
@@ -469,9 +529,13 @@ export function PluginInstallTaskProvider({
|
||||
taskId: params.taskId,
|
||||
pluginName: params.pluginName,
|
||||
source: params.source,
|
||||
operation,
|
||||
extensionType: params.extensionType,
|
||||
stage: InstallStage.DOWNLOADING,
|
||||
overallProgress: 5,
|
||||
stage:
|
||||
operation === 'upgrade'
|
||||
? InstallStage.CHECKING
|
||||
: InstallStage.DOWNLOADING,
|
||||
overallProgress: operation === 'upgrade' ? 3 : 5,
|
||||
fileSize: params.fileSize,
|
||||
startedAt: Date.now(),
|
||||
currentAction: '',
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
Puzzle,
|
||||
Server,
|
||||
Sparkles,
|
||||
RefreshCcw,
|
||||
ShieldCheck,
|
||||
Rocket,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -28,8 +31,11 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STAGE_ICONS: Record<string, React.ElementType> = {
|
||||
[InstallStage.CHECKING]: RefreshCcw,
|
||||
[InstallStage.DOWNLOADING]: Download,
|
||||
[InstallStage.VALIDATING]: ShieldCheck,
|
||||
[InstallStage.INSTALLING_DEPS]: Package,
|
||||
[InstallStage.ACTIVATING]: Rocket,
|
||||
[InstallStage.DONE]: CheckCircle2,
|
||||
[InstallStage.ERROR]: XCircle,
|
||||
};
|
||||
@@ -79,6 +85,9 @@ function TaskQueueItem({
|
||||
};
|
||||
|
||||
const getInstallCompleteMessage = () => {
|
||||
if (task.operation === 'upgrade') {
|
||||
return t('plugins.installProgress.updateComplete');
|
||||
}
|
||||
switch (task.extensionType) {
|
||||
case 'mcp':
|
||||
return t('plugins.installProgress.installCompleteMCP');
|
||||
@@ -91,10 +100,18 @@ function TaskQueueItem({
|
||||
|
||||
const stageLabel = (() => {
|
||||
switch (task.stage) {
|
||||
case InstallStage.CHECKING:
|
||||
return t('plugins.installProgress.checkingUpdate');
|
||||
case InstallStage.DOWNLOADING:
|
||||
return t('plugins.installProgress.downloading');
|
||||
case InstallStage.VALIDATING:
|
||||
return t('plugins.installProgress.validating');
|
||||
case InstallStage.INSTALLING_DEPS:
|
||||
return t('plugins.installProgress.installingDeps');
|
||||
return task.operation === 'upgrade'
|
||||
? t('plugins.installProgress.applyingUpdate')
|
||||
: t('plugins.installProgress.installingDeps');
|
||||
case InstallStage.ACTIVATING:
|
||||
return t('plugins.installProgress.activating');
|
||||
case InstallStage.DONE:
|
||||
return isDone
|
||||
? getInstallCompleteMessage()
|
||||
|
||||
@@ -2,7 +2,11 @@ export {
|
||||
PluginInstallTaskProvider,
|
||||
usePluginInstallTasks,
|
||||
InstallStage,
|
||||
pluginTaskKey,
|
||||
} from './PluginInstallTaskContext';
|
||||
export type {
|
||||
PluginInstallTask,
|
||||
PluginTaskOperation,
|
||||
} from './PluginInstallTaskContext';
|
||||
export type { PluginInstallTask } from './PluginInstallTaskContext';
|
||||
export { default as PluginInstallProgressDialog } from './PluginInstallProgressDialog';
|
||||
export { default as PluginInstallTaskQueue } from './PluginInstallTaskQueue';
|
||||
|
||||
Reference in New Issue
Block a user