diff --git a/CLAUDE.md b/CLAUDE.md index a77e048..8b63aa2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,7 +190,7 @@ MCP servers are surfaced to users as **"Connectors"** (UI naming; `mcp`/schema s **Tables** (see DB section) — registry: `mcp_catalog` (admin-vetted templates; holds only the *schema* of what an activation must supply, never live creds — plus, for OAuth, `oauth_provider` + `oauth_scopes_json` + `deliver_json`), `mcp_global_servers` + `mcp_global_access`, `oauth_providers` (per-provider client creds), `role_capabilities`. Owner: `mcp_user_servers` (per-user activations; `api_key` encrypted at rest — the refresh token for an OAuth one — `catalog_name`/`oauth_provider`/`deliver_json` bare `TEXT` snapshots). -**Endpoints** (`src/frontend/api/mcp.rs`, mounted in `api/mod.rs`) — admin: `/mcp/catalog` (GET/POST/DELETE), `/mcp/global` (list/enable/delete + `/{id}/access` GET/PUT), `/mcp/providers` (GET/POST + DELETE `/{name}` — OAuth provider creds, secret never returned to the browser). User: `/mcp/available`, `/mcp/activate`, `/mcp/activated` (+ DELETE `/{id}` to deactivate), `/mcp/oauth/start` + `/mcp/oauth/complete` (the §15 OAuth login), `/mcp/login/status` + `/mcp/login/reset` (the §15 QR/device login — see below). `connectors.js` (``) renders the user view (activate/deactivate + granted globals) always, plus the admin view (catalog + global + per-server access + a **Sign-in providers** modal) when `role_id === 'admin'`; `connector-detail.js` (``) is a connector's own page and hosts both the OAuth login panel and the QR login panel. +**Endpoints** (`src/frontend/api/mcp.rs`, mounted in `api/mod.rs`) — admin: `/mcp/catalog` (GET/POST/DELETE), `/mcp/global` (list/enable/delete + `/{id}/access` GET/PUT), `/mcp/providers` (GET/POST + DELETE `/{name}` — OAuth provider creds, secret never returned to the browser). User: `/mcp/available`, `/mcp/activate`, `/mcp/activated` (+ DELETE `/{id}` to deactivate), `/mcp/oauth/start` + `/mcp/oauth/complete` (the §15 OAuth login), `/mcp/login/status` + `/mcp/login/reset` (the §15 QR/device login — see below). `connectors.js` (``) is the **single** Connectors surface — a row list, one row per connector (there is no separate catalog page): the user view (activate/deactivate + granted globals) always, plus the admin affordances when `role_id === 'admin'` — the **Add connector** dropdown (from the Marketplace, or manually via the `#connectors/new` sub-page), per-row removal from the catalog, and the **Sign-in providers** modal. The Marketplace stays its own page (`marketplace.js`), reached from that dropdown and linking back to `#connectors`. `connector-detail.js` (``) is a connector's own page and hosts both the OAuth login panel and the QR login panel. **Dependency reconciler (`mcp::install::ensure_installed`).** Copying a local-script connector's files into a container never installed its deps. `ensure_installed` closes that: a **content-hash reconciler** keyed on the connector's *source* files (not a version string) that, when the hash changed, re-copies the files and installs deps inside the container — `npm ci --omit=dev` (node, from `package.json`) and/or `pip install --target .pydeps` (python, from `requirements.txt`, put on the server's `PYTHONPATH` by `user_row_spec`). Runs at activation **and** on every per-user startup path (`UserContext` build, remount) via `mcp::prepare_local_connector`, so a fresh container installs from scratch, an updated connector re-installs, and an unchanged one is a hash-match no-op. Deps are therefore **never vendored** — connectors ship `package.json`/`requirements.txt`, not `node_modules/`. Authoring contract for connectors lives in `scripts/CONNECTOR_MANIFEST_GUIDE.md`. @@ -387,7 +387,7 @@ The role editor (`roles-page.js`) sets the default group + an allowed-groups che | `agent-inbox.js` | `` | Pending approvals + clarifications from background sessions | | `approval-rules.js` | `` | Approval rule management | | `cron-jobs.js` | `` | Scheduled job management | -| `connectors.js` | `` | MCP Connectors list (one row per connector): user activate/deactivate + granted globals; admin gets a **Sign-in providers** modal (OAuth client creds) + Catalog/Marketplace nav (§7/§14/§15) | +| `connectors.js` | `` | MCP Connectors row list (one row per connector): user activate/deactivate + granted globals; admin also gets the **Add connector** dropdown (Marketplace / manual form at `#connectors/new`), per-row removal from the catalog, and the **Sign-in providers** modal (§7/§14/§15) | | `plugins-page.js` | `` | `#plugins` — user half: granted plugins + schema-driven per-user config form | | `plugin-catalog.js` | `` | `#plugin-catalog` — admin status board: one card per plugin (enable toggle + health dot + Configure → `#plugin-detail`) | | `plugin-detail.js` | `` | `#plugin-detail?id=` — one plugin's admin page: instance-config form (`config_schema`) + per-user access checklist (plugin twin of `connector-detail.js`) | diff --git a/web/app.js b/web/app.js index 0179bd0..468d00b 100644 --- a/web/app.js +++ b/web/app.js @@ -19,7 +19,6 @@ import { PluginPageHost } from './components/plugin-page-host.js'; import { PluginCatalogPage } from './components/plugin-catalog.js'; import { PluginDetailPage } from './components/plugin-detail.js'; import { MarketplacePage } from './components/marketplace.js'; -import { CatalogPage } from './components/catalog.js'; import { ProfilePage } from './components/profile-page.js'; import { ApprovalGroupsPage } from './components/approval-groups.js'; import { ApprovalRulesPage } from './components/approval-rules.js'; @@ -62,7 +61,6 @@ customElements.define('plugin-page-host', PluginPageHost); customElements.define('plugin-catalog-page', PluginCatalogPage); customElements.define('plugin-detail-page', PluginDetailPage); customElements.define('marketplace-page', MarketplacePage); -customElements.define('catalog-page', CatalogPage); customElements.define('profile-page', ProfilePage); customElements.define('approval-groups-page', ApprovalGroupsPage); customElements.define('approval-rules-page', ApprovalRulesPage); diff --git a/web/components/catalog.js b/web/components/catalog.js deleted file mode 100644 index f6f00c1..0000000 --- a/web/components/catalog.js +++ /dev/null @@ -1,356 +0,0 @@ -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 catalog — blueprint §14/§15. Admin only. -// -// One question: **what does this box offer?** The catalog is the shelf; nothing here -// is running. A `global` entry still needs the admin to enable it and a `per_user` -// one still needs each user to activate it — both of which happen on the Connectors -// page, where the runtime lives. -// -// Adding is one intent with two sources, so it is one button with two options rather -// than two distant affordances. Their order mirrors the trust model (§14): the -// marketplace path is vetted and hash-verified, the manual path is the escape hatch -// that puts unvetted code on the box — which is why it needs `mcp.register_local_script` -// and why it sits second. -// -// The manual path is a dedicated page (`#catalog/new`), not a dialog: the form is -// long and technical, a fixed modal grew taller than the viewport with no way to -// scroll, and a click on the overlay discarded everything typed so far. A page -// scrolls, and leaving it is a deliberate navigation. -// -// Reuses the shared `um-*` / bootstrap styling (no page-specific CSS). - -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 CatalogPage extends LightElement { - - static get properties() { - return { - _open: { state: true }, - _me: { state: true }, - _rows: { state: true }, - _addOpen: { state: true }, // the "Add connector" chooser - _error: { state: true }, - _view: { state: true }, // 'list' | 'new' - _form: { state: true }, // manual-entry fields, when _view === 'new' - }; - } - - constructor() { - super(); - this._open = false; - this._reset(); - } - - _reset() { - this._me = null; - this._rows = null; - this._addOpen = false; - this._error = null; - this._view = 'list'; - this._form = 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 === 'catalog'; - this.style.display = this._open ? 'flex' : 'none'; - if (this._open) { this._syncViewFromHash(); this._load(); } - }); - window.addEventListener('hashchange', () => { - if (this._open) this._syncViewFromHash(); - }); - document.addEventListener('click', () => { if (this._addOpen) this._addOpen = false; }); - } - - 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; - this._rows = await jf('/api/mcp/catalog'); - } catch (e) { - this._error = e.message; - } - } - - _goMarketplace() { - this._addOpen = false; - history.pushState({ page: 'marketplace' }, '', '#marketplace'); - window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'marketplace' } })); - } - - _goConnectors() { - history.pushState({ page: 'connectors' }, '', '#connectors'); - window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'connectors' } })); - } - - // ── Manual entry ─────────────────────────────────────────────────────────── - - // The `new` view is derived from the `#catalog/new` sub-route, so the browser's - // Back/Forward works and a pasted URL lands on the form. Entering the view - // always starts a fresh form. - _syncViewFromHash() { - const parts = location.hash.slice(1).split('/'); - const wantsNew = parts[0] === 'catalog' && parts[1] === 'new'; - if (wantsNew && this._view !== 'new') { - this._error = null; - this._form = { - name: '', scope: 'per_user', source: 'remote', transport: 'stdio', - command: '', args: '', url: '', script_path: '', config_schema: '', - auth_kind: 'none', friendly_name: '', description: '', - }; - } - this._view = wantsNew ? 'new' : 'list'; - } - - _openManual() { - this._addOpen = false; - history.pushState({ page: 'catalog', view: 'new' }, '', '#catalog/new'); - this._syncViewFromHash(); - } - - _closeNew() { - // Prefer real history so the browser's own Back stays consistent; fall back to - // the list when this page was opened straight from a pasted URL. - if (history.length > 1) { history.back(); return; } - history.pushState({ page: 'catalog' }, '', '#catalog'); - this._view = 'list'; - } - - _patch(field, value) { - this._form = { ...this._form, [field]: value }; - } - - async _saveManual() { - const f = this._form; - if (!f.name.trim()) { this._error = t('catalog.error.name'); return; } - const listField = (s) => s.split(/[\n,]/).map(x => x.trim()).filter(Boolean); - try { - await jf('/api/mcp/catalog', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: f.name.trim(), - scope: f.scope, - source: f.source, - transport: f.transport, - command: f.command.trim() || null, - args: f.args.trim() ? listField(f.args) : null, - url: f.url.trim() || null, - script_path: f.script_path.trim() || null, - config_schema: f.config_schema.trim() ? listField(f.config_schema) : null, - auth_kind: f.auth_kind, - friendly_name: f.friendly_name.trim() || null, - description: f.description.trim() || null, - }), - }); - this._view = 'list'; - this._form = null; - history.pushState({ page: 'catalog' }, '', '#catalog'); - await this._load(); - } catch (e) { this._error = e.message; } - } - - async _delete(row) { - if (!confirm(t('catalog.confirm.delete', { name: row.name }))) return; - try { - await jf(`/api/mcp/catalog/${row.id}`, { method: 'DELETE' }); - await this._load(); - } catch (e) { this._error = e.message; } - } - - // ── Render ───────────────────────────────────────────────────────────────── - - render() { - if (!this._open) return nothing; - if (this._view === 'new') return this._renderNew(); - const rows = this._rows ?? []; - const loading = this._rows === null && !this._error && this._isAdmin; - - return html` -
-
-

${t('catalog.title')}

-
- ${this._isAdmin ? this._renderAddButton() : nothing} -
-
- - ${this._error ? html` -
${this._error}
` : nothing} - -
- ${this._me && !this._isAdmin ? html` -
- -

${t('catalog.not_admin')}

-

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

-
- ` : loading ? html` -

${t('catalog.loading')}

- ` : html` -
${unsafeHTML(t('catalog.desc'))}
- ${rows.length === 0 ? this._renderEmpty() : this._renderTable(rows)} - `} -
-
`; - } - - // Bootstrap's own dropdown classes, not a hand-rolled panel: 5.3 themes - // `.dropdown-menu`/`.dropdown-item` from `data-bs-theme`, so this follows the - // light/dark switch for free. `.show` opens it — the state is ours, not - // Bootstrap's JS. - _renderAddButton() { - return html` - `; - } - - _renderEmpty() { - return html` -
- -

${t('catalog.empty.title')}

-

${t('catalog.empty.hint')}

- -
`; - } - - _renderTable(rows) { - return html` - - - - ${rows.map(r => html` - - - - - - - `)} - -
${t('catalog.table.connector')}${t('catalog.table.scope')}${t('catalog.table.type')}${t('catalog.table.auth')}
- ${r.friendly_name || r.name} - ${r.friendly_name ? html` ${r.name}` : nothing} - ${r.description ? html` -
${r.description}
` : nothing} -
- ${r.scope === 'global' ? t('catalog.badge.global') : t('catalog.badge.per_user')} - ${r.source === 'local_script' ? t('catalog.badge.local_script') : t('catalog.badge.remote')}${r.auth_kind}
- -
`; - } - - _field(label, value, oninput, opts = {}) { - return html`
- - -
`; - } - - _area(label, value, oninput, opts = {}) { - return html`
- - -
`; - } - - _select(label, value, options, onchange) { - return html`
- - -
`; - } - - _renderNew() { - const f = this._form; - const isScript = f.source === 'local_script'; - return html` -
-
-
- -

- ${t('catalog.new.title')}

-
-
-
-
- ${this._error ? html`
${this._error}
` : nothing} - ${isScript ? html` -
${unsafeHTML(t('catalog.new.script_warn'))}
` : nothing} - ${this._field(t('catalog.new.name'), f.name, e => this._patch('name', e.target.value), { hint: t('catalog.new.name_hint'), mono: true })} - ${this._select(t('catalog.new.scope'), f.scope, ['per_user', 'global'], e => this._patch('scope', e.target.value))} - ${this._select(t('catalog.new.type'), f.source, ['remote', 'local_script'], e => this._patch('source', e.target.value))} - ${this._select(t('catalog.new.transport'), f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))} - ${isScript - ? html`${this._field(t('catalog.new.command'), f.command, e => this._patch('command', e.target.value), { placeholder: t('catalog.new.command_ph'), mono: true })} - ${this._field(t('catalog.new.script_path'), f.script_path, e => this._patch('script_path', e.target.value), { hint: t('catalog.new.script_path_hint'), mono: true })}` - : this._field(t('catalog.new.url'), f.url, e => this._patch('url', e.target.value), { mono: true })} - ${this._area(t('catalog.new.args'), f.args, e => this._patch('args', e.target.value), { hint: t('catalog.new.args_hint'), mono: true })} - ${this._area(t('catalog.new.config_schema'), f.config_schema, e => this._patch('config_schema', e.target.value), { hint: t('catalog.new.config_schema_hint'), mono: true })} - ${this._select(t('catalog.new.auth'), f.auth_kind, ['none', 'api_key', 'oauth', 'qr', 'ssh_key'], e => this._patch('auth_kind', e.target.value))} - ${this._field(t('catalog.new.friendly'), f.friendly_name, e => this._patch('friendly_name', e.target.value))} - ${this._area(t('catalog.new.desc'), f.description, e => this._patch('description', e.target.value), { hint: t('catalog.new.desc_hint'), rows: 2 })} -
- - -
-
-
-
`; - } -} diff --git a/web/components/connectors.js b/web/components/connectors.js index d3bdf14..c8fb18e 100644 --- a/web/components/connectors.js +++ b/web/components/connectors.js @@ -1,4 +1,5 @@ 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'; import { connectorIconUrl, statusOf, STATUS_LABEL, statusText } from './shared/connector-common.js'; @@ -11,14 +12,25 @@ import { connectorIconUrl, statusOf, STATUS_LABEL, statusText } from './shared/c // old three-section split (Mine / Global / Available) is gone: the same connector // used to appear twice, once as a template and once as its instance, and the reader // had to join the two by eye. Here each connector appears exactly once, and its -// state is a chip on the card. +// state is a chip on the row. // -// The card is a link, not a form. Everything that needs typing lives on the +// This page is also where the admin **curates** the list: adding is one intent with +// two sources, so it is one button with two options rather than two distant +// affordances. Their order mirrors the trust model (§14): the marketplace path is +// vetted and hash-verified, the manual path is the escape hatch that puts unvetted +// code on the box — which is why it needs `mcp.register_local_script` and why it +// sits second. Removing a catalog entry lives on the row itself. +// +// The row is a link, not a form. Everything that needs typing lives on the // connector's own page (`#connector?name=X`) — an activation form has as many // fields as the connector declares (EMAIL has a dozen), which a fixed-size dialog -// could never hold. +// could never hold. The manual-add path is a dedicated sub-page (`#connectors/new`) +// for the same reason: the form is long and technical, a fixed modal grew taller +// than the viewport with no way to scroll, and a click on the overlay discarded +// everything typed so far. A page scrolls, and leaving it is a deliberate +// navigation. // -// Reuses the marketplace's card styling (`web/css/connectors.css`). +// Row-list styling lives in `web/css/connectors.css`. const ADMIN_ID = 'admin'; @@ -40,6 +52,9 @@ export class ConnectorsPage extends LightElement { _error: { state: true }, _q: { state: true }, _noIcon: { state: true }, // names whose icon failed to load + _addOpen: { state: true }, // admin: the "Add connector" chooser + _view: { state: true }, // admin: 'list' | 'new' + _form: { state: true }, // admin: manual-entry fields, when _view === 'new' _providers: { state: true }, // admin: OAuth provider list (modal) _pForm: { state: true }, // admin: provider being edited, or null _pError: { state: true }, @@ -59,6 +74,9 @@ export class ConnectorsPage extends LightElement { this._available = null; this._activated = null; this._error = null; + this._addOpen = false; + this._view = 'list'; + this._form = null; this._providers = null; this._pForm = null; this._pError = null; @@ -71,9 +89,13 @@ export class ConnectorsPage extends LightElement { window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'connectors'; this.style.display = this._open ? 'flex' : 'none'; - if (this._open) this._load(); + if (this._open) { this._syncViewFromHash(); this._load(); } + }); + window.addEventListener('hashchange', () => { + if (this._open) this._syncViewFromHash(); }); window.addEventListener('connectors-changed', () => { if (this._open) this._load(); }); + document.addEventListener('click', () => { if (this._addOpen) this._addOpen = false; }); } disconnectedCallback() { @@ -99,6 +121,7 @@ export class ConnectorsPage extends LightElement { } _go(page, hash) { + this._addOpen = false; history.pushState({ page }, '', hash); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } })); } @@ -107,6 +130,81 @@ export class ConnectorsPage extends LightElement { this._go('connector', `#connector?name=${encodeURIComponent(name)}`); } + // ── admin: add ─────────────────────────────────────────────────────────────── + + // The `new` view is derived from the `#connectors/new` sub-route, so the + // browser's Back/Forward works and a pasted URL lands on the form. Entering the + // view always starts a fresh form. + _syncViewFromHash() { + const parts = location.hash.slice(1).split('/'); + const wantsNew = parts[0] === 'connectors' && parts[1] === 'new'; + if (wantsNew && this._view !== 'new') { + this._error = null; + this._form = { + name: '', scope: 'per_user', source: 'remote', transport: 'stdio', + command: '', args: '', url: '', script_path: '', config_schema: '', + auth_kind: 'none', friendly_name: '', description: '', + }; + } + this._view = wantsNew ? 'new' : 'list'; + } + + _openManual() { + this._addOpen = false; + history.pushState({ page: 'connectors', view: 'new' }, '', '#connectors/new'); + this._syncViewFromHash(); + } + + _closeNew() { + // Prefer real history so the browser's own Back stays consistent; fall back to + // the list when this page was opened straight from a pasted URL. + if (history.length > 1) { history.back(); return; } + history.pushState({ page: 'connectors' }, '', '#connectors'); + this._view = 'list'; + } + + _patch(field, value) { + this._form = { ...this._form, [field]: value }; + } + + async _saveManual() { + const f = this._form; + if (!f.name.trim()) { this._error = t('connectors.new.error_name'); return; } + const listField = (s) => s.split(/[\n,]/).map(x => x.trim()).filter(Boolean); + try { + await jf('/api/mcp/catalog', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: f.name.trim(), + scope: f.scope, + source: f.source, + transport: f.transport, + command: f.command.trim() || null, + args: f.args.trim() ? listField(f.args) : null, + url: f.url.trim() || null, + script_path: f.script_path.trim() || null, + config_schema: f.config_schema.trim() ? listField(f.config_schema) : null, + auth_kind: f.auth_kind, + friendly_name: f.friendly_name.trim() || null, + description: f.description.trim() || null, + }), + }); + this._view = 'list'; + this._form = null; + history.pushState({ page: 'connectors' }, '', '#connectors'); + await this._load(); + } catch (e) { this._error = e.message; } + } + + async _delete(row) { + if (!confirm(t('connectors.confirm.remove', { name: row.name }))) return; + try { + await jf(`/api/mcp/catalog/${row.id}`, { method: 'DELETE' }); + await this._load(); + } catch (e) { this._error = e.message; } + } + // ── admin: OAuth sign-in providers (§15) ───────────────────────────────────── async _openProviders() { @@ -240,6 +338,7 @@ export class ConnectorsPage extends LightElement { render() { if (!this._open) return nothing; + if (this._view === 'new') return this._renderNew(); const loading = this._available === null && !this._error; const rows = loading ? [] : this._rows; @@ -252,12 +351,7 @@ export class ConnectorsPage extends LightElement { - - ` : nothing} + ${this._renderAddButton()}` : nothing} @@ -276,12 +370,171 @@ export class ConnectorsPage extends LightElement { ${rows.length === 0 ? this._renderEmpty() : html` -
${rows.map(r => this._renderCard(r))}
`} +
${rows.map(r => this._renderRow(r))}
`} `} ${this._providers !== null ? this._renderProvidersModal() : nothing} `; } + // Bootstrap's own dropdown classes, not a hand-rolled panel: 5.3 themes + // `.dropdown-menu`/`.dropdown-item` from `data-bs-theme`, so this follows the + // light/dark switch for free. `.show` opens it — the state is ours, not + // Bootstrap's JS. + _renderAddButton() { + return html` + `; + } + + _renderEmpty() { + if (this._q.trim()) { + return html`
+

