// Marketplace app —— PR-S.1c rewrite per Claude Design bundle 8Uv-tmaD1W9VPBLDMeqJhg. // // Layout(完全照设计稿): // topbar:brand · tabs(Playground/Manage/Evals/Traces/[Marketplace active])· Publish · My installs // market-tabs:Skills | MCP Servers(各自 count badge) // market-toolbar:search + (cats — 仅当后端给了 category 才渲染) + installed counter + sort // market-scroll → market-grid(4 / row,48 / page)→ Pager // // Card(简化版,跟设计稿最后一轮一致): // icon(soft-tinted initials,左)+ body(name + 2-line desc + minimal stats)+ add-btn(右) // stats 我们用真后端字段而不是 fake installs / rating: // - skill:`v{latest_version}` + `verified ✓`(visibility=public) // - mcp:`{transport}` + `available ✓` / `unavailable ⚠` // // 数据(并发拉 4 路,跟 PR-S.1b 一致): // GET /v1/marketplace/skills — 公共 catalog,不含 per-user 字段 // GET /v1/marketplace/mcps — 同上 // GET /v1/skills — per-user 视角(看 `enabled` 判断 installed) // GET /v1/user/bindings — 真相源:skill_ids ∪ mcp_ids // // Install / Uninstall(行为不变,跟 PR-S.1b 同): // skill install → POST /v1/user/skills/{id}/install // skill uninstall: // · owner_user_id == 我 → DELETE /v1/personal-skills/{id}(物理删 clone) // · 其它 → PATCH /v1/user/bindings 把 id 从 skill_ids 摘掉 // mcp install → PATCH /v1/user/bindings 把 server_id append 到 mcp_ids // mcp uninstall → PATCH /v1/user/bindings 把 server_id 从 mcp_ids 摘掉 // // 任何 mutation 后 refreshUserState() 重拉 listSkills + getUserBindings 再算 installed 集合。 const { useState: mkUseState, useEffect: mkUseEffect, useMemo: mkUseMemo, useCallback: mkUseCallback } = React; const PAGE_SIZE = 48; // 4 per row × 12 rows // ---------------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------------- // Map a hex like "#2563eb" to a soft pastel bg by adding alpha. CSS file's // `.app-card .icon` paints text in `item.color`; we paint bg as `color + "1f"` // (12% alpha) here, matching the design's pastel tint. function tintBg(color) { if (!color || typeof color !== "string" || !color.startsWith("#")) { return "rgba(13, 13, 13, 0.06)"; } return color + "1f"; } // Distinct categories present in this list. Backend doesn't yet ship a // `category` field; for V1 (no categories) we return ["All"] and the chips // bar is skipped entirely by the caller. function deriveCategories(items) { const set = new Set(); for (const it of items) { if (it && typeof it.category === "string" && it.category) set.add(it.category); } return ["All", ...Array.from(set).sort()]; } // ---------------------------------------------------------------------------- // AppCard — icon + body (name / desc / minimal stats) + + button // ---------------------------------------------------------------------------- function AppCard({ item, kind, installed, busy, onInstall, onUninstall, disabledReason }) { const handle = () => { if (busy) return; if (disabledReason && !installed) return; if (installed) onUninstall(); else onInstall(); }; // Stats line — real backend fields, not fake installs/rating. let statsBits; if (kind === "skill") { statsBits = ( <> {item.latest_version && v{item.latest_version}} {item.visibility === "public" && ( public )} ); } else { statsBits = ( <> {item.transport && {item.transport}} {item.available === false ? unavailable : available} ); } return (
{item.initials || "?"}
{item.name}
{item.description || item.desc || "No description."}
{statsBits}
); } // ---------------------------------------------------------------------------- // InstallConfirmModal — PR-S.6c collision popup (D3) // ---------------------------------------------------------------------------- // // 渲染场景: // 1. preflight 返 already_installed=true → "Already installed" 提示(idempotent // install,理论 UI 卡住了已 grey out 的 button,但 API 直调可能到这条) // 2. preflight collisions 非空 → 列每条 shadow: // "This will shadow `` from agent " 或 "from your existing skill" // [Continue] 继续 install · [Cancel] 取消 // // 设计稿统一性:`fixed` 全屏覆盖,中间卡片(white bg + border + 280px min-width), // 跟 manage / marketplace 的 quiet 调一致;只用 styles.css 已有的 tokens。 function InstallConfirmModal({ skill, preflight, onConfirm, onCancel }) { const collisions = Array.isArray(preflight?.collisions) ? preflight.collisions : []; const alreadyInstalled = !!preflight?.already_installed; return (
e.stopPropagation()} style={{ background: "var(--bg)", borderRadius: 12, border: "1px solid var(--border)", boxShadow: "0 24px 64px rgba(0,0,0,0.18)", minWidth: 380, maxWidth: 520, padding: 24, }} >

