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.title')}

${this._isAdmin ? html` ` : nothing}
${this._error ? html`
${this._error}
` : nothing} ${this._me && !this._isAdmin ? html`

${t('marketplace.not_admin')}

${unsafeHTML(t('marketplace.not_admin_link'))}

` : html`
${unsafeHTML(t('marketplace.desc'))}
${this._feedErr ? html`
${t('marketplace.feed_unreachable', { error: this._feedErr })}
` : nothing} ${this._renderFilters()} ${loading ? html`

${t('marketplace.loading')}

` : this._renderGrid()} `}
`; } // A segmented control per axis rather than loose buttons: each row is one choice, // and the grouping says so. _segment(label, current, set, options) { return html`
${label}
${options.map(([text, value]) => html` `)}
`; } _renderFilters() { return html`
${this._segment(t('marketplace.filter.scope'), this._scope, (v) => { this._scope = v; }, [[t('marketplace.filter.all'), 'all'], [t('marketplace.filter.global'), 'global'], [t('marketplace.filter.per_user'), 'per_user']])} ${this._segment(t('marketplace.filter.type'), this._source, (v) => { this._source = v; }, [[t('marketplace.filter.all'), 'all'], [t('marketplace.filter.remote'), 'remote'], [t('marketplace.filter.local'), 'local_script']])}
`; } _renderGrid() { const cards = this._filtered; const total = (this._cards ?? []).length; if (cards.length === 0) { return html`

${total === 0 ? t('marketplace.grid.empty_feed') : t('marketplace.grid.no_match')}

`; } return html`
${cards.map((c) => this._renderCard(c))}
`; } _renderCard(c) { const busy = this._installing === c.id; const isScript = c.source === 'local_script'; // Keywords only. `mcp` is on everything, and scope/type already have their own // chips — repeating them as grey tags is noise. const tags = (c.tags ?? []).filter((t) => !['mcp', 'local', 'remote'].includes(t)); return html`
${c.has_icon ? html`` : html`
`}
${c.name}
${c.id}${c.version ? ` · v${c.version}` : ''}
${c.installed ? html`${t('marketplace.card.installed')}` : nothing}
${c.user_description ? html`
${c.user_description}
` : nothing}
${c.scope === 'global' ? t('marketplace.card.scope_global') : t('marketplace.card.scope_per_user')} ${isScript ? t('marketplace.card.type_script') : t('marketplace.card.type_remote')} ${c.auth_kind !== 'none' ? html` ${c.auth_kind}` : nothing} ${tags.map((t) => html`${t}`)}
${isScript ? html`
${t(c.file_count === 1 ? 'marketplace.card.files_one' : 'marketplace.card.files_other', { n: c.file_count })}
` : nothing} ${c.oauth_scopes?.length ? html`
${t(c.oauth_scopes.length === 1 ? 'marketplace.card.oauth_scopes_one' : 'marketplace.card.oauth_scopes_other', { n: c.oauth_scopes.length })} ${c.oauth_scopes.map((s) => html`${s}`)}
` : nothing}
${c.homepage ? html` ` : nothing}
`; } }