// ═══════════════════════ ROLES MARKETPLACE — the creation engine + catalogue ═══════════════════════ // A self-contained view (one IIFE, no new globals but window.Marketplace) that lets a person BROWSE // every role the agent can run (built-in, their own, imported from a pack), CREATE one through an // easy form — with an AI that drafts it from a plain description — and IMPORT / EXPORT packs. It talks // only to the /v1/agent/roles and /v1/agent/tools endpoints through the shared api() helper, and it // reuses the dashboard's card / pill / modal language so it looks like one console. dashboard.js owns // the nav swap and calls window.Marketplace.load() when the Roles tab is shown. (function () { const el = (id) => document.getElementById(id); const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); // Platform look, mirrored from the dashboard so a role's badge matches its account card. const PLAT = { facebook: { name: 'Facebook', mark: 'f', brand: '#1877f2' }, google: { name: 'Google', mark: 'G', brand: '#c9433b' }, linkedin: { name: 'LinkedIn', mark: 'in', brand: '#0a66c2' }, reddit: { name: 'Reddit', mark: 'R', brand: '#ff4500' }, youtube: { name: 'YouTube', mark: '▶', brand: '#ff0000' }, x: { name: 'X', mark: '𝕏', brand: '#0f1419' }, producthunt: { name: 'Product Hunt', mark: 'P', brand: '#da552f' }, instagram: { name: 'Instagram', mark: '◈', brand: '#e1306c' }, }; const PLATFORM_ORDER = ['facebook', 'google', 'linkedin', 'reddit', 'youtube', 'x', 'producthunt', 'instagram']; // A sensible starting toolset for a fresh role — getting around, plus the account tools. const BASICS = ['look', 'read', 'open', 'click', 'type', 'scroll', 'back', 'note', 'finish', 'list_profiles', 'use_profile']; let ROLES = [], TOOLS = [], TOOLGROUPS = [], FILTER = 'all', SEARCH = ''; let SELECTED = new Set(); // tools ticked in the open builder let EDIT_ID = null; // the id being edited (null = creating) // Which bucket a role belongs to, from its source tag ('builtin' | 'user' | 'pack:'). const kindOf = (r) => (r.source === 'builtin' ? 'builtin' : (r.source && r.source.indexOf('pack:') === 0 ? 'pack' : 'yours')); const KIND_LABEL = { builtin: 'Built-in', yours: 'Yours', pack: 'Pack' }; // ── load everything the view needs, then draw ────────────────────────────────────────────────── async function load() { try { const [r, t] = await Promise.all([ api('/v1/agent/roles').catch(() => ({ roles: [] })), api('/v1/agent/tools').catch(() => ({ tools: [] })), ]); ROLES = r.roles || []; TOOLS = t.tools || []; const g = {}; for (const tool of TOOLS) (g[tool.group] = g[tool.group] || []).push(tool); TOOLGROUPS = Object.entries(g).map(([group, tools]) => ({ group, tools })); } catch (e) { /* keep whatever is there */ } render(); } // ── the catalogue ────────────────────────────────────────────────────────────────────────────── function badge(site) { const p = site && PLAT[site]; if (p) return '' + esc(p.mark) + ''; return '✦'; // crosses sites / no single platform } const toolCount = (r) => (r.tools == null ? 'every tool' : (r.tools.length + (r.tools.length === 1 ? ' tool' : ' tools'))); const canAct = (r) => (r.tools == null || (Array.isArray(r.tools) && r.tools.indexOf('act') >= 0)); function match(r) { const k = kindOf(r); if (FILTER === 'yours' && k !== 'yours') return false; if (FILTER === 'builtin' && k !== 'builtin') return false; if (FILTER === 'packs' && k !== 'pack') return false; if (SEARCH) { const hay = (r.label + ' ' + (r.description || '') + ' ' + (r.site || '') + ' ' + (r.group || '')).toLowerCase(); if (hay.indexOf(SEARCH.toLowerCase()) < 0) return false; } return true; } function card(r) { const k = kindOf(r); const site = r.site ? (PLAT[r.site] ? PLAT[r.site].name : r.site) : 'Crosses sites'; const acts = k === 'builtin' ? '' : '' + '' + ''; return '
' + '
' + badge(r.site) + '
' + esc(r.label) + '
' + esc(site) + '
' + '' + KIND_LABEL[k] + (k === 'pack' ? ' · ' + esc(r.source.slice(5)) : '') + '
' + '
' + esc(r.description || '—') + '
' + '
🧰 ' + toolCount(r) + '' + (canAct(r) ? '⚠ can act' : '') + '
' + '
' + acts + '
'; } function render() { const view = el('dashRolesView'); if (!view) return; const counts = { all: ROLES.length, yours: ROLES.filter((r) => kindOf(r) === 'yours').length, builtin: ROLES.filter((r) => kindOf(r) === 'builtin').length, packs: ROLES.filter((r) => kindOf(r) === 'pack').length, }; const TAB = { all: 'All', yours: 'Yours', builtin: 'Built-in', packs: 'Packs' }; const rows = ROLES.filter(match); const grid = rows.length ? rows.map(card).join('') : '
' + (FILTER === 'yours' ? 'You haven\'t created any roles yet. Start from a built-in with Clone, or press Create role.' : 'Nothing here yet.') + '
'; view.innerHTML = '
' + '
' + '' + '' + '' + '
' + '
' + '' + '
' + Object.keys(TAB).map((f) => '').join('') + '
' + '
' + '
' + grid + '
'; wire(); } function wire() { const view = el('dashRolesView'); el('mktNew') && (el('mktNew').onclick = () => openBuilder(null)); el('mktImport') && (el('mktImport').onclick = () => openPack('import')); el('mktExportAll') && (el('mktExportAll').onclick = () => exportRoles(ROLES.filter((r) => kindOf(r) !== 'builtin').map((r) => r.name), 'My roles')); const s = el('mktSearch'); if (s) s.oninput = () => { SEARCH = s.value; const g = view.querySelector('.mkt-grid'); if (g) g.innerHTML = renderGridOnly(); bindCards(); }; view.querySelectorAll('.mtab').forEach((b) => b.onclick = () => { FILTER = b.dataset.filter; render(); }); bindCards(); } // Re-render just the grid on a keystroke, so the search box keeps focus. function renderGridOnly() { const rows = ROLES.filter(match); return rows.length ? rows.map(card).join('') : '
Nothing matches "' + esc(SEARCH) + '".
'; } function bindCards() { const view = el('dashRolesView'); if (!view) return; view.querySelectorAll('[data-clone]').forEach((b) => b.onclick = () => openBuilder(b.dataset.clone, 'clone')); view.querySelectorAll('[data-edit]').forEach((b) => b.onclick = () => openBuilder(b.dataset.edit, 'edit')); view.querySelectorAll('[data-export]').forEach((b) => b.onclick = () => exportRoles([b.dataset.export], b.dataset.export)); view.querySelectorAll('[data-del]').forEach((b) => b.onclick = () => del(b.dataset.del)); } async function del(name) { const r = ROLES.find((x) => x.name === name); if (!confirm('Delete the role "' + (r ? r.label : name) + '"? This cannot be undone.')) return; try { await api('/v1/agent/roles/' + encodeURIComponent(name), { method: 'DELETE' }); } catch (e) { alert('Could not delete: ' + (e.message || e)); } load(); } // ── the builder ────────────────────────────────────────────────────────────────────────────── function platformOptions(site) { const opts = ['']; for (const id of PLATFORM_ORDER) opts.push(''); return opts.join(''); } function toolGroupsHtml() { return TOOLGROUPS.map((g) => '
' + esc(g.group) + '
' + g.tools.map((t) => '').join('') + '
').join(''); } // role is a NAME (to fetch full) or null (new). mode: 'edit' keeps the id, 'clone' starts fresh. async function openBuilder(name, mode) { let role = { label: '', site: '', description: '', tools: BASICS.slice(), prompt: '' }; EDIT_ID = null; if (name) { try { role = await api('/v1/agent/roles/' + encodeURIComponent(name)); } catch (e) { alert('Could not open that role: ' + (e.message || e)); return; } if (mode === 'edit') EDIT_ID = role.name; if (mode === 'clone') role.label = (role.label || '') + ' (my copy)'; } SELECTED = new Set(role.tools == null ? [] : role.tools); const everyTool = role.tools == null; const m = el('roleModal'); m.querySelector('.t').textContent = EDIT_ID ? 'Edit role' : (mode === 'clone' ? 'Clone role' : 'Create a role'); m.querySelector('.s').textContent = 'A specialist your agent can run. The act gate is unchanged — a role decides what it reaches for, never whether it may act unasked.'; m.querySelector('.mbody').innerHTML = '
' + '
✨ Draft with AI
' + '

