mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-03 22:32:27 +08:00
feat(plugin): upload debug plugin to LangBot Space with optional GitH… (#2612)
* 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 <[email protected]> * style(i18n): reflow reworded locale strings for prettier Co-authored-by: multica-agent <[email protected]> * test(utils): stop timing out the pattern-count cap test on loaded runners Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Hyu <[email protected]> Co-authored-by: RockChinQ <[email protected]> Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
Hyu
RockChinQ
parent
4687fc96e7
commit
4b537973f9
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
'/<author>/<plugin_name>/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(
|
||||
'/<author>/<plugin_name>/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(
|
||||
'/<author>/<plugin_name>/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(
|
||||
'/<author>/<plugin_name>/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(
|
||||
'/<author>/<plugin_name>/authenticated-assets/<path:filepath>',
|
||||
@@ -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(
|
||||
'/<author>/<plugin_name>/assets/<path:filepath>',
|
||||
@@ -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'):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user