Install {skill.name}?

{alreadyInstalled && (
You already have a fork of this skill. Re-installing won't create a second copy — the existing instance stays as-is.
)} {collisions.length > 0 && ( <>

Installing will shadow a same-name skill that already belongs to:

    {collisions.map((col, i) => (
  • shadow {col.name} in {col.source}
  • ))}

When you run that agent, your installed version takes priority (user > agent on same-name).

)}
); } // ---------------------------------------------------------------------------- // McpInstallModal — PR-S.7c: MCP install 表单(env + secrets 输入) // ---------------------------------------------------------------------------- // // 跟 InstallConfirmModal 不同:**带表单**,因为 MCP install 需要 user 填 // catalog 声明的 env / secret 字段。modal: // - 如果有 collisions → 顶部列出 "this will shadow X" 提示 // - 如果 already_installed → 顶部 "Re-install will overwrite env+secrets" 提示 // - 对每个 config_schema 字段渲染 input: // type=secret → password input(masked) // type=string → 普通 text input,带 placeholder=default // required → label 加 "*" // - footer:Cancel / Install // onConfirm 收集 form 数据传回 (env_dict, secrets_dict)。 function McpInstallModal({ mcp, preflight, onConfirm, onCancel }) { const schema = Array.isArray(preflight?.config_schema) ? preflight.config_schema : []; const collisions = Array.isArray(preflight?.collisions) ? preflight.collisions : []; const alreadyInstalled = !!preflight?.already_installed; // Initialize form state from schema defaults const initial = {}; for (const f of schema) { initial[f.name] = f.default || ""; } const [values, setValues] = React.useState(initial); const [submitting, setSubmitting] = React.useState(false); function update(name, val) { setValues((prev) => ({ ...prev, [name]: val })); } function handleSubmit(e) { if (e) e.preventDefault(); if (submitting) return; setSubmitting(true); const env = {}; const secrets = {}; for (const f of schema) { const v = values[f.name]; if (v == null || v === "") { if (f.required) continue; // skip empty optional fields continue; } if (f.type === "secret") secrets[f.name] = v; else env[f.name] = v; } onConfirm(env, secrets); } return (
e.stopPropagation()} onSubmit={handleSubmit} style={{ background: "var(--bg)", borderRadius: 12, border: "1px solid var(--border)", boxShadow: "0 24px 64px rgba(0,0,0,0.18)", minWidth: 420, maxWidth: 560, padding: 24, }} >

Install {mcp.name}?