${t('connectors.empty.match', { query: this._q })}

`; + } + return html` +
+

${this._isAdmin ? t('connectors.empty.installed') : t('connectors.empty.available')}

+ ${this._isAdmin + ? html`

${t('connectors.empty.install_hint')}

` + : html`

${t('connectors.empty.ask_admin')}

`} +
`; + } + + _renderRow(r) { + const status = statusOf(r); + const isGlobal = r.scope === 'global'; + const isScript = r.source === 'local_script'; + const showIcon = !this._noIcon.has(r.name); + // A synthetic row (a granted global whose catalog entry the caller cannot read) + // has no catalog id, so there is nothing to delete. + const canDelete = this._isAdmin && r.id != null; + + return html` +
this._openConnector(r.name)} + @keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._openConnector(r.name); } }}> + ${showIcon + ? html` this._iconFailed(r.name)} />` + : html`
`} +
+
+ ${r.friendly_name || r.name} + ${r.friendly_name ? html`${r.name}` : nothing} +
+ ${r.description ? html`
${r.description}
` : nothing} +
+
+ + ${isGlobal ? t('connectors.chip.global') : t('connectors.chip.per_user')} + + ${isScript ? html` + + ${t('connectors.chip.local_script')} + ` : nothing} + ${r.auth_kind && r.auth_kind !== 'none' ? html` + ${r.auth_kind}` : nothing} +
+ + ${statusText(status)} + + ${canDelete ? html` + ` : nothing} +
`; + } + + // ── admin: manual entry ───────────────────────────────────────────────────── + + _field(label, value, oninput, opts = {}) { + return html`
+ + +
`; + } + + _area(label, value, oninput, opts = {}) { + return html`
+ + +
`; + } + + _select(label, value, options, onchange) { + return html`
+ + +
`; + } + + _renderNew() { + const f = this._form; + const isScript = f.source === 'local_script'; + return html` +
+
+
+ +

