mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
feat(plugins): show installed state in marketplace and stream install tasks
- report download progress (bytes, speed) and human-readable install stages from the plugin runtime connector via the install task context - add a marketplace installed-state helper so market cards reflect whether a plugin is already installed - surface install progress / queue state in the plugin install-task UI - extend BackendClient and API entities with install-task status - i18n for the new marketplace / install strings
This commit is contained in:
@@ -87,8 +87,10 @@ async def _read_httpx_response_limited(
|
|||||||
response: httpx.Response,
|
response: httpx.Response,
|
||||||
*,
|
*,
|
||||||
max_bytes: int,
|
max_bytes: int,
|
||||||
|
task_context: taskmgr.TaskContext | None = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
content_length = response.headers.get('content-length')
|
content_length = response.headers.get('content-length')
|
||||||
|
declared_size: int | None = None
|
||||||
if content_length is not None:
|
if content_length is not None:
|
||||||
try:
|
try:
|
||||||
declared_size = int(content_length)
|
declared_size = int(content_length)
|
||||||
@@ -97,11 +99,23 @@ async def _read_httpx_response_limited(
|
|||||||
if declared_size is not None and declared_size > max_bytes:
|
if declared_size is not None and declared_size > max_bytes:
|
||||||
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
|
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
|
||||||
|
|
||||||
|
if task_context is not None and declared_size is not None:
|
||||||
|
task_context.metadata['download_total'] = declared_size
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
body = bytearray()
|
body = bytearray()
|
||||||
async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
|
async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
|
||||||
body.extend(chunk)
|
body.extend(chunk)
|
||||||
if len(body) > max_bytes:
|
if len(body) > max_bytes:
|
||||||
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
|
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
|
||||||
|
if task_context is not None:
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
task_context.metadata.update(
|
||||||
|
{
|
||||||
|
'download_current': len(body),
|
||||||
|
'download_speed': len(body) / elapsed if elapsed > 0 else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
return bytes(body)
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
@@ -111,6 +125,7 @@ async def _marketplace_get(
|
|||||||
*,
|
*,
|
||||||
max_bytes: int,
|
max_bytes: int,
|
||||||
allow_not_found: bool = False,
|
allow_not_found: bool = False,
|
||||||
|
task_context: taskmgr.TaskContext | None = None,
|
||||||
) -> tuple[int, bytes]:
|
) -> tuple[int, bytes]:
|
||||||
async with client.stream('GET', url) as response:
|
async with client.stream('GET', url) as response:
|
||||||
if allow_not_found and response.status_code == 404:
|
if allow_not_found and response.status_code == 404:
|
||||||
@@ -119,6 +134,7 @@ async def _marketplace_get(
|
|||||||
return response.status_code, await _read_httpx_response_limited(
|
return response.status_code, await _read_httpx_response_limited(
|
||||||
response,
|
response,
|
||||||
max_bytes=max_bytes,
|
max_bytes=max_bytes,
|
||||||
|
task_context=task_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1680,6 +1696,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
client,
|
client,
|
||||||
f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{latest_version}',
|
f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{latest_version}',
|
||||||
max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES,
|
max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES,
|
||||||
|
task_context=task_context,
|
||||||
)
|
)
|
||||||
return plugin_package, latest_version
|
return plugin_package, latest_version
|
||||||
|
|
||||||
@@ -1695,7 +1712,21 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
plugin_name = str(install_info.get('plugin_name') or '')
|
plugin_name = str(install_info.get('plugin_name') or '')
|
||||||
file_bytes: bytes | None
|
file_bytes: bytes | None
|
||||||
|
|
||||||
|
if task_context is not None:
|
||||||
|
# Reset per-install progress counters so a re-install of the same
|
||||||
|
# plugin does not inherit stale metadata from a previous task.
|
||||||
|
task_context.set_current_action('preparing plugin install')
|
||||||
|
task_context.metadata.update(
|
||||||
|
{
|
||||||
|
'download_total': 0,
|
||||||
|
'download_current': 0,
|
||||||
|
'download_speed': 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if install_source == PluginInstallSource.MARKETPLACE:
|
if install_source == PluginInstallSource.MARKETPLACE:
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action('downloading plugin package')
|
||||||
file_bytes, version = await self._download_marketplace_package(
|
file_bytes, version = await self._download_marketplace_package(
|
||||||
execution_context,
|
execution_context,
|
||||||
plugin_author,
|
plugin_author,
|
||||||
@@ -1719,6 +1750,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f'Unsupported plugin install source: {install_source.value}')
|
raise ValueError(f'Unsupported plugin install source: {install_source.value}')
|
||||||
|
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action('inspecting plugin package')
|
||||||
manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context)
|
manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||||
if not manifest_author or not manifest_name:
|
if not manifest_author or not manifest_name:
|
||||||
raise ValueError('Plugin package manifest identity is missing')
|
raise ValueError('Plugin package manifest identity is missing')
|
||||||
@@ -1730,8 +1763,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
if task_context is not None:
|
if task_context is not None:
|
||||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||||
|
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action('storing plugin package')
|
||||||
artifact_digest = hashlib.sha256(file_bytes).hexdigest()
|
artifact_digest = hashlib.sha256(file_bytes).hexdigest()
|
||||||
await self._store_artifact_package(execution_context, artifact_digest, file_bytes)
|
await self._store_artifact_package(execution_context, artifact_digest, file_bytes)
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action('installing plugin dependencies')
|
||||||
try:
|
try:
|
||||||
binding, previous_digest, previous_was_durable = await self._persist_installation_package(
|
binding, previous_digest, previous_was_durable = await self._persist_installation_package(
|
||||||
execution_context,
|
execution_context,
|
||||||
@@ -1749,6 +1786,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
plugin_author=plugin_author,
|
plugin_author=plugin_author,
|
||||||
plugin_name=plugin_name,
|
plugin_name=plugin_name,
|
||||||
)
|
)
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action('launching plugin')
|
||||||
await self._apply_desired_state(
|
await self._apply_desired_state(
|
||||||
PluginInstallationDesiredState(binding=binding, enabled=True),
|
PluginInstallationDesiredState(binding=binding, enabled=True),
|
||||||
artifact_package=file_bytes,
|
artifact_package=file_bytes,
|
||||||
@@ -1766,6 +1805,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
pass
|
pass
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}')
|
self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}')
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action('waiting for plugin to become ready')
|
||||||
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
|
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
|
||||||
|
|
||||||
async def upgrade_plugin(
|
async def upgrade_plugin(
|
||||||
|
|||||||
+22
-2
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Package,
|
Package,
|
||||||
|
Rocket,
|
||||||
Server,
|
Server,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -39,11 +40,27 @@ const STAGES: {
|
|||||||
icon: Package,
|
icon: Package,
|
||||||
i18nKey: 'plugins.installProgress.installingDeps',
|
i18nKey: 'plugins.installProgress.installingDeps',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: InstallStage.LAUNCHING,
|
||||||
|
icon: Rocket,
|
||||||
|
i18nKey: 'plugins.installProgress.launching',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the row that should be highlighted for a given stage.
|
||||||
|
* LAUNCHING/INITIALIZING/DONE collapse onto the launching row.
|
||||||
|
*/
|
||||||
function getStageIndex(stage: InstallStage): number {
|
function getStageIndex(stage: InstallStage): number {
|
||||||
|
if (
|
||||||
|
stage === InstallStage.LAUNCHING ||
|
||||||
|
stage === InstallStage.INITIALIZING ||
|
||||||
|
stage === InstallStage.DONE
|
||||||
|
) {
|
||||||
|
return STAGES.length - 1;
|
||||||
|
}
|
||||||
const idx = STAGES.findIndex((s) => s.key === stage);
|
const idx = STAGES.findIndex((s) => s.key === stage);
|
||||||
return idx >= 0 ? idx : -1;
|
return idx >= 0 ? idx : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
@@ -169,9 +186,12 @@ function formatSpeed(bytesPerSec: number): string {
|
|||||||
function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const currentStageIndex = getStageIndex(task.stage);
|
|
||||||
const isDone = task.stage === InstallStage.DONE;
|
const isDone = task.stage === InstallStage.DONE;
|
||||||
const isError = task.stage === InstallStage.ERROR;
|
const isError = task.stage === InstallStage.ERROR;
|
||||||
|
// When a task fails, `stage` becomes ERROR — fall back to the furthest
|
||||||
|
// stage it actually reached so the failed phase is still displayed.
|
||||||
|
const displayStage = isError && task.lastStage ? task.lastStage : task.stage;
|
||||||
|
const currentStageIndex = getStageIndex(displayStage);
|
||||||
|
|
||||||
// MCP / Skill don't have the plugin's download + dependency-install stages;
|
// MCP / Skill don't have the plugin's download + dependency-install stages;
|
||||||
// show a single "installing → done/failed" row instead of plugin steps.
|
// show a single "installing → done/failed" row instead of plugin steps.
|
||||||
|
|||||||
+302
-98
@@ -27,6 +27,9 @@ export interface PluginInstallTask {
|
|||||||
pluginName: string; // display name
|
pluginName: string; // display name
|
||||||
source: 'github' | 'marketplace' | 'local';
|
source: 'github' | 'marketplace' | 'local';
|
||||||
stage: InstallStage;
|
stage: InstallStage;
|
||||||
|
/** Furthest non-terminal stage reached — kept when the task fails so the
|
||||||
|
* UI can still show which phase failed. */
|
||||||
|
lastStage?: InstallStage;
|
||||||
overallProgress: number; // 0-100
|
overallProgress: number; // 0-100
|
||||||
extensionType: 'plugin' | 'mcp' | 'skill'; // type of extension being installed
|
extensionType: 'plugin' | 'mcp' | 'skill'; // type of extension being installed
|
||||||
fileSize?: number; // bytes, if known
|
fileSize?: number; // bytes, if known
|
||||||
@@ -43,6 +46,8 @@ export interface PluginInstallTask {
|
|||||||
depsSpeed?: number; // deps download speed bytes/s
|
depsSpeed?: number; // deps download speed bytes/s
|
||||||
error?: string;
|
error?: string;
|
||||||
startedAt: number; // timestamp
|
startedAt: number; // timestamp
|
||||||
|
/** Timestamp when the current stage began; used for smooth creeping. */
|
||||||
|
stageStartedAt?: number;
|
||||||
currentAction: string; // raw backend action string
|
currentAction: string; // raw backend action string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,42 +89,158 @@ export function usePluginInstallTasks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map backend `current_action` to our InstallStage.
|
* Ordered lifecycle stages. Used to enforce forward-only transitions so the
|
||||||
|
* progress bar never moves backwards while a task is running.
|
||||||
*/
|
*/
|
||||||
function mapActionToStage(action: string): InstallStage {
|
const STAGE_ORDER: InstallStage[] = [
|
||||||
if (!action) return InstallStage.DOWNLOADING;
|
InstallStage.DOWNLOADING,
|
||||||
const lower = action.toLowerCase();
|
InstallStage.INSTALLING_DEPS,
|
||||||
if (lower.includes('download')) return InstallStage.DOWNLOADING;
|
InstallStage.INITIALIZING,
|
||||||
if (lower.includes('dependencies') || lower.includes('requirements'))
|
InstallStage.LAUNCHING,
|
||||||
return InstallStage.INSTALLING_DEPS;
|
InstallStage.DONE,
|
||||||
if (lower.includes('initializ') || lower.includes('setting'))
|
];
|
||||||
return InstallStage.INSTALLING_DEPS;
|
|
||||||
if (lower.includes('launch')) return InstallStage.INSTALLING_DEPS;
|
/**
|
||||||
if (lower.includes('installed') || lower.includes('complete'))
|
* Lower bound (%) for each stage. A task's progress is never allowed to drop
|
||||||
return InstallStage.DONE;
|
* below the floor of the furthest stage it has already reached.
|
||||||
return InstallStage.DOWNLOADING;
|
*/
|
||||||
|
const STAGE_FLOOR: Record<InstallStage, number> = {
|
||||||
|
[InstallStage.DOWNLOADING]: 2,
|
||||||
|
[InstallStage.INSTALLING_DEPS]: 55,
|
||||||
|
[InstallStage.INITIALIZING]: 85,
|
||||||
|
[InstallStage.LAUNCHING]: 94,
|
||||||
|
[InstallStage.DONE]: 100,
|
||||||
|
[InstallStage.ERROR]: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Get the lower-bound percentage for a stage. */
|
||||||
|
function stageFloor(stage: InstallStage): number {
|
||||||
|
return STAGE_FLOOR[stage] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the lower bound of the stage that follows the given one. */
|
||||||
|
function nextStageFloor(stage: InstallStage): number {
|
||||||
|
const idx = STAGE_ORDER.indexOf(stage);
|
||||||
|
const next = idx >= 0 ? STAGE_ORDER[idx + 1] : undefined;
|
||||||
|
return next ? stageFloor(next) : 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return whichever stage is further along in the lifecycle. */
|
||||||
|
function maxStage(current: InstallStage, incoming: InstallStage): InstallStage {
|
||||||
|
const currentIdx = STAGE_ORDER.indexOf(current);
|
||||||
|
const incomingIdx = STAGE_ORDER.indexOf(incoming);
|
||||||
|
if (currentIdx === -1) return incoming;
|
||||||
|
if (incomingIdx === -1) return current;
|
||||||
|
return incomingIdx >= currentIdx ? incoming : current;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get overall progress percentage from a stage.
|
* Map backend `current_action` to our InstallStage.
|
||||||
|
*
|
||||||
|
* Unknown / transitional actions must NOT map back to an earlier stage,
|
||||||
|
* otherwise the bar would jump backwards mid-install.
|
||||||
*/
|
*/
|
||||||
function stageToProgress(stage: InstallStage): number {
|
function mapActionToStage(action: string): InstallStage {
|
||||||
switch (stage) {
|
const lower = (action || '').toLowerCase();
|
||||||
case InstallStage.DOWNLOADING:
|
if (!lower) return InstallStage.DOWNLOADING;
|
||||||
return 10;
|
|
||||||
case InstallStage.INSTALLING_DEPS:
|
// "preparing"/"resolving" happen before any bytes land on disk.
|
||||||
return 70;
|
if (lower.includes('prepar') || lower.includes('resolv'))
|
||||||
case InstallStage.INITIALIZING:
|
return InstallStage.DOWNLOADING;
|
||||||
return 70;
|
|
||||||
case InstallStage.LAUNCHING:
|
if (lower.includes('download') && !lower.includes('dependenc'))
|
||||||
return 85;
|
return InstallStage.DOWNLOADING;
|
||||||
case InstallStage.DONE:
|
|
||||||
return 100;
|
// Activation / readiness tail phase — its own slice of the bar.
|
||||||
case InstallStage.ERROR:
|
if (
|
||||||
return 0;
|
lower.includes('launch') ||
|
||||||
default:
|
lower.includes('start') ||
|
||||||
return 0;
|
lower.includes('wait') ||
|
||||||
|
lower.includes('ready') ||
|
||||||
|
lower.includes('initializ')
|
||||||
|
) {
|
||||||
|
return InstallStage.LAUNCHING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dependency installation and package finalization.
|
||||||
|
if (
|
||||||
|
lower.includes('dependenc') ||
|
||||||
|
lower.includes('requirements') ||
|
||||||
|
lower.includes('parsing') ||
|
||||||
|
lower.includes('extract') ||
|
||||||
|
lower.includes('inspect') ||
|
||||||
|
lower.includes('persist') ||
|
||||||
|
lower.includes('stor') ||
|
||||||
|
lower.includes('install') ||
|
||||||
|
lower.includes('setting')
|
||||||
|
) {
|
||||||
|
return InstallStage.INSTALLING_DEPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown transitional actions belong to the busy middle of the install.
|
||||||
|
return InstallStage.INSTALLING_DEPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time-based creep so the bar keeps moving when no counters exist.
|
||||||
|
*
|
||||||
|
* Uses an asymptote so the increment decelerates as it approaches the stage
|
||||||
|
* ceiling — the bar always feels alive but never overshoots into the next
|
||||||
|
* stage's range.
|
||||||
|
*/
|
||||||
|
function creep(stageStartedAt: number, span: number): number {
|
||||||
|
if (span <= 0) return 0;
|
||||||
|
const elapsed = (Date.now() - stageStartedAt) / 1000;
|
||||||
|
// Approaching `span` asymptotically: after ~60s we are ~86% of the span.
|
||||||
|
const ratio = 1 - Math.exp(-elapsed / 30);
|
||||||
|
return span * ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a progress value for the current stage.
|
||||||
|
*
|
||||||
|
* Real byte / dependency counters drive the value when available; otherwise
|
||||||
|
* the value creeps forward slowly based on elapsed time. Callers are expected
|
||||||
|
* to combine the result with the previous value via `Math.max` so it is
|
||||||
|
* monotonic.
|
||||||
|
*/
|
||||||
|
function computeStageProgress(
|
||||||
|
task: PluginInstallTask,
|
||||||
|
stage: InstallStage,
|
||||||
|
): number {
|
||||||
|
const floor = stageFloor(stage);
|
||||||
|
const ceiling = Math.max(floor, nextStageFloor(stage) - 1);
|
||||||
|
// Creep from when this stage began so a stage change restarts the ramp
|
||||||
|
// instead of inheriting the previous stage's elapsed time.
|
||||||
|
const stageStartedAt = task.stageStartedAt ?? task.startedAt;
|
||||||
|
const creepValue = Math.min(
|
||||||
|
ceiling,
|
||||||
|
floor + creep(stageStartedAt, ceiling - floor),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stage === InstallStage.DOWNLOADING) {
|
||||||
|
const total = task.downloadTotal ?? task.fileSize;
|
||||||
|
const current = task.downloadCurrent;
|
||||||
|
if (total && total > 0 && current != null && current > 0) {
|
||||||
|
const ratio = Math.min(1, current / total);
|
||||||
|
// Never let a stale counter pull the value below the creep baseline.
|
||||||
|
return Math.max(creepValue, floor + (ceiling - floor) * ratio);
|
||||||
|
}
|
||||||
|
return creepValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === InstallStage.INSTALLING_DEPS) {
|
||||||
|
const total = task.depsTotal;
|
||||||
|
const installed = task.depsInstalled;
|
||||||
|
if (total && total > 0 && installed != null && installed > 0) {
|
||||||
|
const ratio = Math.min(1, installed / total);
|
||||||
|
// Leave headroom for the finalize/launch phase that has no counters.
|
||||||
|
return Math.max(creepValue, floor + (ceiling - floor) * ratio * 0.9);
|
||||||
|
}
|
||||||
|
return creepValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return creepValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,8 +267,14 @@ function isPluginInstallTask(name: string): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a backend AsyncTask to our PluginInstallTask.
|
* Convert a backend AsyncTask to our PluginInstallTask.
|
||||||
|
*
|
||||||
|
* `previous` (when provided) carries monotonic state forward so re-syncing
|
||||||
|
* after a refresh or a poll cannot make the progress bar move backwards.
|
||||||
*/
|
*/
|
||||||
function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
function asyncTaskToPluginInstallTask(
|
||||||
|
task: AsyncTask,
|
||||||
|
previous?: PluginInstallTask,
|
||||||
|
): PluginInstallTask {
|
||||||
const source = extractSourceFromName(task.name);
|
const source = extractSourceFromName(task.name);
|
||||||
const md = (task.task_context?.metadata ?? {}) as Record<string, unknown>;
|
const md = (task.task_context?.metadata ?? {}) as Record<string, unknown>;
|
||||||
const action = task.task_context?.current_action || '';
|
const action = task.task_context?.current_action || '';
|
||||||
@@ -157,24 +284,6 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
|||||||
const num = (v: unknown) => (typeof v === 'number' ? v : undefined);
|
const num = (v: unknown) => (typeof v === 'number' ? v : undefined);
|
||||||
const str = (v: unknown) => (typeof v === 'string' ? 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} extension`;
|
const pluginName = str(md.plugin_name) || task.label || `${source} extension`;
|
||||||
|
|
||||||
let extensionType: 'plugin' | 'mcp' | 'skill' = 'plugin';
|
let extensionType: 'plugin' | 'mcp' | 'skill' = 'plugin';
|
||||||
@@ -184,6 +293,75 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
|||||||
extensionType = 'skill';
|
extensionType = 'skill';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer the task's real creation time so a refresh (or first sync) restores
|
||||||
|
// the correct elapsed baseline instead of restarting the ramp from zero.
|
||||||
|
const backendStartedAt =
|
||||||
|
typeof task.created_at === 'number' && task.created_at > 0
|
||||||
|
? task.created_at * 1000
|
||||||
|
: undefined;
|
||||||
|
const startedAt = previous?.startedAt ?? backendStartedAt ?? Date.now();
|
||||||
|
let stageStartedAt =
|
||||||
|
previous?.stageStartedAt ??
|
||||||
|
previous?.startedAt ??
|
||||||
|
backendStartedAt ??
|
||||||
|
startedAt;
|
||||||
|
|
||||||
|
let stage: InstallStage;
|
||||||
|
let overallProgress: number;
|
||||||
|
let error: string | undefined;
|
||||||
|
|
||||||
|
// Furthest non-terminal stage reached, kept across failures.
|
||||||
|
let lastStage = previous?.lastStage ?? previous?.stage;
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
if (exception) {
|
||||||
|
// Preserve how far the task got before failing, so the bar shows the
|
||||||
|
// failure point instead of jumping back to zero.
|
||||||
|
stage = InstallStage.ERROR;
|
||||||
|
overallProgress = previous?.overallProgress ?? 0;
|
||||||
|
error = exception;
|
||||||
|
} else {
|
||||||
|
stage = InstallStage.DONE;
|
||||||
|
overallProgress = 100;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const incoming = mapActionToStage(action);
|
||||||
|
// Forward-only: never move back to an earlier stage than we already reached.
|
||||||
|
stage = previous ? maxStage(previous.stage, incoming) : incoming;
|
||||||
|
if (!previous || previous.stage !== stage) {
|
||||||
|
stageStartedAt = Date.now();
|
||||||
|
}
|
||||||
|
lastStage = stage;
|
||||||
|
|
||||||
|
const counters: PluginInstallTask = {
|
||||||
|
id: `${source}-${task.id}`,
|
||||||
|
taskId: task.id,
|
||||||
|
pluginName,
|
||||||
|
source,
|
||||||
|
extensionType,
|
||||||
|
stage,
|
||||||
|
overallProgress: 0,
|
||||||
|
downloadCurrent: num(md.download_current) ?? previous?.downloadCurrent,
|
||||||
|
downloadTotal: num(md.download_total) ?? previous?.downloadTotal,
|
||||||
|
downloadSpeed: num(md.download_speed) ?? previous?.downloadSpeed,
|
||||||
|
depsTotal: num(md.deps_total) ?? previous?.depsTotal,
|
||||||
|
depsInstalled: num(md.deps_installed) ?? previous?.depsInstalled,
|
||||||
|
depsRemaining: num(md.deps_remaining) ?? previous?.depsRemaining,
|
||||||
|
currentDep: str(md.current_dep) ?? previous?.currentDep,
|
||||||
|
depsDownloadedSize:
|
||||||
|
num(md.deps_downloaded_size) ?? previous?.depsDownloadedSize,
|
||||||
|
depsSpeed: num(md.deps_speed) ?? previous?.depsSpeed,
|
||||||
|
startedAt,
|
||||||
|
stageStartedAt,
|
||||||
|
currentAction: action,
|
||||||
|
};
|
||||||
|
|
||||||
|
const computed = computeStageProgress(counters, stage);
|
||||||
|
overallProgress = Math.max(previous?.overallProgress ?? 0, computed);
|
||||||
|
// Keep the bar strictly below 100 until the backend confirms completion.
|
||||||
|
overallProgress = Math.round(Math.min(99, overallProgress));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${source}-${task.id}`,
|
id: `${source}-${task.id}`,
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
@@ -191,18 +369,21 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
|||||||
source,
|
source,
|
||||||
extensionType,
|
extensionType,
|
||||||
stage,
|
stage,
|
||||||
|
lastStage,
|
||||||
overallProgress,
|
overallProgress,
|
||||||
downloadCurrent: num(md.download_current),
|
downloadCurrent: num(md.download_current) ?? previous?.downloadCurrent,
|
||||||
downloadTotal: num(md.download_total),
|
downloadTotal: num(md.download_total) ?? previous?.downloadTotal,
|
||||||
downloadSpeed: num(md.download_speed),
|
downloadSpeed: num(md.download_speed) ?? previous?.downloadSpeed,
|
||||||
depsTotal: num(md.deps_total),
|
depsTotal: num(md.deps_total) ?? previous?.depsTotal,
|
||||||
depsInstalled: num(md.deps_installed),
|
depsInstalled: num(md.deps_installed) ?? previous?.depsInstalled,
|
||||||
depsRemaining: num(md.deps_remaining),
|
depsRemaining: num(md.deps_remaining) ?? previous?.depsRemaining,
|
||||||
currentDep: str(md.current_dep),
|
currentDep: str(md.current_dep) ?? previous?.currentDep,
|
||||||
depsDownloadedSize: num(md.deps_downloaded_size),
|
depsDownloadedSize:
|
||||||
depsSpeed: num(md.deps_speed),
|
num(md.deps_downloaded_size) ?? previous?.depsDownloadedSize,
|
||||||
|
depsSpeed: num(md.deps_speed) ?? previous?.depsSpeed,
|
||||||
error,
|
error,
|
||||||
startedAt: Date.now(),
|
startedAt,
|
||||||
|
stageStartedAt,
|
||||||
currentAction: action,
|
currentAction: action,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -315,8 +496,11 @@ export function PluginInstallTaskProvider({
|
|||||||
return {
|
return {
|
||||||
...t,
|
...t,
|
||||||
stage: InstallStage.ERROR,
|
stage: InstallStage.ERROR,
|
||||||
|
// Keep the phase that failed for the UI to display.
|
||||||
|
lastStage: t.lastStage ?? t.stage,
|
||||||
error: exception,
|
error: exception,
|
||||||
overallProgress: 0,
|
// Show where it failed instead of resetting to 0.
|
||||||
|
overallProgress: t.overallProgress,
|
||||||
currentAction: action,
|
currentAction: action,
|
||||||
...progressFields,
|
...progressFields,
|
||||||
};
|
};
|
||||||
@@ -332,26 +516,28 @@ export function PluginInstallTaskProvider({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const stage = mapActionToStage(action);
|
// Forward-only stage transition.
|
||||||
const baseProgress = stageToProgress(stage);
|
const incoming = mapActionToStage(action);
|
||||||
// Add small time-based increment within stage
|
const stage = maxStage(t.stage, incoming);
|
||||||
const elapsed = (Date.now() - t.startedAt) / 1000;
|
// Reset the per-stage ramp whenever we enter a new stage.
|
||||||
const withinStageIncrement = Math.min(
|
const stageAdvanced = stage !== t.stage;
|
||||||
15,
|
|
||||||
Math.floor(elapsed / 2),
|
|
||||||
);
|
|
||||||
const progress = Math.min(
|
|
||||||
95,
|
|
||||||
baseProgress + withinStageIncrement,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
const next: PluginInstallTask = {
|
||||||
...t,
|
...t,
|
||||||
stage,
|
stage,
|
||||||
overallProgress: progress,
|
lastStage: stage,
|
||||||
|
stageStartedAt: stageAdvanced
|
||||||
|
? Date.now()
|
||||||
|
: (t.stageStartedAt ?? t.startedAt),
|
||||||
currentAction: action,
|
currentAction: action,
|
||||||
...progressFields,
|
...progressFields,
|
||||||
};
|
};
|
||||||
|
const computed = computeStageProgress(next, stage);
|
||||||
|
// Progress must never move backwards while the task runs.
|
||||||
|
const overallProgress = Math.round(
|
||||||
|
Math.min(99, Math.max(t.overallProgress, computed)),
|
||||||
|
);
|
||||||
|
return { ...next, overallProgress };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -377,46 +563,61 @@ export function PluginInstallTaskProvider({
|
|||||||
);
|
);
|
||||||
|
|
||||||
setTasks((prevTasks) => {
|
setTasks((prevTasks) => {
|
||||||
const existingTaskIds = new Set(prevTasks.map((t) => t.taskId));
|
|
||||||
const updatedTasks = [...prevTasks];
|
const updatedTasks = [...prevTasks];
|
||||||
|
// Collect tasks that need polling started after state is committed.
|
||||||
|
const toPoll: Array<{ key: string; taskId: number }> = [];
|
||||||
|
|
||||||
for (const bt of backendTasks) {
|
for (const bt of backendTasks) {
|
||||||
// Skip tasks that the user has dismissed
|
// Skip tasks that the user has dismissed
|
||||||
if (dismissedTaskIds.current.has(bt.id)) continue;
|
if (dismissedTaskIds.current.has(bt.id)) continue;
|
||||||
|
|
||||||
if (!existingTaskIds.has(bt.id)) {
|
const idx = updatedTasks.findIndex((t) => t.taskId === bt.id);
|
||||||
|
|
||||||
|
if (idx === -1) {
|
||||||
// New task from backend (e.g. after page refresh) — add it
|
// New task from backend (e.g. after page refresh) — add it
|
||||||
const newTask = asyncTaskToPluginInstallTask(bt);
|
const newTask = asyncTaskToPluginInstallTask(bt);
|
||||||
updatedTasks.push(newTask);
|
updatedTasks.push(newTask);
|
||||||
|
|
||||||
// If not done, start polling for progress
|
|
||||||
if (!bt.runtime.done) {
|
if (!bt.runtime.done) {
|
||||||
pollTask(newTask.id, bt.id);
|
toPoll.push({ key: newTask.id, taskId: bt.id });
|
||||||
} else {
|
} else {
|
||||||
// Mark as already notified so we don't re-trigger toasts for old completed tasks
|
// Mark as already notified so we don't re-trigger toasts for old completed tasks
|
||||||
notifiedTaskIds.current.add(bt.id);
|
notifiedTaskIds.current.add(bt.id);
|
||||||
}
|
}
|
||||||
} else {
|
continue;
|
||||||
// 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) {
|
// Already tracking — merge the backend snapshot into the existing
|
||||||
const existing = updatedTasks[idx];
|
// task. Passing `existing` keeps `startedAt`, `pluginName` and
|
||||||
if (
|
// progress monotonic so re-syncing never rewinds the bar.
|
||||||
bt.runtime.done &&
|
const existing = updatedTasks[idx];
|
||||||
existing.stage !== InstallStage.DONE &&
|
const converted = asyncTaskToPluginInstallTask(bt, existing);
|
||||||
existing.stage !== InstallStage.ERROR
|
converted.pluginName = existing.pluginName;
|
||||||
) {
|
converted.fileSize = existing.fileSize;
|
||||||
const converted = asyncTaskToPluginInstallTask(bt);
|
converted.extensionType = existing.extensionType;
|
||||||
converted.startedAt = existing.startedAt;
|
|
||||||
converted.pluginName = existing.pluginName;
|
// Never downgrade a terminal task that is already done/failed locally,
|
||||||
converted.fileSize = existing.fileSize;
|
// unless the backend reports it finished as well.
|
||||||
converted.extensionType = existing.extensionType;
|
if (
|
||||||
updatedTasks[idx] = converted;
|
(existing.stage === InstallStage.DONE ||
|
||||||
}
|
existing.stage === InstallStage.ERROR) &&
|
||||||
}
|
!bt.runtime.done
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedTasks[idx] = converted;
|
||||||
|
|
||||||
|
if (!bt.runtime.done) {
|
||||||
|
toPoll.push({ key: converted.id, taskId: bt.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schedule polling outside the state updater.
|
||||||
|
queueMicrotask(() => {
|
||||||
|
toPoll.forEach(({ key, taskId }) => pollTask(key, taskId));
|
||||||
|
});
|
||||||
|
|
||||||
return updatedTasks;
|
return updatedTasks;
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -464,6 +665,7 @@ export function PluginInstallTaskProvider({
|
|||||||
// Remove from dismissed set if re-added
|
// Remove from dismissed set if re-added
|
||||||
dismissedTaskIds.current.delete(params.taskId);
|
dismissedTaskIds.current.delete(params.taskId);
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
const newTask: PluginInstallTask = {
|
const newTask: PluginInstallTask = {
|
||||||
id: taskKey,
|
id: taskKey,
|
||||||
taskId: params.taskId,
|
taskId: params.taskId,
|
||||||
@@ -471,9 +673,11 @@ export function PluginInstallTaskProvider({
|
|||||||
source: params.source,
|
source: params.source,
|
||||||
extensionType: params.extensionType,
|
extensionType: params.extensionType,
|
||||||
stage: InstallStage.DOWNLOADING,
|
stage: InstallStage.DOWNLOADING,
|
||||||
overallProgress: 5,
|
// Start at the downloading floor and creep up from real counters.
|
||||||
|
overallProgress: stageFloor(InstallStage.DOWNLOADING),
|
||||||
fileSize: params.fileSize,
|
fileSize: params.fileSize,
|
||||||
startedAt: Date.now(),
|
downloadTotal: params.fileSize,
|
||||||
|
startedAt,
|
||||||
currentAction: '',
|
currentAction: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Rocket,
|
||||||
X,
|
X,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
@@ -30,6 +31,7 @@ import { cn } from '@/lib/utils';
|
|||||||
const STAGE_ICONS: Record<string, React.ElementType> = {
|
const STAGE_ICONS: Record<string, React.ElementType> = {
|
||||||
[InstallStage.DOWNLOADING]: Download,
|
[InstallStage.DOWNLOADING]: Download,
|
||||||
[InstallStage.INSTALLING_DEPS]: Package,
|
[InstallStage.INSTALLING_DEPS]: Package,
|
||||||
|
[InstallStage.LAUNCHING]: Rocket,
|
||||||
[InstallStage.DONE]: CheckCircle2,
|
[InstallStage.DONE]: CheckCircle2,
|
||||||
[InstallStage.ERROR]: XCircle,
|
[InstallStage.ERROR]: XCircle,
|
||||||
};
|
};
|
||||||
@@ -95,6 +97,8 @@ function TaskQueueItem({
|
|||||||
return t('plugins.installProgress.downloading');
|
return t('plugins.installProgress.downloading');
|
||||||
case InstallStage.INSTALLING_DEPS:
|
case InstallStage.INSTALLING_DEPS:
|
||||||
return t('plugins.installProgress.installingDeps');
|
return t('plugins.installProgress.installingDeps');
|
||||||
|
case InstallStage.LAUNCHING:
|
||||||
|
return t('plugins.installProgress.launching');
|
||||||
case InstallStage.DONE:
|
case InstallStage.DONE:
|
||||||
return isDone
|
return isDone
|
||||||
? getInstallCompleteMessage()
|
? getInstallCompleteMessage()
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
Suspense,
|
||||||
|
} from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +58,10 @@ import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
|
|||||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
|
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
|
||||||
|
import {
|
||||||
|
resolveInstalledState,
|
||||||
|
useMarketplaceInstalledIndex,
|
||||||
|
} from './marketplace-installed';
|
||||||
|
|
||||||
interface SortOption {
|
interface SortOption {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -91,6 +102,20 @@ function MarketPageContent({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
// Installed-extension lookup, recomputed whenever the sidebar lists change
|
||||||
|
// (e.g. right after an install completes).
|
||||||
|
const installedIndex = useMarketplaceInstalledIndex();
|
||||||
|
|
||||||
|
const decorateInstalled = useCallback(
|
||||||
|
(vo: PluginMarketCardVO): PluginMarketCardVO => {
|
||||||
|
const state = resolveInstalledState(installedIndex, vo);
|
||||||
|
vo.installed = state.installed;
|
||||||
|
vo.hasUpdate = state.hasUpdate;
|
||||||
|
return vo;
|
||||||
|
},
|
||||||
|
[installedIndex],
|
||||||
|
);
|
||||||
|
|
||||||
const validTypes = ['plugin', 'mcp', 'skill'];
|
const validTypes = ['plugin', 'mcp', 'skill'];
|
||||||
|
|
||||||
const extensionTypeOptions = [
|
const extensionTypeOptions = [
|
||||||
@@ -571,7 +596,12 @@ function MarketPageContent({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const visiblePlugins = plugins;
|
// Decorate with installed state at render time so the badge updates the
|
||||||
|
// moment the sidebar lists refresh (e.g. after an install completes).
|
||||||
|
const visiblePlugins = useMemo(
|
||||||
|
() => plugins.map((plugin) => decorateInstalled(plugin)),
|
||||||
|
[plugins, decorateInstalled],
|
||||||
|
);
|
||||||
|
|
||||||
// 加载更多
|
// 加载更多
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { I18nObject } from '@/app/infra/entities/common';
|
|||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
resolveInstalledState,
|
||||||
|
useMarketplaceInstalledIndex,
|
||||||
|
} from './marketplace-installed';
|
||||||
|
|
||||||
export interface RecommendationList {
|
export interface RecommendationList {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
@@ -66,6 +70,7 @@ function RecommendationListRow({
|
|||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const installedIndex = useMarketplaceInstalledIndex();
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [perPage, setPerPage] = useState(4);
|
const [perPage, setPerPage] = useState(4);
|
||||||
// Countdown progress to the next auto-advance, 0 → 1 over AUTO_ADVANCE_MS.
|
// Countdown progress to the next auto-advance, 0 → 1 over AUTO_ADVANCE_MS.
|
||||||
@@ -261,16 +266,22 @@ function RecommendationListRow({
|
|||||||
ref={gridRef}
|
ref={gridRef}
|
||||||
className="grid gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,24rem),1fr))]"
|
className="grid gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,24rem),1fr))]"
|
||||||
>
|
>
|
||||||
{visiblePlugins.map((plugin) => (
|
{visiblePlugins.map((plugin) => {
|
||||||
<PluginMarketCardComponent
|
const cardVO = pluginToVO(plugin, t);
|
||||||
key={plugin.author + ' / ' + plugin.name}
|
const state = resolveInstalledState(installedIndex, cardVO);
|
||||||
cardVO={pluginToVO(plugin, t)}
|
cardVO.installed = state.installed;
|
||||||
tagNames={tagNames}
|
cardVO.hasUpdate = state.hasUpdate;
|
||||||
onInstall={onInstall}
|
return (
|
||||||
installDisabled={installDisabled}
|
<PluginMarketCardComponent
|
||||||
installDisabledTooltip={installDisabledTooltip}
|
key={plugin.author + ' / ' + plugin.name}
|
||||||
/>
|
cardVO={cardVO}
|
||||||
))}
|
tagNames={tagNames}
|
||||||
|
onInstall={onInstall}
|
||||||
|
installDisabled={installDisabled}
|
||||||
|
installDisabledTooltip={installDisabledTooltip}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{totalPages > 1 && !isLast && (
|
{totalPages > 1 && !isLast && (
|
||||||
<div className="border-b border-border mt-6" />
|
<div className="border-b border-border mt-6" />
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
|
|
||||||
|
export interface MarketplaceInstalledState {
|
||||||
|
installed: boolean;
|
||||||
|
hasUpdate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstalledIndexEntry {
|
||||||
|
hasUpdate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Composite key used to look up installed extensions: `type:author/name`. */
|
||||||
|
export function installedExtensionKey(
|
||||||
|
type: string | undefined,
|
||||||
|
author: string,
|
||||||
|
name: string,
|
||||||
|
): string {
|
||||||
|
return `${type || 'plugin'}:${author}/${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a lookup of already-installed extensions.
|
||||||
|
*
|
||||||
|
* The sidebar identifies each kind differently:
|
||||||
|
* - plugins: `author/name`
|
||||||
|
* - MCP servers: `author__name` (double underscore)
|
||||||
|
* - skills: the bare skill name
|
||||||
|
*/
|
||||||
|
export function buildInstalledIndex(
|
||||||
|
plugins: { id: string; hasUpdate?: boolean }[],
|
||||||
|
mcpServers: { id: string }[],
|
||||||
|
skills: { id: string }[],
|
||||||
|
): Map<string, InstalledIndexEntry> {
|
||||||
|
const index = new Map<string, InstalledIndexEntry>();
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
index.set(`plugin:${plugin.id}`, { hasUpdate: plugin.hasUpdate ?? false });
|
||||||
|
}
|
||||||
|
for (const server of mcpServers) {
|
||||||
|
index.set(`mcp:${server.id.replace(/__/g, '/')}`, { hasUpdate: false });
|
||||||
|
}
|
||||||
|
for (const skill of skills) {
|
||||||
|
index.set(`skill:${skill.id}`, { hasUpdate: false });
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve whether a marketplace extension is installed.
|
||||||
|
*
|
||||||
|
* Marketplace entries always use `author/name`; skills may be stored under
|
||||||
|
* their bare name, so both keys are checked for that case.
|
||||||
|
*/
|
||||||
|
export function resolveInstalledState(
|
||||||
|
index: Map<string, InstalledIndexEntry>,
|
||||||
|
extension: { type?: string; author: string; pluginName: string },
|
||||||
|
): MarketplaceInstalledState {
|
||||||
|
const type = extension.type || 'plugin';
|
||||||
|
const keys = [
|
||||||
|
`${type}:${extension.author}/${extension.pluginName}`,
|
||||||
|
`${type}:${extension.pluginName}`,
|
||||||
|
];
|
||||||
|
for (const key of keys) {
|
||||||
|
const entry = index.get(key);
|
||||||
|
if (entry) {
|
||||||
|
return { installed: true, hasUpdate: entry.hasUpdate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { installed: false, hasUpdate: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive installed-extension index derived from the sidebar data context.
|
||||||
|
* Recomputes automatically after an install finishes and the sidebar refreshes.
|
||||||
|
*/
|
||||||
|
export function useMarketplaceInstalledIndex(): Map<
|
||||||
|
string,
|
||||||
|
InstalledIndexEntry
|
||||||
|
> {
|
||||||
|
const { plugins, mcpServers, skills } = useSidebarData();
|
||||||
|
return useMemo(
|
||||||
|
() => buildInstalledIndex(plugins, mcpServers, skills),
|
||||||
|
[plugins, mcpServers, skills],
|
||||||
|
);
|
||||||
|
}
|
||||||
+39
-17
@@ -3,7 +3,14 @@ import { useRef, useState, useEffect } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import PluginComponentList from '../PluginComponentList';
|
import PluginComponentList from '../PluginComponentList';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Info, Package, ExternalLink, Heart, Loader2 } from 'lucide-react';
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Info,
|
||||||
|
Package,
|
||||||
|
ExternalLink,
|
||||||
|
Heart,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -48,6 +55,10 @@ export default function PluginMarketCardComponent({
|
|||||||
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
|
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Already installed → swap the download count for an "installed" marker.
|
||||||
|
// Click behaviour stays identical to a normal card.
|
||||||
|
const isInstalled = cardVO.installed === true;
|
||||||
|
|
||||||
const showTypeBadge = cardVO.type;
|
const showTypeBadge = cardVO.type;
|
||||||
const typeLabel =
|
const typeLabel =
|
||||||
cardVO.type === 'mcp'
|
cardVO.type === 'mcp'
|
||||||
@@ -320,23 +331,34 @@ export default function PluginMarketCardComponent({
|
|||||||
className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden"
|
className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
|
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
|
||||||
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
|
{/* Installed extensions replace the download count with an
|
||||||
<svg
|
"installed" marker so the card reflects local state. */}
|
||||||
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
|
{isInstalled ? (
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
|
||||||
viewBox="0 0 24 24"
|
<CheckCircle2 className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-green-600 dark:text-green-400 flex-shrink-0" />
|
||||||
fill="none"
|
<div className="text-xs sm:text-sm text-green-600 dark:text-green-400 font-medium whitespace-nowrap">
|
||||||
stroke="currentColor"
|
{t('market.installed')}
|
||||||
strokeWidth="2"
|
</div>
|
||||||
>
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
||||||
<polyline points="7,10 12,15 17,10" />
|
|
||||||
<line x1="12" y1="15" x2="12" y2="3" />
|
|
||||||
</svg>
|
|
||||||
<div className="text-xs sm:text-sm text-[#2563eb] dark:text-[#5b8def] font-medium whitespace-nowrap">
|
|
||||||
{cardVO.installCount?.toLocaleString() ?? '0'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7,10 12,15 17,10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
<div className="text-xs sm:text-sm text-[#2563eb] dark:text-[#5b8def] font-medium whitespace-nowrap">
|
||||||
|
{cardVO.installCount?.toLocaleString() ?? '0'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
|
{cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
|
||||||
<div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0">
|
<div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0">
|
||||||
|
|||||||
+8
@@ -12,6 +12,10 @@ export interface IPluginMarketCardVO {
|
|||||||
components?: Record<string, number>;
|
components?: Record<string, number>;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
type?: 'plugin' | 'mcp' | 'skill';
|
type?: 'plugin' | 'mcp' | 'skill';
|
||||||
|
/** Whether this extension is already installed in the current workspace. */
|
||||||
|
installed?: boolean;
|
||||||
|
/** Whether an installed extension has a newer marketplace version. */
|
||||||
|
hasUpdate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PluginMarketCardVO implements IPluginMarketCardVO {
|
export class PluginMarketCardVO implements IPluginMarketCardVO {
|
||||||
@@ -28,6 +32,8 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
|||||||
components?: Record<string, number>;
|
components?: Record<string, number>;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
type?: 'plugin' | 'mcp' | 'skill';
|
type?: 'plugin' | 'mcp' | 'skill';
|
||||||
|
installed?: boolean;
|
||||||
|
hasUpdate?: boolean;
|
||||||
|
|
||||||
constructor(prop: IPluginMarketCardVO) {
|
constructor(prop: IPluginMarketCardVO) {
|
||||||
this.description = prop.description;
|
this.description = prop.description;
|
||||||
@@ -43,5 +49,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
|||||||
this.components = prop.components;
|
this.components = prop.components;
|
||||||
this.tags = prop.tags;
|
this.tags = prop.tags;
|
||||||
this.type = prop.type;
|
this.type = prop.type;
|
||||||
|
this.installed = prop.installed ?? false;
|
||||||
|
this.hasUpdate = prop.hasUpdate ?? false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -460,6 +460,8 @@ export interface AsyncTask {
|
|||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
task_type: string; // system or user
|
task_type: string; // system or user
|
||||||
|
/** Unix epoch seconds (float) when the task was created. */
|
||||||
|
created_at?: number;
|
||||||
runtime: AsyncTaskRuntimeInfo;
|
runtime: AsyncTaskRuntimeInfo;
|
||||||
task_context: AsyncTaskTaskContext;
|
task_context: AsyncTaskTaskContext;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -748,6 +748,9 @@ const enUS = {
|
|||||||
'Are you sure you want to install plugin "{{name}}" ({{version}})?',
|
'Are you sure you want to install plugin "{{name}}" ({{version}})?',
|
||||||
downloadComplete: 'Plugin "{{name}}" download completed',
|
downloadComplete: 'Plugin "{{name}}" download completed',
|
||||||
installFailed: 'Installation failed, please try again later',
|
installFailed: 'Installation failed, please try again later',
|
||||||
|
installed: 'Installed',
|
||||||
|
updateAvailable: 'Update available',
|
||||||
|
alreadyInstalled: '{{name}} is already installed',
|
||||||
loadFailed: 'Failed to get plugin list, please try again later',
|
loadFailed: 'Failed to get plugin list, please try again later',
|
||||||
noDescription: 'No description available',
|
noDescription: 'No description available',
|
||||||
recommendation: {
|
recommendation: {
|
||||||
|
|||||||
@@ -769,6 +769,9 @@ const esES = {
|
|||||||
installFailed: 'Error en la instalación, por favor inténtalo más tarde',
|
installFailed: 'Error en la instalación, por favor inténtalo más tarde',
|
||||||
loadFailed:
|
loadFailed:
|
||||||
'Error al obtener la lista de plugins, por favor inténtalo más tarde',
|
'Error al obtener la lista de plugins, por favor inténtalo más tarde',
|
||||||
|
installed: 'Instalado',
|
||||||
|
updateAvailable: 'Actualización disponible',
|
||||||
|
alreadyInstalled: '{{name}} ya está instalado',
|
||||||
noDescription: 'No hay descripción disponible',
|
noDescription: 'No hay descripción disponible',
|
||||||
recommendation: {
|
recommendation: {
|
||||||
pause: 'Pausar rotación automática',
|
pause: 'Pausar rotación automática',
|
||||||
|
|||||||
@@ -758,6 +758,9 @@ const jaJP = {
|
|||||||
installFailed: 'インストールに失敗しました。後でもう一度お試しください',
|
installFailed: 'インストールに失敗しました。後でもう一度お試しください',
|
||||||
loadFailed:
|
loadFailed:
|
||||||
'プラグインリストの取得に失敗しました。後でもう一度お試しください',
|
'プラグインリストの取得に失敗しました。後でもう一度お試しください',
|
||||||
|
installed: 'インストール済み',
|
||||||
|
updateAvailable: '更新あり',
|
||||||
|
alreadyInstalled: '{{name}} はインストール済みです',
|
||||||
noDescription: '説明がありません',
|
noDescription: '説明がありません',
|
||||||
recommendation: {
|
recommendation: {
|
||||||
pause: '自動ローテーションを一時停止',
|
pause: '自動ローテーションを一時停止',
|
||||||
|
|||||||
@@ -763,6 +763,9 @@ const ruRU = {
|
|||||||
downloadComplete: 'Плагин "{{name}}" загружен',
|
downloadComplete: 'Плагин "{{name}}" загружен',
|
||||||
installFailed: 'Ошибка установки, попробуйте позже',
|
installFailed: 'Ошибка установки, попробуйте позже',
|
||||||
loadFailed: 'Не удалось получить список плагинов, попробуйте позже',
|
loadFailed: 'Не удалось получить список плагинов, попробуйте позже',
|
||||||
|
installed: 'Установлено',
|
||||||
|
updateAvailable: 'Доступно обновление',
|
||||||
|
alreadyInstalled: '{{name}} уже установлен',
|
||||||
noDescription: 'Описание отсутствует',
|
noDescription: 'Описание отсутствует',
|
||||||
recommendation: {
|
recommendation: {
|
||||||
pause: 'Приостановить авто-прокрутку',
|
pause: 'Приостановить авто-прокрутку',
|
||||||
|
|||||||
@@ -741,6 +741,9 @@ const thTH = {
|
|||||||
downloadComplete: 'ดาวน์โหลดปลั๊กอิน "{{name}}" เสร็จสมบูรณ์',
|
downloadComplete: 'ดาวน์โหลดปลั๊กอิน "{{name}}" เสร็จสมบูรณ์',
|
||||||
installFailed: 'ติดตั้งล้มเหลว กรุณาลองใหม่ภายหลัง',
|
installFailed: 'ติดตั้งล้มเหลว กรุณาลองใหม่ภายหลัง',
|
||||||
loadFailed: 'ไม่สามารถดึงรายการปลั๊กอินได้ กรุณาลองใหม่ภายหลัง',
|
loadFailed: 'ไม่สามารถดึงรายการปลั๊กอินได้ กรุณาลองใหม่ภายหลัง',
|
||||||
|
installed: 'ติดตั้งแล้ว',
|
||||||
|
updateAvailable: 'มีอัปเดต',
|
||||||
|
alreadyInstalled: '{{name}} ติดตั้งแล้ว',
|
||||||
noDescription: 'ไม่มีคำอธิบาย',
|
noDescription: 'ไม่มีคำอธิบาย',
|
||||||
recommendation: {
|
recommendation: {
|
||||||
pause: 'หยุดการหมุนอัตโนมัติชั่วคราว',
|
pause: 'หยุดการหมุนอัตโนมัติชั่วคราว',
|
||||||
|
|||||||
@@ -756,6 +756,9 @@ const viVN = {
|
|||||||
downloadComplete: 'Tải plugin "{{name}}" hoàn tất',
|
downloadComplete: 'Tải plugin "{{name}}" hoàn tất',
|
||||||
installFailed: 'Cài đặt thất bại, vui lòng thử lại sau',
|
installFailed: 'Cài đặt thất bại, vui lòng thử lại sau',
|
||||||
loadFailed: 'Lấy danh sách plugin thất bại, vui lòng thử lại sau',
|
loadFailed: 'Lấy danh sách plugin thất bại, vui lòng thử lại sau',
|
||||||
|
installed: 'Đã cài đặt',
|
||||||
|
updateAvailable: 'Có bản cập nhật',
|
||||||
|
alreadyInstalled: '{{name}} đã được cài đặt',
|
||||||
noDescription: 'Không có mô tả',
|
noDescription: 'Không có mô tả',
|
||||||
recommendation: {
|
recommendation: {
|
||||||
pause: 'Tạm dừng tự động xoay',
|
pause: 'Tạm dừng tự động xoay',
|
||||||
|
|||||||
@@ -715,6 +715,9 @@ const zhHans = {
|
|||||||
installConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?',
|
installConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?',
|
||||||
downloadComplete: '插件 "{{name}}" 下载完成',
|
downloadComplete: '插件 "{{name}}" 下载完成',
|
||||||
installFailed: '安装失败,请稍后重试',
|
installFailed: '安装失败,请稍后重试',
|
||||||
|
installed: '已安装',
|
||||||
|
updateAvailable: '有更新',
|
||||||
|
alreadyInstalled: '{{name}} 已安装',
|
||||||
loadFailed: '获取插件列表失败,请稍后重试',
|
loadFailed: '获取插件列表失败,请稍后重试',
|
||||||
noDescription: '暂无描述',
|
noDescription: '暂无描述',
|
||||||
recommendation: {
|
recommendation: {
|
||||||
|
|||||||
@@ -719,6 +719,9 @@ const zhHant = {
|
|||||||
downloadComplete: '插件 "{{name}}" 下載完成',
|
downloadComplete: '插件 "{{name}}" 下載完成',
|
||||||
installFailed: '安裝失敗,請稍後重試',
|
installFailed: '安裝失敗,請稍後重試',
|
||||||
loadFailed: '取得插件列表失敗,請稍後重試',
|
loadFailed: '取得插件列表失敗,請稍後重試',
|
||||||
|
installed: '已安裝',
|
||||||
|
updateAvailable: '有更新',
|
||||||
|
alreadyInstalled: '{{name}} 已安裝',
|
||||||
noDescription: '暫無描述',
|
noDescription: '暫無描述',
|
||||||
recommendation: {
|
recommendation: {
|
||||||
pause: '暫停自動輪播',
|
pause: '暫停自動輪播',
|
||||||
|
|||||||
Reference in New Issue
Block a user