Describe what this role should do — AI fills the form below. You review everything before saving.

' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '
' + toolGroupsHtml() + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
'; m.querySelector('.mfoot').innerHTML = '' + ''; wireBuilder(); updateCount(); m.classList.add('on'); } function wireBuilder() { const m = el('roleModal'); m.querySelectorAll('.tchip').forEach((c) => c.onclick = () => { if (el('fAll').checked) return; const t = c.dataset.tool; if (SELECTED.has(t)) { SELECTED.delete(t); c.classList.remove('on'); } else { SELECTED.add(t); c.classList.add('on'); } updateCount(); }); el('fAll').onchange = () => { el('fTools').classList.toggle('dim', el('fAll').checked); updateCount(); }; el('aiGo').onclick = aiSuggest; el('fSave').onclick = save; m.querySelectorAll('[data-mclose]').forEach((b) => b.onclick = closeBuilder); } function updateCount() { const c = el('fCount'); if (!c) return; c.textContent = el('fAll').checked ? 'Every tool — the agent may reach for anything.' : (SELECTED.size + ' selected'); } function closeBuilder() { el('roleModal').classList.remove('on'); } async function aiSuggest() { const desc = (el('aiDesc').value || '').trim(); const msg = el('aiMsg'); if (desc.length < 8) { msg.textContent = 'Describe it in a sentence or two first.'; msg.className = 'aimsg warn'; return; } msg.textContent = 'Thinking…'; msg.className = 'aimsg'; const go = el('aiGo'); go.disabled = true; try { const d = await api('/v1/agent/roles/suggest', { method: 'POST', body: JSON.stringify({ description: desc }) }); if (d.label) el('fLabel').value = d.label; if (d.description) el('fDesc').value = d.description; if (d.prompt) el('fPrompt').value = d.prompt; el('fSite').value = d.site || ''; // The suggestion names tools — untick "every tool", select exactly those. el('fAll').checked = false; el('fTools').classList.remove('dim'); SELECTED = new Set(Array.isArray(d.tools) ? d.tools : []); el('roleModal').querySelectorAll('.tchip').forEach((c) => c.classList.toggle('on', SELECTED.has(c.dataset.tool))); updateCount(); msg.textContent = 'Drafted — review it and adjust anything before saving.'; msg.className = 'aimsg ok'; } catch (e) { msg.textContent = e.message || 'The AI model could not be reached — check it under Settings.'; msg.className = 'aimsg warn'; } finally { go.disabled = false; } } async function save() { const err = el('fErr'); err.textContent = ''; const body = { label: el('fLabel').value.trim(), site: el('fSite').value || null, description: el('fDesc').value.trim(), tools: el('fAll').checked ? null : Array.from(SELECTED), prompt: el('fPrompt').value.trim(), }; const path = EDIT_ID ? '/v1/agent/roles/' + encodeURIComponent(EDIT_ID) : '/v1/agent/roles'; try { await api(path, { method: EDIT_ID ? 'PUT' : 'POST', body: JSON.stringify(body) }); closeBuilder(); load(); } catch (e) { err.textContent = e.message || 'Could not save this role.'; } } // ── packs: import one, export a set ────────────────────────────────────────────────────────────── function openPack(mode, payload) { const m = el('packModal'); if (mode === 'import') { m.querySelector('.t').textContent = 'Import a roles pack'; m.querySelector('.s').textContent = 'Paste a pack below, or load a .json file. It installs whole, or not at all.'; m.querySelector('.mbody').innerHTML = '' + '' + '
'; m.querySelector('.mfoot').innerHTML = ''; m.classList.add('on'); el('pkFile').onchange = (e) => { const f = e.target.files && e.target.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = () => { el('pkText').value = rd.result; }; rd.readAsText(f); }; el('pkGo').onclick = installPack; } else { m.querySelector('.t').textContent = 'Export ' + (payload.count === 1 ? 'a role' : payload.count + ' roles'); m.querySelector('.s').textContent = 'A shareable pack. Copy it, or download the file, and import it anywhere.'; m.querySelector('.mbody').innerHTML = ''; m.querySelector('.mfoot').innerHTML = '' + '' + ''; m.classList.add('on'); el('pkCopy').onclick = async () => { try { await navigator.clipboard.writeText(payload.json); el('pkCopy').textContent = 'Copied'; setTimeout(() => el('pkCopy').textContent = 'Copy', 1400); } catch (e) { el('pkText').select(); } }; el('pkDl').onclick = () => download((payload.name || 'roles') + '.json', payload.json); } m.querySelectorAll('[data-pclose]').forEach((b) => b.onclick = () => m.classList.remove('on')); } async function installPack() { const err = el('pkErr'); err.textContent = ''; let pack; try { pack = JSON.parse(el('pkText').value); } catch (e) { err.textContent = 'That is not valid JSON.'; return; } try { const r = await api('/v1/agent/roles/import', { method: 'POST', body: JSON.stringify(pack) }); el('packModal').classList.remove('on'); load(); alert('Installed ' + ((r.installed || []).length) + ' role(s).'); } catch (e) { err.textContent = e.message || 'Could not install this pack.'; } } async function exportRoles(ids, name) { if (!ids.length) { alert('Nothing to export — create or clone a role first.'); return; } try { const pack = await api('/v1/agent/roles/export?ids=' + encodeURIComponent(ids.join(',')) + '&name=' + encodeURIComponent(name || 'My roles')); openPack('export', { json: JSON.stringify(pack, null, 2), count: (pack.roles || []).length, name: (name || 'roles').replace(/[^a-z0-9_-]+/gi, '-') }); } catch (e) { alert('Could not export: ' + (e.message || e)); } } function download(fname, text) { try { const url = URL.createObjectURL(new Blob([text], { type: 'application/json' })); const a = document.createElement('a'); a.href = url; a.download = fname; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 2000); } catch (e) { /* the Copy button is the reliable fallback */ } } // ── the two modals, injected once so index.html stays about structure ────────────────────────── function ensureModals() { if (el('roleModal')) return; const mk = (id) => { const d = document.createElement('div'); d.className = 'mkt-modal'; d.id = id; d.innerHTML = '
' + '
'; document.body.appendChild(d); d.addEventListener('click', (e) => { if (e.target === d || e.target.closest('[data-xclose]')) d.classList.remove('on'); }); return d; }; mk('roleModal'); mk('packModal'); document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; ['roleModal', 'packModal'].forEach((id) => el(id) && el(id).classList.remove('on')); }); } ensureModals(); window.Marketplace = { load }; })();