+ ${t('connectors.new.title')}

+
+
+
+
+ ${this._error ? html`
${this._error}
` : nothing} + ${isScript ? html` +
${unsafeHTML(t('connectors.new.script_warn'))}
` : nothing} + ${this._field(t('connectors.new.name'), f.name, e => this._patch('name', e.target.value), { hint: t('connectors.new.name_hint'), mono: true })} + ${this._select(t('connectors.new.scope'), f.scope, ['per_user', 'global'], e => this._patch('scope', e.target.value))} + ${this._select(t('connectors.new.type'), f.source, ['remote', 'local_script'], e => this._patch('source', e.target.value))} + ${this._select(t('connectors.new.transport'), f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))} + ${isScript + ? html`${this._field(t('connectors.new.command'), f.command, e => this._patch('command', e.target.value), { placeholder: t('connectors.new.command_ph'), mono: true })} + ${this._field(t('connectors.new.script_path'), f.script_path, e => this._patch('script_path', e.target.value), { hint: t('connectors.new.script_path_hint'), mono: true })}` + : this._field(t('connectors.new.url'), f.url, e => this._patch('url', e.target.value), { mono: true })} + ${this._area(t('connectors.new.args'), f.args, e => this._patch('args', e.target.value), { hint: t('connectors.new.args_hint'), mono: true })} + ${this._area(t('connectors.new.config_schema'), f.config_schema, e => this._patch('config_schema', e.target.value), { hint: t('connectors.new.config_schema_hint'), mono: true })} + ${this._select(t('connectors.new.auth'), f.auth_kind, ['none', 'api_key', 'oauth', 'qr', 'ssh_key'], e => this._patch('auth_kind', e.target.value))} + ${this._field(t('connectors.new.friendly'), f.friendly_name, e => this._patch('friendly_name', e.target.value))} + ${this._area(t('connectors.new.desc'), f.description, e => this._patch('description', e.target.value), { hint: t('connectors.new.desc_hint'), rows: 2 })} +
+ + +
+
+
+
`; + } + _renderProvidersModal() { return html`
-

