mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-22 17:36:59 +08:00
feat(plugins): show installed state in marketplace and search installed extensions
Marketplace cards now reflect whether an extension is already installed in the current workspace, and the installed-extension list gains a search box. Backend (stream install progress): - _read_httpx_response_limited gains an optional task_context: it publishes download_total from Content-Length before the first chunk and updates download_current / download_speed per chunk. The marketplace download path previously had no progress reporting; it now matches the GitHub path. - _marketplace_get forwards task_context to that helper. - install_plugin resets the per-install counters so re-installing the same plugin cannot inherit stale metadata, and reports human-readable stages: preparing -> downloading -> inspecting -> storing -> installing dependencies -> launching -> waiting for plugin to become ready. Frontend (installed state): - New marketplace-installed helper normalises the sidebar identities (plugin: author/name, mcp: author__name, skill: bare name) into one type:author/name index and resolves a card's installed state from it. useMarketplaceInstalledIndex memoises on the sidebar lists, so a finished install (which refreshes the sidebar) re-evaluates the cards automatically. - PluginMarketCardVO carries installed / hasUpdate. An installed extension turns its download affordance into a hollow green ring with a green check in place, instead of adding a separate badge; the count slot switches to the installed label. Cards with an available update use amber. - PluginMarketComponent derives the annotated list and shares the index with RecommendationLists. Frontend (install task UI): - mapActionToStage matches the new connector stage strings. The pre-download stages are checked before the generic "install" match, because "preparing plugin install" also contains "install". - Stage progress ranges are non-overlapping; overall progress interpolates on real byte counts while downloading and drifts monotonically elsewhere, capped at 99%. - The progress dialog and task queue expose the launching stage. Frontend (installed list search): - The installed list had no search at all. A query box in the page header filters by label / name / author / description, case-insensitively, applied before grouping so grouped and flat views both honour it. - Search misses and an empty list now show distinct empty states, with a clear action on a search miss. - AsyncTask entity gains the optional created_at field. i18n: new marketplace / install / search strings across all 8 locales. Verified: ruff format + check, tsc --noEmit, prettier --check, eslint (0 errors), and 89/89 frontend unit tests.
This commit is contained in:
@@ -87,8 +87,10 @@ async def _read_httpx_response_limited(
|
||||
response: httpx.Response,
|
||||
*,
|
||||
max_bytes: int,
|
||||
task_context: taskmgr.TaskContext | None = None,
|
||||
) -> bytes:
|
||||
content_length = response.headers.get('content-length')
|
||||
declared_size: int | None = None
|
||||
if content_length is not None:
|
||||
try:
|
||||
declared_size = int(content_length)
|
||||
@@ -97,11 +99,25 @@ async def _read_httpx_response_limited(
|
||||
if declared_size is not None and declared_size > max_bytes:
|
||||
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
|
||||
|
||||
if task_context is not None and declared_size is not None:
|
||||
# Publish the advertised size up-front so the UI can render a
|
||||
# determinate bar even before the first chunk arrives.
|
||||
task_context.metadata['download_total'] = declared_size
|
||||
|
||||
start_time = time.time()
|
||||
body = bytearray()
|
||||
async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
|
||||
body.extend(chunk)
|
||||
if len(body) > max_bytes:
|
||||
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)
|
||||
|
||||
|
||||
@@ -111,6 +127,7 @@ async def _marketplace_get(
|
||||
*,
|
||||
max_bytes: int,
|
||||
allow_not_found: bool = False,
|
||||
task_context: taskmgr.TaskContext | None = None,
|
||||
) -> tuple[int, bytes]:
|
||||
async with client.stream('GET', url) as response:
|
||||
if allow_not_found and response.status_code == 404:
|
||||
@@ -119,6 +136,7 @@ async def _marketplace_get(
|
||||
return response.status_code, await _read_httpx_response_limited(
|
||||
response,
|
||||
max_bytes=max_bytes,
|
||||
task_context=task_context,
|
||||
)
|
||||
|
||||
|
||||
@@ -1680,6 +1698,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
client,
|
||||
f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{latest_version}',
|
||||
max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES,
|
||||
task_context=task_context,
|
||||
)
|
||||
return plugin_package, latest_version
|
||||
|
||||
@@ -1695,7 +1714,21 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
plugin_name = str(install_info.get('plugin_name') or '')
|
||||
file_bytes: bytes | None
|
||||
|
||||
if task_context is not None:
|
||||
# Reset the per-install counters so re-installing the same plugin
|
||||
# cannot inherit stale progress 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 task_context is not None:
|
||||
task_context.set_current_action('downloading plugin package')
|
||||
file_bytes, version = await self._download_marketplace_package(
|
||||
execution_context,
|
||||
plugin_author,
|
||||
@@ -1719,6 +1752,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
else:
|
||||
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)
|
||||
if not manifest_author or not manifest_name:
|
||||
raise ValueError('Plugin package manifest identity is missing')
|
||||
@@ -1730,8 +1765,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
if task_context is not None:
|
||||
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()
|
||||
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:
|
||||
binding, previous_digest, previous_was_durable = await self._persist_installation_package(
|
||||
execution_context,
|
||||
@@ -1749,6 +1788,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
plugin_author=plugin_author,
|
||||
plugin_name=plugin_name,
|
||||
)
|
||||
if task_context is not None:
|
||||
task_context.set_current_action('launching plugin')
|
||||
await self._apply_desired_state(
|
||||
PluginInstallationDesiredState(binding=binding, enabled=True),
|
||||
artifact_package=file_bytes,
|
||||
@@ -1766,6 +1807,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
pass
|
||||
except Exception as 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)
|
||||
|
||||
async def upgrade_plugin(
|
||||
|
||||
Reference in New Issue
Block a user