import { html, nothing } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.js'; // Connector marketplace — blueprint §14/§15. // // Admin-only: browses the remote feed of vetted connectors and *installs* one into // the local catalog. Installing is deliberately not activating — a global entry // still needs the admin to enable it with a key, a per-user one still needs each // user to activate it from the Connectors page. The feed only ever proposes; the // trust anchor stays on this box. // // Page shell from the shared `um-*` styling; the card grid, chips and filter bar // live in `css/connectors.css`. Colours come from the theme's own variables — no // literal colour belongs in here. const ADMIN_ID = 'admin'; async function jf(url, opts) { const res = await fetch(url, opts); if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`); const ct = res.headers.get('content-type') || ''; return ct.includes('application/json') ? res.json() : null; } export class MarketplacePage extends LightElement { static get properties() { return { _open: { state: true }, _me: { state: true }, _cards: { state: true }, _feedErr: { state: true }, // feed unreachable — scoped, not page-level _error: { state: true }, _q: { state: true }, _scope: { state: true }, // 'all' | 'per_user' | 'global' _source: { state: true }, // 'all' | 'remote' | 'local_script' _installing: { state: true }, }; } constructor() { super(); this._open = false; this._q = ''; this._scope = 'all'; this._source = 'all'; this._reset(); } _reset() { this._me = null; this._cards = null; this._feedErr = null; this._error = null; this._installing = null; } connectedCallback() { super.connectedCallback(); this.__onLocaleChanged = () => this.requestUpdate(); window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'marketplace'; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._load(); }); } disconnectedCallback() { window.removeEventListener('locale-changed', this.__onLocaleChanged); super.disconnectedCallback(); } get _isAdmin() { return this._me?.role_id === ADMIN_ID; } async _load() { this._error = null; try { this._me = await jf('/api/auth/me'); if (!this._isAdmin) return; await this._loadFeed(false); } catch (e) { this._error = e.message; } } async _loadFeed(refresh) { this._feedErr = null; if (refresh) this._cards = null; try { const res = await jf(`/api/mcp/marketplace${refresh ? '?refresh=true' : ''}`); this._cards = res.connectors ?? []; } catch (e) { this._cards = []; this._feedErr = e.message; } } async _install(card) { const warn = card.source === 'local_script' ? '\n\n' + t('marketplace.confirm.install_warn', { n: card.file_count, id: card.id }) : ''; if (!confirm(t('marketplace.confirm.install_body', { name: card.name }) + warn)) return; this._installing = card.id; this._error = null; try { await jf('/api/mcp/marketplace/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: card.id }), }); await this._loadFeed(false); } catch (e) { this._error = e.message; } finally { this._installing = null; } } // Client-side: the feed is small, and one payload keeps typing instant. get _filtered() { const q = this._q.trim().toLowerCase(); return (this._cards ?? []).filter((c) => { if (this._scope !== 'all' && c.scope !== this._scope) return false; if (this._source !== 'all' && c.source !== this._source) return false; if (!q) return true; const hay = [c.name, c.id, c.user_description, ...(c.tags ?? []), ...(c.requires ?? [])] .filter(Boolean).join(' ').toLowerCase(); return hay.includes(q); }); } // The marketplace is a destination of the catalog's "Add connector" action, not a // place of its own — so it goes back where it came from. _goCatalog() { history.pushState({ page: 'catalog' }, '', '#catalog'); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'catalog' } })); } render() { if (!this._open) return nothing; const loading = this._cards === null && !this._feedErr && !this._error; return html`
${t('marketplace.not_admin')}
${unsafeHTML(t('marketplace.not_admin_link'))}
${t('marketplace.loading')}
${total === 0 ? t('marketplace.grid.empty_feed') : t('marketplace.grid.no_match')}
${s}`)}