From 4b537973f96a47e490c3ac00e8d049c72e08cf93 Mon Sep 17 00:00:00 2001 From: Type_rBody Date: Wed, 28 Oct 2026 18:23:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20upload=20debug=20plugin=20to=20?= =?UTF-8?q?LangBot=20Space=20with=20optional=20GitH=E2=80=A6=20(#2612)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugin): upload debug plugin to LangBot Space with optional GitHub sync - SDK: reusable in-memory .lbpkg packaging + git sync helper (local git by default, optional repo/token override) - routing: space-upload config/upload/git-sync endpoints; debug plugins resolved via OSS bridge without touching get_plugin_info - frontend: debug-card entry + upload dialog with multi-language metadata, icon upload and changelog - i18n across 8 locales; operation-trace + action allowlist entries * fix(plugin-space-upload): stable dialog width, single-line locale tabs, broader error surfacing - fixed dialog width so toggling the GitHub section no longer resizes it - language tabs render on one line (horizontal scroll when needed) - surface the real upload/git error message (msg field) instead of [object Object] - hint that a missing GitHub repo is auto-created when a token is provided * fix(plugin-space-upload): narrow dialog and reset sm:max-w clamp The base DialogContent applies sm:max-w-lg at the sm breakpoint, which overrode the unprefixed max-w-none and clamped the upload card to 32rem on desktop. Set an explicit sm:max-width and size the card to ~36rem. Locale tabs now wrap instead of scrolling horizontally. * fix * fix(plugin-space-upload): address CI and UX feedback - narrow the upload dialog to ~36rem and reset the sm:max-w clamp - guide the publisher to the Space review page after a successful upload - drop the redundant null comparison flagged by code quality - update the operation-action expectation and Space/LangBot Account wording - ruff format the touched API modules * feat(plugin-space-upload): public repo, editable author/license, card red dot * fix(market): replace yellow download count with update available badge * style(market): unify installed and update indicators into badges * feat(plugin): redesign debug card banner, style debug icon, and prioritize debug plugins * feat(plugin-space-upload): simplify upload dialog layout, add creator verification and market redirection * fix(i18n): use LangBot Account terminology in Space upload dialog Co-authored-by: multica-agent * style(i18n): reflow reworded locale strings for prettier Co-authored-by: multica-agent * test(utils): stop timing out the pattern-count cap test on loaded runners Co-authored-by: multica-agent --------- Co-authored-by: Hyu Co-authored-by: RockChinQ Co-authored-by: multica-agent --- .gitignore | 3 + .../api/http/controller/groups/extensions.py | 16 +- .../pkg/api/http/controller/groups/plugins.py | 309 +++- .../pkg/api/http/controller/groups/system.py | 6 +- src/langbot/pkg/api/http/service/space.py | 79 + src/langbot/pkg/discover/engine.py | 3 + src/langbot/pkg/operation_trace/service.py | 17 + src/langbot/pkg/plugin/connector.py | 104 ++ src/langbot/pkg/plugin/handler.py | 59 + tests/integration/api/test_workspaces.py | 1 + tests/unit_tests/utils/test_safe_regex.py | 3 + .../components/home-sidebar/HomeSidebar.tsx | 16 +- .../home-sidebar/SidebarDataContext.tsx | 8 +- .../ExtensionCardComponent.tsx | 85 +- .../PluginInstalledComponent.tsx | 55 +- .../PluginSpaceUploadDialog.tsx | 1412 +++++++++++++++++ .../plugin-card/PluginCardComponent.tsx | 50 +- .../PluginMarketCardComponent.tsx | 62 +- web/src/app/infra/entities/plugin/index.ts | 65 + web/src/app/infra/http/BackendClient.ts | 40 +- web/src/i18n/locales/en-US.ts | 206 +++ web/src/i18n/locales/es-ES.ts | 194 +++ web/src/i18n/locales/ja-JP.ts | 190 +++ web/src/i18n/locales/ru-RU.ts | 188 +++ web/src/i18n/locales/th-TH.ts | 187 +++ web/src/i18n/locales/vi-VN.ts | 190 +++ web/src/i18n/locales/zh-Hans.ts | 193 +++ web/src/i18n/locales/zh-Hant.ts | 177 +++ 28 files changed, 3809 insertions(+), 109 deletions(-) create mode 100644 web/src/app/home/plugins/components/plugin-installed/PluginSpaceUploadDialog.tsx diff --git a/.gitignore b/.gitignore index b236ce6a9..c245b2e49 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ packaging/fnos/app/desktop/images/ packaging/fnos/*.fpk r.ps1 + +*.log +*.pid diff --git a/src/langbot/pkg/api/http/controller/groups/extensions.py b/src/langbot/pkg/api/http/controller/groups/extensions.py index 040c04379..39fd8f202 100644 --- a/src/langbot/pkg/api/http/controller/groups/extensions.py +++ b/src/langbot/pkg/api/http/controller/groups/extensions.py @@ -40,9 +40,11 @@ class ExtensionsRouterGroup(group.RouterGroup): return_exceptions=True, ) - def _sort_key(item: dict) -> str: + def _sort_key(item: dict) -> tuple[int, str]: + is_debug = 0 if (item.get('type') == 'plugin' and item.get('plugin', {}).get('debug')) else 1 + name = '' if item['type'] == 'plugin': - return ( + name = ( item['plugin'] .get('manifest', {}) .get('manifest', {}) @@ -50,11 +52,11 @@ class ExtensionsRouterGroup(group.RouterGroup): .get('name', '') .lower() ) - if item['type'] == 'mcp': - return (item['server'].get('name') or '').lower() - if item['type'] == 'skill': - return (item['skill'].get('display_name') or item['skill'].get('name') or '').lower() - return '' + elif item['type'] == 'mcp': + name = (item['server'].get('name') or '').lower() + elif item['type'] == 'skill': + name = (item['skill'].get('display_name') or item['skill'].get('name') or '').lower() + return (is_debug, name) extensions: list[dict] = [] if isinstance(plugins, list): diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index 28024ca5d..5b8fd5487 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -23,6 +23,7 @@ from ...context import ExecutionContext, RequestContext from .....operation_trace import service as settings_service from .. import group from .....workspace.errors import WorkspaceNotFoundError +from langbot_plugin.entities.io.errors import ActionCallError from .....plugin.github import validate_github_plugin_install_info from .....plugin.archive import inspect_plugin_archive_metadata from .....utils import httpclient @@ -436,6 +437,257 @@ class PluginsRouterGroup(group.RouterGroup): } ) + async def _space_access_token_for_workspace( + request_context: RequestContext, + ) -> str | None: + """Resolve the Workspace owner's or requesting account's LangBot Space token, if bound. + + Every step is best-effort: a Space account is optional for the + underlying LangBot install, so an unavailable identity service must + not turn the upload page's metadata read into a 500. + """ + try: + user_service = getattr(self.ap, 'user_service', None) + space_service = getattr(self.ap, 'space_service', None) + if user_service is None or space_service is None: + return None + if request_context.account_uuid: + account = await user_service.get_user_by_uuid(request_context.account_uuid) + if account and account.account_type == 'space': + token = await space_service.get_valid_access_token(account.user) + if token: + return token + owner = await user_service.get_workspace_owner(request_context.workspace_uuid) + if owner is None: + return None + return await space_service.get_valid_access_token(owner.user) + except Exception: + return None + + async def _resolve_upload_target(author: str, plugin_name: str) -> dict | None: + """Resolve a plugin for the upload page, preferring debug connections. + + A plugin shown as "debugging" is not persisted as an installation, so + it is looked up through the OSS debug bridge first; installed plugins + remain a fallback so the page also works for local/marketplace ones. + """ + connector = self.ap.plugin_connector + for resolver in ( + getattr(connector, 'get_debug_plugin_info', None), + getattr(connector, 'get_plugin_info', None), + ): + if resolver is None: + continue + try: + info = await resolver(author, plugin_name) + except Exception: + self.ap.logger.exception('Failed to resolve plugin %s/%s for upload', author, plugin_name) + continue + if info: + return info + return None + + @self.route( + '///space-upload/config', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.RESOURCE_MANAGE, + ) + async def _(author: str, plugin_name: str, request_context: RequestContext) -> str: + """Metadata and connection state for the "upload to Space" page.""" + await self._require_authenticated_plugin_runtime_context(request_context) + plugin = await _resolve_upload_target(author, plugin_name) + if plugin is None: + return self.http_status(404, -1, 'plugin not found') + + metadata = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}) or {} + access_token = await _space_access_token_for_workspace(request_context) + space_connected = False + has_creator_profile = False + creator_slug = None + author_matches = False + + if access_token: + try: + space_service = getattr(self.ap, 'space_service', None) + if space_service is not None: + token_valid, creator_profile = await space_service.get_creator_profile(access_token) + if token_valid: + space_connected = True + if creator_profile and creator_profile.get('slug'): + has_creator_profile = True + creator_slug = creator_profile['slug'] + plugin_author = metadata.get('author', author) + if plugin_author and creator_slug and plugin_author.lower() == creator_slug.lower(): + author_matches = True + except Exception: + self.ap.logger.exception('Failed to retrieve creator profile for space-upload/config') + + return self.success( + data={ + 'debug': bool(plugin.get('debug')), + 'metadata': { + 'author': metadata.get('author', author), + 'name': metadata.get('name', plugin_name), + 'label': metadata.get('label'), + 'description': metadata.get('description'), + 'version': metadata.get('version'), + 'repository': metadata.get('repository'), + 'license': metadata.get('license'), + }, + 'space_connected': space_connected, + 'has_creator_profile': has_creator_profile, + 'creator_slug': creator_slug, + 'author_matches': author_matches, + 'cloud_service_url': self.ap.instance_config.data.get('space', {}).get( + 'web_url', + self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app'), + ), + } + ) + + @self.route( + '///space-upload', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.RESOURCE_MANAGE, + ) + async def _(author: str, plugin_name: str, request_context: RequestContext) -> str: + """Build the debug plugin and publish it to LangBot Space. + + Optionally synchronises the working directory to GitHub first, so the + published plugin and the repository stay aligned. + """ + await self._require_authenticated_plugin_runtime_context(request_context) + try: + data = await quart.request.get_json(silent=True) or {} + except Exception: + data = {} + if not isinstance(data, dict): + return self.http_status(400, -1, 'invalid request body') + + overrides = data.get('manifest_overrides') or {} + if not isinstance(overrides, dict): + return self.http_status(400, -1, 'manifest_overrides must be an object') + + sync_github = data.get('sync_github') is True + git_result: dict | None = None + + try: + package_result = await self.ap.plugin_connector.get_plugin_package( + author, + plugin_name, + manifest_overrides=overrides, + ) + except Exception as exc: + self.ap.logger.warning('Building package for %s/%s failed: %s', author, plugin_name, exc) + message = str(exc) + if 'build_plugin_package' in message and 'not found' in message.lower(): + message = ( + 'The debugging plugin process does not support packaging yet. ' + 'Update the LangBot plugin SDK used by `lbp run` and restart it.' + ) + return self.http_status(400, -1, message) + + package_bytes = package_result.get('package') or b'' + if not package_bytes: + return self.http_status(400, -1, 'Failed to build the plugin package') + + if sync_github: + try: + git_result = await self.ap.plugin_connector.git_sync_plugin( + author, + plugin_name, + repo_url=str(data.get('repo_url') or ''), + token=str(data.get('token') or ''), + branch=str(data.get('branch') or ''), + commit_message=str(data.get('commit_message') or ''), + manifest_overrides=overrides, + ) + except Exception as exc: + self.ap.logger.warning('Git sync failed for %s/%s: %s', author, plugin_name, exc) + return self.http_status(400, -1, f'Git sync failed: {exc}') + + access_token = await _space_access_token_for_workspace(request_context) + if not access_token: + return self.http_status( + 400, + -1, + 'Please connect a LangBot Space account before uploading plugins', + ) + + space_service = getattr(self.ap, 'space_service', None) + if space_service is not None: + token_valid, creator_profile = await space_service.get_creator_profile(access_token) + if not token_valid: + return self.http_status(401, -1, 'Space login session has expired. Please re-authenticate.') + if not creator_profile or not creator_profile.get('slug'): + return self.http_status( + 400, -1, 'Please set up a creator profile on Space before uploading plugins.' + ) + creator_slug = creator_profile.get('slug') + if author and creator_slug and author.lower() != creator_slug.lower(): + return self.http_status( + 400, + -1, + f"Plugin author '{author}' does not match your Space creator slug '@{creator_slug}'.", + ) + + try: + submission = await self.ap.space_service.publish_plugin( + access_token, + package_bytes, + filename=package_result.get('filename') or 'plugin.lbpkg', + changelog=str(data.get('changelog') or ''), + ) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + + quart.g.operation_log_resource_id = f'{author}/{plugin_name}' + return self.success( + data={ + 'submission': submission, + 'git': git_result, + 'filename': package_result.get('filename', ''), + } + ) + + @self.route( + '///space-upload/git-sync', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.RESOURCE_MANAGE, + ) + async def _(author: str, plugin_name: str, request_context: RequestContext) -> str: + """Synchronise only the debug plugin's working directory to GitHub.""" + await self._require_authenticated_plugin_runtime_context(request_context) + try: + data = await quart.request.get_json(silent=True) or {} + except Exception: + data = {} + if not isinstance(data, dict): + return self.http_status(400, -1, 'invalid request body') + + overrides = data.get('manifest_overrides') or {} + if not isinstance(overrides, dict): + return self.http_status(400, -1, 'manifest_overrides must be an object') + + try: + git_result = await self.ap.plugin_connector.git_sync_plugin( + author, + plugin_name, + repo_url=str(data.get('repo_url') or ''), + token=str(data.get('token') or ''), + branch=str(data.get('branch') or ''), + commit_message=str(data.get('commit_message') or ''), + manifest_overrides=overrides, + ) + except Exception as exc: + self.ap.logger.warning('Git sync failed for %s/%s: %s', author, plugin_name, exc) + return self.http_status(400, -1, str(exc)) + + return self.success(data={'git': git_result}) + @self.route( '///upgrade', methods=['POST'], @@ -616,15 +868,17 @@ class PluginsRouterGroup(group.RouterGroup): await self._require_authenticated_plugin_runtime_context(request_context) try: icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name) - except ValueError as exc: + if not icon_data or not icon_data.get('plugin_icon_base64'): + return quart.Response('Icon not found', status=404) + except (ValueError, ActionCallError) as exc: # A plugin that is not installed for this Workspace is a missing # resource, not a server fault: the WebUI asks for icons by plugin # identity and must be able to fall back silently. - if _is_plugin_not_installed(exc): + if isinstance(exc, ActionCallError) or _is_plugin_not_installed(exc): return quart.Response('Icon not found', status=404) raise icon_bytes = await asyncio.to_thread(base64.b64decode, icon_data['plugin_icon_base64']) - return quart.Response(icon_bytes, mimetype=icon_data['mime_type']) + return quart.Response(icon_bytes, mimetype=icon_data.get('mime_type', 'image/svg+xml')) @self.route( '///authenticated-assets/', @@ -642,7 +896,10 @@ class PluginsRouterGroup(group.RouterGroup): asset_path = _normalize_plugin_asset_path(filepath) if asset_path is None: return quart.Response('Asset not found', status=404) - asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path) + try: + asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path) + except (ValueError, ActionCallError): + return quart.Response('Asset not found', status=404) if not asset_data.get('asset_base64'): return quart.Response('Asset not found', status=404) asset_bytes = await asyncio.to_thread(base64.b64decode, asset_data['asset_base64']) @@ -654,14 +911,15 @@ class PluginsRouterGroup(group.RouterGroup): auth_type=group.AuthType.NONE, ) async def _(author: str, plugin_name: str) -> quart.Response: - await self._require_public_plugin_runtime_context() - icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name) - icon_base64 = icon_data['plugin_icon_base64'] - mime_type = icon_data['mime_type'] - - icon_data = await asyncio.to_thread(base64.b64decode, icon_base64) - - return quart.Response(icon_data, mimetype=mime_type) + try: + await self._require_public_plugin_runtime_context() + icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name) + if not icon_data or not icon_data.get('plugin_icon_base64'): + return quart.Response('Icon not found', status=404) + icon_bytes = await asyncio.to_thread(base64.b64decode, icon_data['plugin_icon_base64']) + return quart.Response(icon_bytes, mimetype=icon_data.get('mime_type', 'image/svg+xml')) + except (ValueError, ActionCallError, WorkspaceNotFoundError): + return quart.Response('Icon not found', status=404) @self.route( '///assets/', @@ -669,20 +927,23 @@ class PluginsRouterGroup(group.RouterGroup): auth_type=group.AuthType.NONE, ) async def _(author: str, plugin_name: str, filepath: str) -> quart.Response: - await self._require_public_plugin_runtime_context() - asset_path = _normalize_plugin_asset_path(filepath) - if asset_path is None: - return quart.Response('Asset not found', status=404) + try: + await self._require_public_plugin_runtime_context() + asset_path = _normalize_plugin_asset_path(filepath) + if asset_path is None: + return quart.Response('Asset not found', status=404) - asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path) - if not asset_data.get('asset_base64'): + asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path) + if not asset_data.get('asset_base64'): + return quart.Response('Asset not found', status=404) + asset_bytes = await asyncio.to_thread( + base64.b64decode, + asset_data['asset_base64'], + ) + mime_type = asset_data['mime_type'] + resp = quart.Response(asset_bytes, mimetype=mime_type) + except (ValueError, ActionCallError, WorkspaceNotFoundError): return quart.Response('Asset not found', status=404) - asset_bytes = await asyncio.to_thread( - base64.b64decode, - asset_data['asset_base64'], - ) - mime_type = asset_data['mime_type'] - resp = quart.Response(asset_bytes, mimetype=mime_type) # CSP for HTML pages served to sandboxed iframes (opaque origin). # 'self' doesn't work in sandboxed iframes — use actual server origin. if mime_type and mime_type.startswith('text/html'): diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 3f95ee7e4..9947598b6 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -59,6 +59,7 @@ SYSTEM_CAPABILITY_OPERATIONS = ( 'plugin.install.marketplace', 'plugin.install.local', 'plugin.upgrade', + 'plugin.space_upload', 'plugin.get', 'plugin.list', 'plugin.config.get', @@ -227,7 +228,10 @@ class SystemRouterGroup(group.RouterGroup): 'enable_marketplace', True ), 'cloud_service_url': ( - self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app') + self.ap.instance_config.data.get('space', {}).get( + 'web_url', + self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app'), + ) ), 'allow_modify_login_info': self.ap.instance_config.data.get('system', {}).get( 'allow_modify_login_info', True diff --git a/src/langbot/pkg/api/http/service/space.py b/src/langbot/pkg/api/http/service/space.py index c95713f56..0f0b87416 100644 --- a/src/langbot/pkg/api/http/service/space.py +++ b/src/langbot/pkg/api/http/service/space.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections import OrderedDict from langbot.pkg.utils import httpclient +import aiohttp import typing import datetime import time @@ -202,6 +203,33 @@ class SpaceService: return None return await self.get_user_info_raw(access_token) + async def get_creator_profile(self, access_token: str) -> typing.Tuple[bool, dict | None]: + """Fetch the authenticated user's Creator Profile from Space. + + Returns (is_token_valid, creator_dict_or_none). + """ + if not access_token: + return False, None + space_url = self._get_space_config()['url'].rstrip('/') + session = httpclient.get_session() + try: + async with session.get( + f'{space_url}/api/v1/creator/profile', + headers={'Authorization': f'Bearer {access_token}'}, + ) as response: + if response.status == 401: + return False, None + if response.status != 200: + return True, None + payload = await httpclient.read_json_limited(response) + if payload.get('code') != 0: + return True, None + data = payload.get('data') or {} + return True, data.get('creator') + except Exception: + self.ap.logger.exception('Failed to fetch creator profile from Space') + return True, None + async def get_credits(self, user_email: str, force_refresh: bool = False) -> int | None: """Get Space credits for user with caching (60s TTL)""" now = time.time() @@ -285,6 +313,57 @@ class SpaceService: models.append(selection) return [SpaceModelSelection.model_validate(model) for model in models] + # === Plugin publishing === + + async def publish_plugin( + self, + access_token: str, + package_bytes: bytes, + *, + filename: str = 'plugin.lbpkg', + changelog: str = '', + ) -> typing.Dict: + """Publish a built ``.lbpkg`` to the LangBot Space marketplace. + + Mirrors the CLI's ``POST /api/v1/marketplace/plugins/publish`` contract + (multipart ``file`` + ``changelog``). The caller supplies a valid Space + access token resolved from the Workspace owner. + """ + + if not access_token: + raise ValueError('A LangBot Space account is required to publish plugins') + + space_url = self._get_space_config()['url'].rstrip('/') + + form = aiohttp.FormData() + form.add_field('changelog', changelog or '') + form.add_field( + 'file', + package_bytes, + filename=filename, + content_type='application/octet-stream', + ) + + session = httpclient.get_session() + async with session.post( + f'{space_url}/api/v1/marketplace/plugins/publish', + data=form, + headers={'Authorization': f'Bearer {access_token}'}, + ) as response: + if response.status != 200: + try: + payload = await httpclient.read_json_limited(response) + msg = payload.get('msg') or payload.get('message') or '' + except Exception: + msg = '' + if not msg: + msg = await httpclient.read_text_limited(response) + raise ValueError(msg or 'Failed to publish plugin') + data = await httpclient.read_json_limited(response) + if data.get('code') != 0: + raise ValueError(data.get('msg') or 'Failed to publish plugin') + return data.get('data', {}) + async def get_recommended_chat_model(self, context: typing.Any) -> dict: """Resolve Space's first ranked chat model to a local Workspace model.""" selection = await self.get_model_selection('chat') diff --git a/src/langbot/pkg/discover/engine.py b/src/langbot/pkg/discover/engine.py index ce2b69690..267a332d2 100644 --- a/src/langbot/pkg/discover/engine.py +++ b/src/langbot/pkg/discover/engine.py @@ -78,6 +78,9 @@ class Metadata(pydantic.BaseModel): repository: typing.Optional[str] = None """仓库""" + license: typing.Optional[str] = None + """开源协议(SPDX 标识符,如 MIT / Apache-2.0)""" + def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/src/langbot/pkg/operation_trace/service.py b/src/langbot/pkg/operation_trace/service.py index 1d771645e..98e335e67 100644 --- a/src/langbot/pkg/operation_trace/service.py +++ b/src/langbot/pkg/operation_trace/service.py @@ -377,6 +377,14 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = ( bucket='write', resource_type='plugin', ), + # Building a debug plugin package and/or syncing it to GitHub and publishing + # it to LangBot Space. + ActionRule( + action='plugin_publish', + category='extension', + bucket='write', + resource_type='plugin', + ), ActionRule( action='page_view', category='extension', @@ -609,6 +617,15 @@ _ROUTE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str, str | None, str | N # opaque resource delete. Must precede the ``/config`` rule below, whose # fragment also matches ``config-files``. (('/plugins/', '/config-files'), 'plugin_view', 'plugin_config', 'plugin_config', 'plugin_view'), + # The debug-plugin "upload to LangBot Space" surface: building/syncing is a + # plugin change, never an opaque view. Must precede the bare plugin rules. + ( + ('/plugins/', '/space-upload'), + 'plugin_view', + 'plugin_publish', + None, + 'plugin_view', + ), (('/plugins/', '/config'), 'plugin_view', 'plugin_config', None, 'plugin_view'), (('/plugins/', '/page-api'), 'page_view', 'page_view', None, 'page_view'), (('/plugins/', '/upgrade'), 'plugin_view', 'plugin_upgrade', None, 'plugin_view'), diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 9a0b69886..b31cac262 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -2285,6 +2285,64 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): return plugins + async def _debug_plugin_binding( + self, + author: str, + plugin_name: str, + ) -> InstallationBinding | None: + """Locate a debug-connected (non-installed) plugin in this Workspace. + + Debug plugins are not persisted as settings, so they are only reachable + through the OSS compatibility bridge binding. Returns ``None`` when the + identity is not present on that bridge, so the caller can distinguish a + genuinely unknown plugin from a permission error. + """ + + if self.runtime_profile != 'oss_dev': + return None + execution_context = await self._current_execution_context() + runtime_handler = self._runtime_handler() + binding = self._legacy_oss_bridge_binding(execution_context) + with runtime_handler.installation_scope(binding): + info = await runtime_handler.get_plugin_info(author, plugin_name) + if not isinstance(info, dict) or not info.get('manifest'): + return None + return binding + + async def _plugin_operation_binding( + self, + author: str, + plugin_name: str, + ) -> InstallationBinding: + """Resolve the binding that owns a plugin, installed or debug-connected.""" + + try: + return await self._target_binding(author, plugin_name, require_enabled=False) + except ValueError as exc: + if str(exc) == f'Plugin {author}/{plugin_name} is not installed in this Workspace': + binding = await self._debug_plugin_binding(author, plugin_name) + if binding is not None: + return binding + raise + + async def get_debug_plugin_info( + self, + author: str, + plugin_name: str, + ) -> dict[str, Any] | None: + """Return a debug-connected plugin's info without touching installs. + + This is an additive read used only by the "upload to Space" surface; the + installed-plugin lookup in :meth:`get_plugin_info` is left unchanged. + """ + + binding = await self._debug_plugin_binding(author, plugin_name) + if binding is None: + return None + runtime_handler = self._runtime_handler() + with runtime_handler.installation_scope(binding): + return await runtime_handler.get_plugin_info(author, plugin_name) + async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any] | None: runtime_handler = self._runtime_handler() try: @@ -2397,6 +2455,52 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): return {} return await self._runtime_handler().get_debug_info(execution_context) + async def get_plugin_package( + self, + author: str, + plugin_name: str, + *, + manifest_overrides: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Build a ``.lbpkg`` from a debug plugin's live source tree. + + Only debug plugins can be packaged: an installed plugin's on-disk + directory is an extracted artifact, not the developer's source tree. + """ + runtime_handler = self._runtime_handler() + binding = await self._plugin_operation_binding(author, plugin_name) + with runtime_handler.installation_scope(binding): + return await runtime_handler.get_plugin_package( + author, + plugin_name, + manifest_overrides=manifest_overrides, + ) + + async def git_sync_plugin( + self, + author: str, + plugin_name: str, + *, + repo_url: str = '', + token: str = '', + branch: str = '', + commit_message: str = '', + manifest_overrides: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Commit and push a debug plugin's working directory to GitHub.""" + runtime_handler = self._runtime_handler() + binding = await self._plugin_operation_binding(author, plugin_name) + with runtime_handler.installation_scope(binding): + return await runtime_handler.git_sync_plugin( + author, + plugin_name, + repo_url=repo_url, + token=token, + branch=branch, + commit_message=commit_message, + manifest_overrides=manifest_overrides, + ) + async def emit_event( self, event: events.BaseEventModel, diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 0b87218d0..05a17cbab 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -3294,6 +3294,65 @@ class RuntimeConnectionHandler(handler.Handler): ) return result + async def get_plugin_package( + self, + plugin_author: str, + plugin_name: str, + *, + manifest_overrides: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Build a ``.lbpkg`` from a debug plugin's live source tree. + + The Runtime returns the package over the file-transfer channel; the + bytes are read back here so callers receive the package inline. + """ + result = await self.call_action( + _langbot_to_runtime_action('BUILD_PLUGIN_PACKAGE', 'build_plugin_package'), + { + 'plugin_author': plugin_author, + 'plugin_name': plugin_name, + 'manifest_overrides': manifest_overrides or {}, + }, + timeout=300, + ) + package_file_key = result.get('package_file_key') + package_bytes = b'' + if package_file_key: + package_bytes = await self.read_local_file(package_file_key) + await self.delete_local_file(package_file_key) + return { + 'package': package_bytes, + 'filename': result.get('filename', ''), + 'metadata': result.get('metadata', {}), + 'manifest': result.get('manifest', {}), + } + + async def git_sync_plugin( + self, + plugin_author: str, + plugin_name: str, + *, + repo_url: str = '', + token: str = '', + branch: str = '', + commit_message: str = '', + manifest_overrides: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Commit and push a debug plugin's working directory to GitHub.""" + return await self.call_action( + _langbot_to_runtime_action('GIT_SYNC_PLUGIN', 'git_sync_plugin'), + { + 'plugin_author': plugin_author, + 'plugin_name': plugin_name, + 'repo_url': repo_url, + 'token': token, + 'branch': branch, + 'commit_message': commit_message, + 'manifest_overrides': manifest_overrides or {}, + }, + timeout=300, + ) + # ================= RAG Capability Callers (LangBot -> Runtime) ================= async def rag_ingest_document( diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index 568606cc3..924f83cb4 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -530,6 +530,7 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi 'plugin.config.get', 'plugin.config.update', 'plugin.logs', + 'plugin.space_upload', 'plugin.delete', 'provider.list', 'provider.get', diff --git a/tests/unit_tests/utils/test_safe_regex.py b/tests/unit_tests/utils/test_safe_regex.py index 35632a8bb..d6a2e2732 100644 --- a/tests/unit_tests/utils/test_safe_regex.py +++ b/tests/unit_tests/utils/test_safe_regex.py @@ -75,11 +75,14 @@ async def test_bundled_sensitive_words_fit_within_pattern_limit(): @pytest.mark.asyncio async def test_mask_patterns_honors_explicit_pattern_count_cap(): patterns = ['a'] * (safe_regex.MAX_PATTERN_COUNT + 6) + # The wall-clock deadline is not what this test asserts, and 262 pattern + # compilations do not fit the 50ms default on a loaded CI runner. found, masked = await safe_regex.mask_patterns( patterns, 'hello', mask='*', mask_word='', + timeout_seconds=30.0, max_pattern_count=len(patterns), ) assert found is False diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 9f2fbbe59..c2738ffff 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -688,11 +688,14 @@ function NavItems({ // Shared entity list renderer used by both popover and collapsible const renderEntityList = (inPopover: boolean) => { const sortedItems = isExtensionsCategory - ? [...items].sort((a, b) => - a.name.localeCompare(b.name, undefined, { + ? [...items].sort((a, b) => { + const aDebug = Boolean(a.debug); + const bDebug = Boolean(b.debug); + if (aDebug !== bDebug) return aDebug ? -1 : 1; + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base', - }), - ) + }); + }) : sortByRecent(items); const isExpanded = expandedLists[section]?.[categoryId] ?? false; const maxItems = inPopover ? 10 : MAX_VISIBLE_ITEMS; @@ -833,6 +836,9 @@ function NavItems({ {t('bots.legacyAdapterBadge')} )} + {item.debug && ( + + )} ); } @@ -924,7 +930,7 @@ function NavItems({ )} {item.debug && ( - + )} diff --git a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx index aeef4895d..5ad8193ba 100644 --- a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx +++ b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx @@ -228,7 +228,13 @@ export function SidebarDataProvider({ pluginMap.set(compositeKey, item); } } - setPlugins(Array.from(pluginMap.values())); + const pluginItems = Array.from(pluginMap.values()).sort((a, b) => { + const aDebug = Boolean(a.debug); + const bDebug = Boolean(b.debug); + if (aDebug !== bDebug) return aDebug ? -1 : 1; + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); + setPlugins(pluginItems); // Extract plugin pages from spec.pages (deduplicate by id) const pages: PluginPageItem[] = []; diff --git a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx index 2add99f3e..acaca8268 100644 --- a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx @@ -1,7 +1,8 @@ import { ExtensionCardVO, ExtensionType } from './ExtensionCardVO'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { useTranslation } from 'react-i18next'; +import { cn } from '@/lib/utils'; import { BugIcon, ExternalLink, @@ -11,6 +12,7 @@ import { Server, Sparkles, Puzzle, + Upload, } from 'lucide-react'; import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http'; import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon'; @@ -28,6 +30,8 @@ type ExtensionCardComponentProps = { onCardClick: () => void; onDeleteClick: (cardVO: ExtensionCardVO) => void; onUpgradeClick?: (cardVO: ExtensionCardVO) => void; + /** Upload a debug-connected plugin to LangBot Space. */ + onUploadToSpace?: (cardVO: ExtensionCardVO) => void; }; export default function ExtensionCardComponent({ @@ -35,6 +39,7 @@ export default function ExtensionCardComponent({ onCardClick, onDeleteClick, onUpgradeClick, + onUploadToSpace, }: ExtensionCardComponentProps) { const { t } = useTranslation(); const [dropdownOpen, setDropdownOpen] = useState(false); @@ -53,6 +58,13 @@ export default function ExtensionCardComponent({ const iconSrc = cardVO.type === 'plugin' ? pluginIconURL : cardVO.iconURL; const showFallback = iconFailed || !iconSrc; + useEffect(() => { + setIconFailed(false); + }, [iconSrc]); + // Debug plugins can always be uploaded to LangBot Space; surface the same + // persistent red-dot reminder as an available plugin update. + const hasSpaceUpload = cardVO.type === 'plugin' && !!cardVO.debug; + const getTypeLabel = (type: ExtensionType) => { switch (type) { case 'mcp': @@ -101,15 +113,6 @@ export default function ExtensionCardComponent({ v{cardVO.version} {renderTypeBadge(cardVO.type)} - {cardVO.debug && ( - - - {t('plugins.debugging')} - - )} {!cardVO.debug && ( <> {cardVO.install_source === 'github' && ( @@ -221,10 +224,51 @@ export default function ExtensionCardComponent({ return ( <> onCardClick()} > -
+ {cardVO.debug && ( +
+
+ + + {t('plugins.debugging')} + + + + + +
+ + {cardVO.type === 'plugin' && onUploadToSpace && ( + + )} +
+ )} + +
{showFallback ? (
@@ -264,12 +308,27 @@ export default function ExtensionCardComponent({ - {cardVO.hasUpdate && ( + {(cardVO.hasUpdate || hasSpaceUpload) && (
)}
+ {cardVO.type === 'plugin' && cardVO.debug && ( + { + e.stopPropagation(); + onUploadToSpace?.(cardVO); + setDropdownOpen(false); + }} + > + + {t('plugins.spaceUpload.menuAction')} + {/* Persistent red-dot reminder, matching the update dot. */} + + + )} {cardVO.type === 'plugin' && cardVO.install_source === 'marketplace' && ( { + const aDebug = Boolean(a.debug); + const bDebug = Boolean(b.debug); + if (aDebug !== bDebug) return aDebug ? -1 : 1; + return (a.label || a.name).localeCompare(b.label || b.name, undefined, { + sensitivity: 'base', + }); + }); +} + interface PluginInstalledComponentProps { filterType: FilterType; groupByType: boolean; @@ -87,6 +101,8 @@ const PluginInstalledComponent = forwardRef< const [targetExtension, setTargetExtension] = useState(null); const [deleteData, setDeleteData] = useState(false); + const [spaceUploadTarget, setSpaceUploadTarget] = + useState(null); const asyncTask = useAsyncTask({ onSuccess: () => { @@ -204,7 +220,7 @@ const PluginInstalledComponent = forwardRef< } } - setExtensionList(extensions); + setExtensionList(sortExtensionsWithDebugFirst(extensions)); } catch (error) { console.error('Failed to fetch extension list:', error); if (!silent) setExtensionList([]); @@ -326,13 +342,15 @@ const PluginInstalledComponent = forwardRef< // Match the query against the fields a user can actually see on the card // (label / name / author) plus the description, case-insensitively. const normalizedQuery = searchQuery.trim().toLowerCase(); - const filteredExtensions = extensionList.filter((ext) => { - if (filterType !== 'all' && ext.type !== filterType) return false; - if (!normalizedQuery) return true; - return [ext.label, ext.name, ext.author, ext.description].some((field) => - (field || '').toLowerCase().includes(normalizedQuery), - ); - }); + const filteredExtensions = sortExtensionsWithDebugFirst( + extensionList.filter((ext) => { + if (filterType !== 'all' && ext.type !== filterType) return false; + if (!normalizedQuery) return true; + return [ext.label, ext.name, ext.author, ext.description].some((field) => + (field || '').toLowerCase().includes(normalizedQuery), + ); + }), + ); const clearSearch = () => { onClearSearch?.(); @@ -344,7 +362,9 @@ const PluginInstalledComponent = forwardRef< .map((type) => ({ type, labelKey: FilterOptions.find((o) => o.value === type)!.labelKey, - items: filteredExtensions.filter((ext) => ext.type === type), + items: sortExtensionsWithDebugFirst( + filteredExtensions.filter((ext) => ext.type === type), + ), })) .filter((g) => g.items.length > 0); @@ -532,6 +552,7 @@ const PluginInstalledComponent = forwardRef< ? () => handleExtensionUpdate(vo) : undefined } + onUploadToSpace={() => setSpaceUploadTarget(vo)} />
))} @@ -553,12 +574,28 @@ const PluginInstalledComponent = forwardRef< ? () => handleExtensionUpdate(vo) : undefined } + onUploadToSpace={() => setSpaceUploadTarget(vo)} />
); })} )} + + {spaceUploadTarget && ( + { + if (!open) setSpaceUploadTarget(null); + }} + onUploaded={() => { + getExtensionList(true); + refreshPlugins(); + }} + /> + )} ); }); diff --git a/web/src/app/home/plugins/components/plugin-installed/PluginSpaceUploadDialog.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginSpaceUploadDialog.tsx new file mode 100644 index 000000000..df63023af --- /dev/null +++ b/web/src/app/home/plugins/components/plugin-installed/PluginSpaceUploadDialog.tsx @@ -0,0 +1,1412 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from '@/components/ui/card'; +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { + Loader2, + Upload, + AlertTriangle, + Check, + CheckCircle2, + ArrowLeft, + ArrowRight, + ExternalLink, + Info, + RefreshCw, + Sparkles, + AlertCircle, + ShieldCheck, + Puzzle, + Globe, +} from 'lucide-react'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource'; +import type { PluginSpaceUploadConfig } from '@/app/infra/entities/plugin'; +import type { I18nObject } from '@/app/infra/entities/common'; +import { cn } from '@/lib/utils'; + +/** + * Locales supported for manifest preview. + * English (en_US) is placed first as the canonical manifest default. + */ +const LOCALES: { code: string; label: string }[] = [ + { code: 'en_US', label: 'English' }, + { code: 'zh_Hans', label: '简体中文' }, + { code: 'zh_Hant', label: '繁體中文' }, + { code: 'ja_JP', label: '日本語' }, + { code: 'th_TH', label: 'ไทย' }, + { code: 'vi_VN', label: 'Tiếng Việt' }, + { code: 'es_ES', label: 'Español' }, +]; + +/** Extract a readable message from the backend's ``{code, msg}`` error shape. */ +function formatError(error: unknown): string { + if (typeof error === 'string') return error; + if (error && typeof error === 'object') { + const record = error as { + msg?: string; + message?: string; + response?: { data?: { msg?: string; message?: string } }; + data?: { msg?: string; message?: string }; + }; + if (record.response?.data?.msg) return record.response.data.msg; + if (record.response?.data?.message) return record.response.data.message; + if (record.data?.msg) return record.data.msg; + if (record.data?.message) return record.data.message; + if (record.msg) return record.msg; + if (record.message) return record.message; + try { + return JSON.stringify(error); + } catch { + return String(error); + } + } + return String(error); +} + +function localizeUploadError( + error: unknown, + t: (key: string, options?: Record) => string, +): string { + const raw = formatError(error); + if (!raw) return ''; + + if (/set up a creator profile/i.test(raw)) { + return t('plugins.spaceUpload.errors.noCreatorProfile'); + } + + const mismatchMatch = raw.match( + /plugin author ['"]?([^'"]+)['"]? does not match .*?@['"]?([^'"]+)['"]?/i, + ); + if (mismatchMatch) { + return t('plugins.spaceUpload.errors.authorMismatch', { + author: mismatchMatch[1], + slug: mismatchMatch[2], + }); + } + + if (/already have a plugin submission pending/i.test(raw)) { + return t('plugins.spaceUpload.errors.pendingReview'); + } + + if (/session has expired|re-authenticate|unauthorized/i.test(raw)) { + return t('plugins.spaceUpload.errors.sessionExpired'); + } + + if (/connect a LangBot Space account/i.test(raw)) { + return t('plugins.spaceUpload.errors.notConnected'); + } + + if (/failed certification precheck/i.test(raw)) { + const detail = raw.replace(/^failed certification precheck:\s*/i, ''); + return t('plugins.spaceUpload.errors.certPrecheckFailed', { detail }); + } + + if (/failed to inspect plugin/i.test(raw)) { + const detail = raw.replace(/^failed to inspect plugin:\s*/i, ''); + return t('plugins.spaceUpload.errors.inspectFailed', { detail }); + } + + if (/failed to build the plugin package/i.test(raw)) { + return t('plugins.spaceUpload.errors.packageFailed'); + } + + return raw; +} + +interface UploadErrorState { + type: + | 'pendingReview' + | 'sessionExpired' + | 'notConnected' + | 'noCreator' + | 'authorMismatch' + | 'validation' + | 'generic'; + detail?: string; + author?: string; + slug?: string; +} + +function parseUploadError(error: unknown): UploadErrorState { + const raw = formatError(error); + if (/already have a plugin submission pending/i.test(raw)) { + return { type: 'pendingReview' }; + } + if (/session has expired|re-authenticate|unauthorized/i.test(raw)) { + return { type: 'sessionExpired' }; + } + if (/set up a creator profile/i.test(raw)) { + return { type: 'noCreator' }; + } + const mismatchMatch = raw.match( + /plugin author ['"]?([^'"]+)['"]? does not match .*?@['"]?([^'"]+)['"]?/i, + ); + if (mismatchMatch) { + return { + type: 'authorMismatch', + author: mismatchMatch[1], + slug: mismatchMatch[2], + }; + } + if (/connect a LangBot Space account/i.test(raw)) { + return { type: 'notConnected' }; + } + if (/failed certification precheck|failed to inspect/i.test(raw)) { + return { + type: 'validation', + detail: raw.replace( + /^(failed certification precheck|failed to inspect plugin):\s*/i, + '', + ), + }; + } + return { + type: 'generic', + detail: raw, + }; +} + +type I18nDraft = Record; + +function toDraft(value: I18nObject | string | undefined): I18nDraft { + const draft: I18nDraft = {}; + if (value === undefined || value === null) return draft; + if (typeof value === 'string') { + draft.en_US = value; + return draft; + } + for (const locale of LOCALES) { + const text = value[locale.code as keyof I18nObject]; + if (typeof text === 'string') draft[locale.code] = text; + } + return draft; +} + +export default function PluginSpaceUploadDialog({ + author, + name, + open, + onOpenChange, + onUploaded, +}: { + author: string; + name: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onUploaded?: () => void; +}) { + const { t } = useTranslation(); + const authenticatedIcon = useAuthenticatedPluginIcon(author, name, true); + const [iconFailed, setIconFailed] = useState(false); + + useEffect(() => { + setIconFailed(false); + }, [open, authenticatedIcon.url]); + + // Stepper state: 1 = Plugin Info Confirmation, 2 = Release Notes, 3 = Review & Upload + const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1); + + const [loadingConfig, setLoadingConfig] = useState(false); + const [loadError, setLoadError] = useState(false); + const [config, setConfig] = useState(null); + + // Manifest fields (strictly read-only confirmation) + const [labelDraft, setLabelDraft] = useState({}); + const [descriptionDraft, setDescriptionDraft] = useState({}); + const [version, setVersion] = useState(''); + const [repository, setRepository] = useState(''); + const [authorName, setAuthorName] = useState(author); + const [license, setLicense] = useState(''); + + // Step 2 input: Changelog + const [changelog, setChangelog] = useState(''); + + // Space Account Binding action + const [bindingSpace, setBindingSpace] = useState(false); + + // Multi-language tab selection defaults to English + const [activeLocale, setActiveLocale] = useState('en_US'); + const [submitting, setSubmitting] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [uploadStatus, setUploadStatus] = useState< + 'idle' | 'uploading' | 'success' | 'error' + >('idle'); + const [uploadProgress, setUploadProgress] = useState(0); + + const loadConfig = useMemo( + () => async () => { + setLoadingConfig(true); + setLoadError(false); + try { + const info = await httpClient.getPluginSpaceUploadConfig(author, name); + setConfig(info); + setLabelDraft(toDraft(info.metadata.label)); + setDescriptionDraft(toDraft(info.metadata.description)); + setVersion(info.metadata.version ?? ''); + setRepository(info.metadata.repository ?? ''); + setAuthorName(info.metadata.author ?? author); + setLicense(info.metadata.license ?? ''); + } catch { + setLoadError(true); + toast.error(t('plugins.spaceUpload.loadFailed')); + } finally { + setLoadingConfig(false); + } + }, + [author, name, t], + ); + + useEffect(() => { + if (open) { + setCurrentStep(1); + setActiveLocale('en_US'); + setUploadError(null); + setUploadStatus('idle'); + setUploadProgress(0); + void loadConfig(); + } + }, [open, loadConfig]); + + // Display name prioritizes active locale, then English fallback, then first available + const displayName = useMemo(() => { + if (labelDraft[activeLocale]) return labelDraft[activeLocale]; + if (labelDraft.en_US) return labelDraft.en_US; + if (labelDraft.zh_Hans) return labelDraft.zh_Hans; + const first = Object.values(labelDraft).find((v) => Boolean(v)); + if (first) return first; + return name; + }, [labelDraft, activeLocale, name]); + + const displayDescription = useMemo(() => { + if (descriptionDraft[activeLocale]) return descriptionDraft[activeLocale]; + if (descriptionDraft.en_US) return descriptionDraft.en_US; + if (descriptionDraft.zh_Hans) return descriptionDraft.zh_Hans; + const first = Object.values(descriptionDraft).find((v) => Boolean(v)); + if (first) return first; + return ''; + }, [descriptionDraft, activeLocale]); + + async function handleBindSpace() { + setBindingSpace(true); + try { + const currentOrigin = window.location.origin; + const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`; + const response = await httpClient.getSpaceBindAuthorizeUrl(redirectUri); + window.location.href = response.authorize_url; + } catch { + toast.error(t('common.spaceLoginFailed')); + setBindingSpace(false); + } + } + + async function handleStartUpload() { + setCurrentStep(3); + setUploadStatus('uploading'); + setSubmitting(true); + setUploadError(null); + setUploadProgress(15); + + const timer = setInterval(() => { + setUploadProgress((prev) => { + if (prev >= 88) return prev; + return prev + Math.floor(Math.random() * 12 + 6); + }); + }, 300); + + try { + await httpClient.uploadPluginToSpace(author, name, { + sync_github: false, + changelog: changelog.trim(), + manifest_overrides: {}, + }); + clearInterval(timer); + setUploadProgress(100); + setUploadStatus('success'); + onUploaded?.(); + } catch (error) { + clearInterval(timer); + const parsed = parseUploadError(error); + setUploadError(parsed); + setUploadStatus('error'); + if ( + parsed.type === 'notConnected' || + parsed.type === 'noCreator' || + parsed.type === 'authorMismatch' + ) { + void loadConfig(); + } + } finally { + clearInterval(timer); + setSubmitting(false); + } + } + + const prerequisitesMet = Boolean( + config?.space_connected && config?.has_creator_profile, + ); + + const busy = submitting || bindingSpace || uploadStatus === 'uploading'; + + const canSubmit = Boolean( + prerequisitesMet && + config?.author_matches && + !busy && + !loadingConfig && + !loadError, + ); + + const steps = [ + { id: 1, title: t('plugins.spaceUpload.stepInfo') }, + { id: 2, title: t('plugins.spaceUpload.stepConfirm') }, + { id: 3, title: t('plugins.spaceUpload.stepResult') }, + ]; + + return ( + !busy && onOpenChange(next)}> + + {/* Dialog Header */} + + + + {t('plugins.spaceUpload.title')} + + + {t('plugins.spaceUpload.description', { author, name })} + + + + {/* Stepper Progress */} + {prerequisitesMet && !loadingConfig && !loadError && ( +
+
+ {steps.map((step, idx) => { + const stepNum = idx + 1; + const isCompleted = currentStep > stepNum; + const isActive = currentStep === stepNum; + return ( +
+ + {idx < steps.length - 1 && ( +
stepNum ? 'bg-blue-600' : 'bg-border', + )} + /> + )} +
+ ); + })} +
+
+ )} + + {/* Dialog Body Content */} +
+ {loadingConfig ? ( +
+ + {t('common.loading')} +
+ ) : loadError ? ( + + + + {t('plugins.spaceUpload.loadFailed')} + + + + 无法获取插件上传配置信息,请检查后台服务连接。 + + + + + ) : !prerequisitesMet ? ( + /* Dedicated Prerequisites Gate Page */ +
+ + + + {t('plugins.spaceUpload.prerequisitesTitle')} + + + {t('plugins.spaceUpload.prerequisitesDescription')} + + + + {/* Requirement 1: Space Account Binding */} + {(() => { + const isSpaceConnected = Boolean(config?.space_connected); + return ( + + +
+
+ {isSpaceConnected ? ( + + ) : ( + + )} +
+
+ + {t('plugins.spaceUpload.req1Title')} + + + {t('plugins.spaceUpload.req1Desc')} + +
+
+ +
+ {isSpaceConnected ? ( + + + {t('plugins.spaceUpload.reqBound')} + + ) : ( + + )} +
+
+
+ ); + })()} + + {/* Requirement 2: Creator Identity */} + {(() => { + const isCreatorReady = Boolean(config?.has_creator_profile); + return ( + + +
+
+ {isCreatorReady ? ( + + ) : ( + + )} +
+
+ + {t('plugins.spaceUpload.req2Title')} + + + {t('plugins.spaceUpload.req2Desc')} + + + {/* Status explanation */} + {!config?.space_connected ? ( +

+ + + {t('plugins.spaceUpload.req2WaitSpace')} + +

+ ) : !config?.has_creator_profile ? ( +

+ + + {t('plugins.spaceUpload.req2NoCreator')} + +

+ ) : ( +

+ + @{config?.creator_slug} +

+ )} +
+
+ +
+ {isCreatorReady ? ( + + + {t('plugins.spaceUpload.reqCompleted')} + + ) : !config?.space_connected ? ( + + {t('plugins.spaceUpload.pendingReq')} + + ) : !config?.has_creator_profile ? ( + + ) : ( + + )} +
+
+
+ ); + })()} +
+ ) : ( + <> + {/* STEP 1: Plugin Card with Language Switcher + Changelog */} + {currentStep === 1 && ( +
+ {/* Clean Section Header */} +
+
+ + {t('plugins.spaceUpload.stepInfo')} + + ({t('plugins.spaceUpload.manifestSource')}) + +
+ + 点击下方语种切换实时预览 + +
+ + {/* Dedicated Full-Width Language Switcher Bar - Single clean line, never wraps */} +
+
+ + 多语言预览 +
+
+ {LOCALES.map((locale) => { + const hasTranslation = Boolean( + labelDraft[locale.code] || + descriptionDraft[locale.code], + ); + const isSelected = activeLocale === locale.code; + return ( + + ); + })} +
+
+ + {/* Plugin Card with Fixed-Height Layout: Zero Jitter on Language Switch */} +
+
+ {/* Plugin Icon */} +
+ {authenticatedIcon.url && !iconFailed ? ( + plugin icon setIconFailed(true)} + /> + ) : ( +
+ +
+ )} +
+ + {/* Plugin Card Details */} +
+
+ + {author} / {name} + + + {LOCALES.find((l) => l.code === activeLocale) + ?.label || activeLocale} + +
+ + {/* Title and Badges: Fixed single line, no vertical shift */} +
+

+ {displayName} +

+ + v{version || '0.1.0'} + + + + {t('market.typePlugin', '插件')} + + {license && ( + + {license} + + )} +
+ + {/* Description: Fixed 3-line height box so switching locales never causes height jump */} +
+

+ {displayDescription || ( + + {t( + 'plugins.spaceUpload.noDescription', + '(该语言暂无描述)', + )} + + )} +

+
+
+
+ + {/* Card Meta Sub-row: Fixed height */} +
+
+ + {t('plugins.spaceUpload.author')}:{' '} + + {authorName} + + + {repository && ( + + + {repository} + + + + )} +
+
+ + {activeLocale} +
+
+
+ + {/* Changelog Input directly in Step 1 */} +
+
+ + + {t('plugins.spaceUpload.changelogHint')} + +
+