{alreadyInstalled && (
You've installed this MCP already. Re-install will overwrite your existing env + secrets with the values below.
)} {collisions.length > 0 && ( <>

Installing will shadow a same-name MCP already belonging to:

    {collisions.map((col, i) => (
  • shadow {col.name} in {col.source}
  • ))}
)} {schema.length > 0 && ( <>

Configure this MCP for your account:

{schema.map((f) => (
update(f.name, e.target.value)} placeholder={f.default || (f.type === "secret" ? "•••••" : "")} autoComplete={f.type === "secret" ? "new-password" : "off"} style={{ width: "100%", padding: "7px 10px", fontSize: 12.5, border: "1px solid var(--border)", borderRadius: 6, background: "white", outline: "none", fontFamily: f.type === "secret" ? "var(--font-mono)" : "inherit", }} /> {f.description && (
{f.description}
)}
))}
)} {schema.length === 0 && !alreadyInstalled && collisions.length === 0 && (

This MCP doesn't require any per-user configuration. Click Install to bind it to your account.

)}
); } // ---------------------------------------------------------------------------- // Pager // ---------------------------------------------------------------------------- function Pager({ page, pageCount, onChange, total, start, end }) { if (pageCount <= 1) { return (
{total} item{total === 1 ? "" : "s"}
); } const nums = []; const push = (n) => nums.push(n); push(1); const lo = Math.max(2, page - 1); const hi = Math.min(pageCount - 1, page + 1); if (lo > 2) nums.push("…"); for (let i = lo; i <= hi; i++) push(i); if (hi < pageCount - 1) nums.push("…"); if (pageCount > 1) push(pageCount); return (
Showing {start + 1}–{end} of {total} {nums.map((n, i) => n === "…" ? : )}
); } // ---------------------------------------------------------------------------- // MarketplaceApp // ---------------------------------------------------------------------------- function MarketplaceApp() { const [tab, setTab] = mkUseState("skill"); // "skill" | "mcp" const [marketSkills, setMarketSkills] = mkUseState([]); const [marketSkillTotal, setMarketSkillTotal] = mkUseState(0); const [marketMcps, setMarketMcps] = mkUseState([]); const [mySkills, setMySkills] = mkUseState([]); const [bindings, setBindings] = mkUseState({ skill_ids: [], mcp_ids: [] }); const [query, setQuery] = mkUseState(""); const [cat, setCat] = mkUseState("All"); const [sort, setSort] = mkUseState("name"); // "name" | "newest" const [page, setPage] = mkUseState(1); const [loading, setLoading] = mkUseState(true); const [skillPageLoading, setSkillPageLoading] = mkUseState(false); const [error, setError] = mkUseState(null); const [busyIds, setBusyIds] = mkUseState({}); // PR-S.1d:My installs 过滤 — 只显示当前 user 已绑定的 catalog 条目。 // Manage 把 Tenant 编辑入口砍了,所以"我装了哪些 skills / mcps"现在只在 // 这里看 ——My installs 按钮(topbar 右上)切这个 boolean。 const [installedOnly, setInstalledOnly] = mkUseState(false); // PR-S.6c:install confirm modal。null = 不显;否则 { skill, preflight }。 const [pendingInstall, setPendingInstall] = mkUseState(null); // PR-S.7c MCP 同 pattern,但有 env/secrets 表单 → 自己 state。 const [pendingMcpInstall, setPendingMcpInstall] = mkUseState(null); // PR-S.17d · topbar 上 Users 入口 admin-gate const [isAdmin, setIsAdmin] = mkUseState(false); mkUseEffect(() => { window.api.authMe() .then((m) => setIsAdmin(!!m.is_admin)) .catch(() => {}); }, []); // Reset paging + category + query when switching tab. mkUseEffect(() => { setPage(1); setCat("All"); setQuery(""); }, [tab]); const refreshUserState = mkUseCallback(async () => { const cursor = String((Math.max(1, page) - 1) * PAGE_SIZE); const [market, mcpMarket, mine, b] = await Promise.all([ window.api.listMarketplaceSkillsPage({ limit: PAGE_SIZE, cursor, q: query, sort, installedOnly, }), window.api.listMarketplaceMcps(), window.api.listSkills({ scope: "mine" }), window.api.getUserBindings(), ]); setMarketSkills(Array.isArray(market?.items) ? market.items : []); setMarketSkillTotal(Number.isFinite(Number(market?.total_count)) ? Number(market.total_count) : 0); setMarketMcps(Array.isArray(mcpMarket) ? mcpMarket : []); setMySkills(Array.isArray(mine) ? mine : []); setBindings({ skill_ids: Array.isArray(b?.skill_ids) ? b.skill_ids : [], mcp_ids: Array.isArray(b?.mcp_ids) ? b.mcp_ids : [], }); }, [page, query, sort, installedOnly]); mkUseEffect(() => { (async () => { try { const [m, mine, b] = await Promise.all([ window.api.listMarketplaceMcps(), window.api.listSkills({ scope: "mine" }), window.api.getUserBindings(), ]); setMarketMcps(m); setMySkills(Array.isArray(mine) ? mine : []); setBindings({ skill_ids: Array.isArray(b?.skill_ids) ? b.skill_ids : [], mcp_ids: Array.isArray(b?.mcp_ids) ? b.mcp_ids : [], }); } catch (e) { setError(e?.message || String(e)); } finally { setLoading(false); } })(); }, []); mkUseEffect(() => { if (tab !== "skill") return undefined; let active = true; (async () => { setSkillPageLoading(true); setError(null); try { const cursor = String((Math.max(1, page) - 1) * PAGE_SIZE); const resp = await window.api.listMarketplaceSkillsPage({ limit: PAGE_SIZE, cursor, q: query, sort, installedOnly, }); if (!active) return; setMarketSkills(Array.isArray(resp.items) ? resp.items : []); setMarketSkillTotal(Number.isFinite(Number(resp.total_count)) ? Number(resp.total_count) : 0); } catch (e) { if (active) setError(e?.message || String(e)); } finally { if (active) setSkillPageLoading(false); } })(); return () => { active = false; }; }, [tab, page, query, sort, installedOnly]); // installed = (enabled in mySkills) ∪ (skill_id in bindings). const installedSkillIds = mkUseMemo(() => { const s = new Set(bindings.skill_ids || []); for (const sk of mySkills) { if (sk?.enabled) s.add(sk.skill_id); } return s; }, [bindings.skill_ids, mySkills]); const ownedSkillIds = mkUseMemo(() => { const s = new Set(); for (const sk of mySkills) { if (sk?.owner_type === "user" && sk?.owner_user_id === window.api.userId) { s.add(sk.skill_id); } } return s; }, [mySkills]); const setBusy = (id, on) => { setBusyIds((prev) => { const next = { ...prev }; if (on) next[id] = true; else delete next[id]; return next; }); }; // ---- mutations --------------------------------------------------------- async function installSkill(skill) { // PR-S.6c install flow: // 1. preflight 检测 shadow / already-installed // 2. 有警告 → 弹 modal,等 user 点 Continue/Cancel // 3. 干净 → 直接 install // preflight 挂(老后端没这 endpoint)→ skip modal,直接 install。 setBusy(skill.skill_id, true); setError(null); let preflight = null; try { preflight = await window.api.preflightInstallSkill(skill.skill_id); } catch { preflight = null; } const hasCollisions = preflight && Array.isArray(preflight.collisions) && preflight.collisions.length > 0; const alreadyInstalled = preflight && preflight.already_installed; if (preflight && (hasCollisions || alreadyInstalled)) { // 留 busy=true,modal 的 Continue/Cancel handler 才 clear。 setPendingInstall({ skill, preflight }); return; } try { await window.api.installSkill(skill.skill_id, skill.latest_version); await refreshUserState(); } catch (e) { setError(`Install ${skill.name}: ${e?.message || e}`); } finally { setBusy(skill.skill_id, false); } } async function confirmPendingInstall() { if (!pendingInstall) return; const { skill } = pendingInstall; setError(null); setPendingInstall(null); try { await window.api.installSkill(skill.skill_id, skill.latest_version); await refreshUserState(); } catch (e) { setError(`Install ${skill.name}: ${e?.message || e}`); } finally { setBusy(skill.skill_id, false); } } function cancelPendingInstall() { if (!pendingInstall) return; const { skill } = pendingInstall; setPendingInstall(null); setBusy(skill.skill_id, false); } async function uninstallSkill(skill) { setBusy(skill.skill_id, true); setError(null); try { const instanceId = skill.installed_instance_id || skill.skill_id; if (ownedSkillIds.has(instanceId)) { await window.api.deletePersonalSkill(instanceId); } else { const cur = bindings.skill_ids || []; if (cur.includes(instanceId)) { const next = cur.filter((id) => id !== instanceId); await window.api.updateUserBindings({ skill_ids: next, mcp_ids: bindings.mcp_ids }); } } await refreshUserState(); } catch (e) { setError(`Uninstall ${skill.name}: ${e?.message || e}`); } finally { setBusy(skill.skill_id, false); } } async function installMcp(mcp) { // PR-S.7c install flow: // 1. preflight 拿 collisions / already_installed / config_schema // 2. 任何一项非空 → 弹 modal 收 env+secrets,Confirm 才真 install // 3. 干净 + 无 config_schema → 直 install(只 wire bindings,无 env / secret) // preflight 挂(老后端无 endpoint)→ 走 legacy bindings PATCH 兜底 setBusy(mcp.server_id, true); setError(null); let preflight = null; try { preflight = await window.api.preflightInstallMcp(mcp.server_id); } catch { preflight = null; } if (preflight) { const hasCollisions = Array.isArray(preflight.collisions) && preflight.collisions.length > 0; const alreadyInstalled = !!preflight.already_installed; const needsForm = Array.isArray(preflight.config_schema) && preflight.config_schema.length > 0; if (hasCollisions || alreadyInstalled || needsForm) { setPendingMcpInstall({ mcp, preflight }); return; } // Clean + no form → install via new fork endpoint with empty env/secrets. try { await window.api.installMcp(mcp.server_id, { env: {}, secrets: {} }); await refreshUserState(); } catch (e) { setError(`Install ${mcp.name}: ${e?.message || e}`); } finally { setBusy(mcp.server_id, false); } return; } // Legacy fallback:老 backend 没 preflight endpoint → bindings PATCH 路径 try { const cur = bindings.mcp_ids || []; if (!cur.includes(mcp.server_id)) { const next = [...cur, mcp.server_id]; await window.api.updateUserBindings({ skill_ids: bindings.skill_ids, mcp_ids: next }); } await refreshUserState(); } catch (e) { setError(`Install ${mcp.name}: ${e?.message || e}`); } finally { setBusy(mcp.server_id, false); } } async function confirmPendingMcpInstall(env, secrets) { if (!pendingMcpInstall) return; const { mcp } = pendingMcpInstall; setError(null); setPendingMcpInstall(null); try { await window.api.installMcp(mcp.server_id, { env, secrets }); await refreshUserState(); } catch (e) { setError(`Install ${mcp.name}: ${e?.message || e}`); } finally { setBusy(mcp.server_id, false); } } function cancelPendingMcpInstall() { if (!pendingMcpInstall) return; const { mcp } = pendingMcpInstall; setPendingMcpInstall(null); setBusy(mcp.server_id, false); } async function uninstallMcp(mcp) { setBusy(mcp.server_id, true); setError(null); try { const instanceId = mcp.installed_instance_id; if (instanceId) { await window.api.uninstallMcp(instanceId); } else { const mids = bindings.mcp_ids || []; if (!mids.includes(mcp.server_id)) { throw new Error("Could not find a matching MCP installation."); } await window.api.updateUserBindings({ skill_ids: bindings.skill_ids, mcp_ids: mids.filter((id) => id !== mcp.server_id), }); } await refreshUserState(); } catch (e) { setError(`Uninstall ${mcp.name}: ${e?.message || e}`); } finally { setBusy(mcp.server_id, false); } } // ---- derived filtered + paged list ------------------------------------ const catalog = tab === "skill" ? marketSkills : marketMcps; const cats = mkUseMemo(() => deriveCategories(catalog), [catalog]); const filtered = mkUseMemo(() => { if (tab === "skill") return marketSkills; let xs = catalog.filter((it) => { if (cat !== "All" && it.category !== cat) return false; if (query.trim()) { const q = query.toLowerCase(); const name = (it.name || "").toLowerCase(); const desc = (it.description || it.desc || "").toLowerCase(); if (!name.includes(q) && !desc.includes(q)) return false; } if (installedOnly) { if (!it.installed) return false; } return true; }); if (sort === "newest") { xs = [...xs].sort((a, b) => { const av = a.latest_version || ""; const bv = b.latest_version || ""; if (av === bv) return (a.name || "").localeCompare(b.name || ""); return bv.localeCompare(av); }); } else { xs = [...xs].sort((a, b) => (a.name || "").localeCompare(b.name || "")); } return xs; }, [catalog, marketSkills, cat, query, sort, installedOnly, tab]); const total = tab === "skill" ? marketSkillTotal : filtered.length; const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); const safePage = Math.min(page, pageCount); const start = (safePage - 1) * PAGE_SIZE; const pageItems = tab === "skill" ? marketSkills : filtered.slice(start, Math.min(start + PAGE_SIZE, filtered.length)); const end = Math.min(start + pageItems.length, total); const installedCount = tab === "skill" ? installedSkillIds.size : marketMcps.filter((mcp) => mcp.installed).length; // ---- topbar nav -------------------------------------------------------- const navLink = (label, href) => ( {label} ); return (
baizhi / Marketplace
{navLink("Playground", "/playground")} {navLink("Manage", "/manage")} {navLink("Evals", "/evals")} {navLink("Traces", "/traces")} {navLink("Apps", "/apps")} {isAdmin && navLink("Users", "/users")} {navLink("API Docs", "/api-docs")}
{/* PR-S.2-nav-order: Marketplace 是统一顺序里的第 5 位,active button 天然落在正确位置(它自己),无需再 reorder。*/}
Publish
{ setQuery(e.target.value); setPage(1); }} />
{cats.length > 1 ? (
{cats.map((c) => ( ))}
) : (
)}
{error &&
{error}
} {pendingInstall && ( )} {pendingMcpInstall && ( )}
{loading || (tab === "skill" && skillPageLoading) ? (

Loading…

Fetching marketplace catalog and your installs.

) : pageItems.length === 0 ? (

No {tab === "skill" ? "skills" : "MCP servers"} found

{installedOnly ? `You haven't installed any ${tab === "skill" ? "skills" : "MCP servers"} yet. Browse the catalog and click + to install.` : (query || cat !== "All") ? "Try a different search term or category." : "Admins can add catalog items from the Manage page."}

{installedOnly && (

)}
) : ( <>
{pageItems.map((it) => { if (tab === "skill") { const id = it.skill_id; const installed = !!it.installed; return ( installSkill(it)} onUninstall={() => uninstallSkill(it)} /> ); } else { const id = it.server_id; const installed = !!it.installed; const disabledReason = !installed && it.available === false ? "MCP server is currently unavailable" : null; return ( installMcp(it)} onUninstall={() => uninstallMcp(it)} /> ); } })}
)}
); } ReactDOM.createRoot(document.getElementById("root")).render();