${t('connectors.empty.match', { query: this._q })}

`; - } - return html` -
-

${this._isAdmin ? t('connectors.empty.installed') : t('connectors.empty.available')}

- ${this._isAdmin - ? html`

${t('connectors.empty.install_hint')}

` - : html`

${t('connectors.empty.ask_admin')}

`} -
`; - } - - _renderCard(r) { - const status = statusOf(r); - const isGlobal = r.scope === 'global'; - const isScript = r.source === 'local_script'; - const showIcon = !this._noIcon.has(r.name); - - return html` -
this._openConnector(r.name)} - @keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._openConnector(r.name); } }}> -
- ${showIcon - ? html` this._iconFailed(r.name)} />` - : html`
`} -
-
${r.friendly_name || r.name}
-
${r.name}
-
- - ${statusText(status)} - -
- - ${r.description ? html`
${r.description}
` : nothing} - -
- - ${isGlobal ? t('connectors.chip.global') : t('connectors.chip.per_user')} - - ${isScript ? html` - - ${t('connectors.chip.local_script')} - ` : nothing} - ${r.auth_kind && r.auth_kind !== 'none' ? html` - ${r.auth_kind}` : nothing} -
-
`; - } } diff --git a/web/components/copilot.js b/web/components/copilot.js index d1cda6b..d08f951 100644 --- a/web/components/copilot.js +++ b/web/components/copilot.js @@ -78,7 +78,7 @@ export class AppCopilot extends I18nMixin(ChatSession) { _pageFromHash() { const m = location.hash.slice(1).match(/^([^/?]+)/); const seg = m ? m[1] : ''; - const known = ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'system-agents', 'file_viewer', 'tool_detail']; + const known = ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'system-agents', 'file_viewer', 'tool_detail']; return known.includes(seg) ? seg : 'home'; } diff --git a/web/components/marketplace.js b/web/components/marketplace.js index 6308201..3dcf914 100644 --- a/web/components/marketplace.js +++ b/web/components/marketplace.js @@ -132,11 +132,11 @@ export class MarketplacePage extends LightElement { }); } - // 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' } })); + // The marketplace is a destination of the Connectors page's "Add connector" + // action, not a place of its own — so it goes back where it came from. + _goConnectors() { + history.pushState({ page: 'connectors' }, '', '#connectors'); + window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'connectors' } })); } render() { @@ -148,8 +148,8 @@ export class MarketplacePage extends LightElement {

${t('marketplace.title')}

- ${this._isAdmin ? html`