// ====== Manager app (wired to live backend) ====== // // Loads skills first, then agents / mcps from the API at mount; agent // decoration needs window.SKILLS to translate backend skill_ids. // Surfaces them through the three editors which call back into api.* for persistence. // // What the backend supports today (so the UI is honest about it): // • Agents: list, create, update (name + system_prompt + skill_ids). // No delete endpoint — Delete button is hidden. // • Skills: list, create personal-skill draft, update draft, publish. // File tree only roundtrips SKILL.md content — backend doesn't // store other files yet. Other files are visible/editable but // warned as local-only. // • MCP: list, create, update, delete, check health, and bind/unbind // the catalog item for the current user's runs. const { useState: mUseState, useEffect: mUseEffect, useRef: mUseRef, useMemo: mUseMemo } = React; const MANAGE_MARKET_SKILLS_LIMIT = 200; const MODEL_CONFIG_UI_KEYS = [ "modelProvider", "model", "temperature", "top_p", "max_tokens", "reasoning_enabled", "reasoning_visible", ]; function loadSkillFiles(skill) { // The list-skills endpoint doesn't return the SKILL.md body — only the // editor needs it, and only for personal/editable skills (drafts). For // public skills, we show a synthesized SKILL.md placeholder. if (!skill) return []; // If we already populated files (e.g. for a freshly-created personal // skill), keep them. if (skill.files && skill.files.some((f) => f.content)) return skill.files; return [{ path: "SKILL.md", required: true, content: skill.editable ? "# SKILL.md content not yet fetched. Click in the editor to start writing." : `# ${skill.name}\n\n${skill.description || ""}\n\n(read-only: this is a public skill installed from the catalog)`, }]; } function changedLineRange(before = "", after = "") { const oldLines = before.split("\n"); const newLines = after.split("\n"); let prefix = 0; while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix += 1; let suffix = 0; while ( suffix < oldLines.length - prefix && suffix < newLines.length - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix] ) suffix += 1; return { start: prefix + 1, end: Math.max(prefix + 1, newLines.length - suffix), }; } function skillTestBadge(state) { if (state?.status !== "passed") return null; const completedAt = Number(state.completed_at || 0); const detail = completedAt ? `Skill Test passed on ${new Date(completedAt).toLocaleString()}` : "Skill Test passed"; return tested; } function ManagerApp() { const [section, setSection] = mUseState("agents"); const [agents, setAgents] = mUseState([]); const [skills, setSkills] = mUseState([]); const [mcps, setMcps] = mUseState([]); const [apps, setApps] = mUseState([]); const [localTools, setLocalTools] = mUseState([]); // PR-S.1: marketplace = 全平台 public skills/mcps,跟 Mine 视图(`skills`/`mcps`) // 分开 fetch。subTab 决定 list-col 展示哪一份。 const [marketSkills, setMarketSkills] = mUseState([]); const [marketSkillsTotal, setMarketSkillsTotal] = mUseState(0); const [marketSkillsCursor, setMarketSkillsCursor] = mUseState(null); const [marketSkillsHasMore, setMarketSkillsHasMore] = mUseState(false); const [marketSkillsLoading, setMarketSkillsLoading] = mUseState(false); const [marketSkillsLoadingMore, setMarketSkillsLoadingMore] = mUseState(false); const [marketSkillsPrefetch, setMarketSkillsPrefetch] = mUseState(null); const [marketSkillsQueryKey, setMarketSkillsQueryKey] = mUseState(null); const [marketSkillsPreloaded, setMarketSkillsPreloaded] = mUseState(false); const [marketMcps, setMarketMcps] = mUseState([]); const [skillsSubTab, setSkillsSubTab] = mUseState("mine"); // "mine" | "marketplace" const [mcpsSubTab, setMcpsSubTab] = mUseState("mine"); // PR-S.2: caller's admin flag — drives whether admin actions (Add MCP // catalog item, etc.) render. Default false until /v1/auth/me responds. const [isAdmin, setIsAdmin] = mUseState(false); // Bindings still come from Marketplace as the primary install surface, but // MCP detail needs to show whether a checked server is actually advertised // to future runs for this user. const [bindings, setBindings] = mUseState({ skill_ids: [], mcp_ids: [] }); const [loaded, setLoaded] = mUseState(false); const [loadError, setLoadError] = mUseState(null); const [toast, setToast] = mUseState(null); const [selectedAgent, setSelectedAgent] = mUseState(null); const [selectedSkill, setSelectedSkill] = mUseState(null); const [selectedMCP, setSelectedMCP] = mUseState(null); const [query, setQuery] = mUseState(""); const marketSkillsRequestSeq = mUseRef(0); const marketSkillsLoadingMoreRef = mUseRef(false); // PR-S.16:Add-MCP modal 状态(原 window.prompt 简陋路径升级) const [mcpAddModalOpen, setMcpAddModalOpen] = mUseState(false); // Initial load mUseEffect(() => { (async () => { try { const s = await api.listSkills({ scope: "mine" }); window.SKILLS = s; const [a, m, lt, modelsResp, mm, me, tb, appItems] = await Promise.all([ api.listAgents(), api.listMcpServers(), api.listLocalTools().catch(() => []), api.listModels().catch(() => null), // 后端不可达时退回 bootstrap MODELS // PR-S.1: MCP marketplace remains small; skills marketplace is loaded lazily. api.listMarketplaceMcps().catch(() => []), // PR-S.2: 读 admin 标志(失败不挡)。 api.authMe().catch(() => ({ is_admin: false })), api.getUserBindings().catch(() => ({ skill_ids: [], mcp_ids: [] })), api.listApps().catch(() => []), ]); setIsAdmin(!!me.is_admin); setBindings({ skill_ids: Array.isArray(tb?.skill_ids) ? tb.skill_ids : [], mcp_ids: Array.isArray(tb?.mcp_ids) ? tb.mcp_ids : [], }); window.INITIAL_AGENTS = a; window.SKILLS = s; window.MCPS = m; window.LOCAL_TOOLS = lt; // LLM 窗口权威来源是后端;覆盖 bootstrap MODELS。 if (modelsResp && Array.isArray(modelsResp.items) && modelsResp.items.length) { window.MODELS = modelsResp.items; } if (modelsResp && Array.isArray(modelsResp.providers) && modelsResp.providers.length) { window.MODEL_PROVIDERS = modelsResp.providers; } setAgents(a); setSkills(s.map((sk) => ({ ...sk, files: loadSkillFiles(sk) }))); setMcps(m); setLocalTools(lt); setApps(appItems); setMarketMcps(mm); setSelectedAgent(a[0]?.id || null); setSelectedSkill(s[0]?.id || null); setSelectedMCP(m[0]?.id || null); setLoaded(true); preloadMarketplaceSkillsFirstPage(); } catch (err) { setLoadError(err.message || String(err)); setLoaded(true); } })(); }, []); mUseEffect(() => { window.SKILLS = skills; }, [skills]); mUseEffect(() => { window.MCPS = mcps; }, [mcps]); mUseEffect(() => { window.LOCAL_TOOLS = localTools; }, [localTools]); // Keep Manage's App Tools view aligned with runtime tool injection. The // runtime scopes App tools by the current Agent, so refresh this list when // the selected Agent changes instead of relying only on user-owned Apps. mUseEffect(() => { if (!selectedAgent) return; api.listApps({ agentId: selectedAgent }).then((agentApps) => { setApps((previous) => { const merged = new Map(previous.map((item) => [item.app_id, item])); for (const item of agentApps || []) merged.set(item.app_id, item); return Array.from(merged.values()); }); }).catch(() => {}); }, [selectedAgent]); mUseEffect(() => { window.INITIAL_AGENTS = agents; }, [agents]); mUseEffect(() => { if (agents.length && !agents.find((a) => a.id === selectedAgent)) setSelectedAgent(agents[0]?.id); }, [agents]); mUseEffect(() => { if (skills.length && !skills.find((s) => s.id === selectedSkill)) setSelectedSkill(skills[0]?.id); }, [skills]); mUseEffect(() => { if (mcps.length && !mcps.find((m) => m.id === selectedMCP)) setSelectedMCP(mcps[0]?.id); }, [mcps]); async function preloadMarketplaceSkillsFirstPage() { try { await loadMarketplaceSkillsFirstPage(); setMarketSkillsPreloaded(true); } catch (_) { setMarketSkillsPreloaded(false); } } // Hydrate the file tree for a personal skill on first selection. The /v1/skills // listing doesn't include draft contents — we fetch them lazily so list rendering // stays cheap and editing has real bytes to round-trip. mUseEffect(() => { const skill = skills.find((x) => x.id === selectedSkill); if (!skill || !skill.editable) return; if (skill._draftLoaded) return; api.getPersonalSkillDraft(skill.skill_id).then((draft) => { setSkillTestStates((prev) => ({ ...prev, [skill.id]: draft.test_state || { status: "idle" } })); setSkills((prev) => prev.map((s) => s.id !== skill.id ? s : { ...s, _draftLoaded: true, files: [ { path: "SKILL.md", required: true, content: draft.skill_md || "" }, ...Object.entries(draft.other_files || {}).map(([path, content]) => ({ path, content })), ], })); }).catch((err) => { // Mark loaded anyway to avoid retry loop; surface a toast. setSkills((prev) => prev.map((s) => s.id !== skill.id ? s : { ...s, _draftLoaded: true })); showToast("Load draft failed: " + (err.message || err)); }); }, [selectedSkill, skills]); mUseEffect(() => { setQuery(""); }, [section]); mUseEffect(() => { if ( section === "skills" && skillsSubTab === "marketplace" && (!marketSkillsPreloaded || marketSkillsQueryKey !== marketSkillsKey()) ) { loadMarketplaceSkillsFirstPage(); } }, [section, skillsSubTab, query, marketSkillsPreloaded, marketSkillsQueryKey]); const showToast = (msg) => { setToast(msg); setTimeout(() => setToast(null), 2200); }; const marketSkillsKey = () => query.trim(); async function fetchMarketplaceSkillsPage(cursor, key = marketSkillsKey()) { return await api.listMarketplaceSkillsPage({ limit: MANAGE_MARKET_SKILLS_LIMIT, cursor, q: key, sort: "name", }); } async function loadAgentSkillPage({ cursor = null, limit = 50, q = "", sort = "name", } = {}) { return await api.listMarketplaceSkillsPage({ cursor, limit, q, sort }); } async function prefetchMarketplaceSkills(cursor, key, seq) { if (!cursor) return; try { const resp = await fetchMarketplaceSkillsPage(cursor, key); if (seq !== marketSkillsRequestSeq.current) return; setMarketSkillsPrefetch({ key, cursor, resp }); } catch { // Prefetch is opportunistic; the explicit load-more path will retry. } } async function loadMarketplaceSkillsFirstPage() { const key = marketSkillsKey(); const seq = ++marketSkillsRequestSeq.current; setMarketSkillsLoading(true); setMarketSkillsLoadingMore(false); marketSkillsLoadingMoreRef.current = false; setMarketSkillsPrefetch(null); setMarketSkillsQueryKey(key); setMarketSkills([]); setMarketSkillsTotal(0); setMarketSkillsCursor(null); setMarketSkillsHasMore(false); try { const resp = await fetchMarketplaceSkillsPage(null, key); if (seq !== marketSkillsRequestSeq.current) return; const items = Array.isArray(resp?.items) ? resp.items : []; setMarketSkills(items); if (key === "") setMarketSkillsPreloaded(true); setMarketSkillsTotal(Number.isFinite(Number(resp?.total_count)) ? Number(resp.total_count) : items.length); setMarketSkillsCursor(resp?.next_cursor || null); setMarketSkillsHasMore(!!resp?.has_more); if (resp?.has_more && resp?.next_cursor) { prefetchMarketplaceSkills(resp.next_cursor, key, seq); } } catch (err) { if (seq === marketSkillsRequestSeq.current) { setMarketSkills([]); if (key === "") setMarketSkillsPreloaded(false); setMarketSkillsTotal(0); setMarketSkillsCursor(null); setMarketSkillsHasMore(false); showToast("Load marketplace skills failed: " + (err.message || err)); } } finally { if (seq === marketSkillsRequestSeq.current) setMarketSkillsLoading(false); } } async function loadMoreMarketplaceSkills() { if (!marketSkillsHasMore || !marketSkillsCursor) return; if (marketSkillsLoading || marketSkillsLoadingMoreRef.current) return; const key = marketSkillsQueryKey ?? marketSkillsKey(); const cursor = marketSkillsCursor; const seq = ++marketSkillsRequestSeq.current; marketSkillsLoadingMoreRef.current = true; setMarketSkillsLoadingMore(true); try { let resp = null; if (marketSkillsPrefetch?.key === key && marketSkillsPrefetch?.cursor === cursor) { resp = marketSkillsPrefetch.resp; } else { resp = await fetchMarketplaceSkillsPage(cursor, key); } if (seq !== marketSkillsRequestSeq.current) return; const items = Array.isArray(resp?.items) ? resp.items : []; setMarketSkills((prev) => [...prev, ...items]); setMarketSkillsTotal(Number.isFinite(Number(resp?.total_count)) ? Number(resp.total_count) : marketSkillsTotal); setMarketSkillsCursor(resp?.next_cursor || null); setMarketSkillsHasMore(!!resp?.has_more); setMarketSkillsPrefetch(null); if (resp?.has_more && resp?.next_cursor) { prefetchMarketplaceSkills(resp.next_cursor, key, seq); } } catch (err) { showToast("Load more marketplace skills failed: " + (err.message || err)); } finally { if (seq === marketSkillsRequestSeq.current) { marketSkillsLoadingMoreRef.current = false; setMarketSkillsLoadingMore(false); } } } function onListScroll(e) { if (section !== "skills" || skillsSubTab !== "marketplace") return; const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight < 160) { loadMoreMarketplaceSkills(); } } const refreshSkillState = async (preferredSkillId = selectedSkill) => { const [fresh, tb] = await Promise.all([ api.listSkills({ scope: "mine" }), api.getUserBindings().catch(() => ({ skill_ids: [], mcp_ids: [] })), ]); const enriched = fresh.map((s) => ({ ...s, files: loadSkillFiles(s) })); setSkills(enriched); setBindings({ skill_ids: Array.isArray(tb?.skill_ids) ? tb.skill_ids : [], mcp_ids: Array.isArray(tb?.mcp_ids) ? tb.mcp_ids : [], }); const nextSelected = preferredSkillId && enriched.some((s) => s.id === preferredSkillId) ? preferredSkillId : enriched[0]?.id || null; setSelectedSkill(nextSelected); if (section === "skills" && skillsSubTab === "marketplace") { await loadMarketplaceSkillsFirstPage(); } return enriched; }; // Per-entity save state for the editor's SaveBar (PR 18). Agents still // autosave on backend-backed edits. Skills are intentionally local-dirty // until the user clicks Save, so editing SKILL.md/name does not PATCH on // every keystroke. const [agentSaveStates, setAgentSaveStates] = React.useState({}); // {id: {status, lastSavedAt?, error?}} const [skillSaveStates, setSkillSaveStates] = React.useState({}); const [skillTestStates, setSkillTestStates] = React.useState({}); const [skillChangedLines, setSkillChangedLines] = React.useState({}); const [mcpCheckStates, setMcpCheckStates] = React.useState({}); const [mcpBindingStates, setMcpBindingStates] = React.useState({}); const setAgentSaveState = (id, patch) => setAgentSaveStates((m) => ({ ...m, [id]: { ...(m[id] || {}), ...patch } })); const setSkillSaveState = (id, patch) => setSkillSaveStates((m) => ({ ...m, [id]: { ...(m[id] || {}), ...patch } })); const setMcpCheckState = (id, patch) => setMcpCheckStates((m) => ({ ...m, [id]: { ...(m[id] || {}), ...patch } })); const setMcpBindingState = (id, patch) => setMcpBindingStates((m) => ({ ...m, [id]: { ...(m[id] || {}), ...patch } })); // ---------- agents ---------- // Optimistic local + backend PATCH. Model fields persist in Agent.model_config; // only presentation fields(color / initials / description) stay local. // MCP simplification(2026-05-26):`enabledMCPs` 现在也走 backend // (`agent.enabled_mcp_servers`),不再 localStorage-only。 const updateAgent = async (id, patch) => { let optimistic; setAgents((prev) => prev.map((a) => { if (a.id !== id) return a; optimistic = { ...a, ...patch }; api.persistAgentUi(optimistic); return optimistic; })); const touchesBackend = "name" in patch || "system" in patch || "promptVariables" in patch || "maxAgentRounds" in patch || "enabledSkills" in patch || "enabledMCPs" in patch || "enabledLocalTools" in patch || MODEL_CONFIG_UI_KEYS.some((key) => key in patch); if (!touchesBackend || !optimistic) { // UI-only fields land in localStorage synchronously — surface as saved. if (optimistic) setAgentSaveState(id, { status: "saved", lastSavedAt: Date.now(), error: null }); return; } setAgentSaveState(id, { status: "saving" }); try { const updated = await api.updateAgent(id, { name: optimistic.name, system_prompt: optimistic.system || "", prompt_variables: optimistic.promptVariables || [], max_agent_rounds: optimistic.maxAgentRounds || 0, skill_ids: optimistic.enabledSkills || [], enabled_mcp_servers: optimistic.enabledMCPs || [], local_tool_names: optimistic.enabledLocalTools || optimistic.localToolNames || [], model_config: api.modelConfigFromAgentUi(optimistic), }); setAgents((prev) => prev.map((a) => a.id === id ? { ...a, ...updated } : a)); setAgentSaveState(id, { status: "saved", lastSavedAt: Date.now(), error: null }); } catch (err) { const msg = err.message || String(err); setAgentSaveState(id, { status: "error", error: msg }); showToast("Save failed: " + msg); } }; // Force a re-save of the entity's *current* local state — what the user // gets when they click the bottom Save button. Because we already auto- // save on each keystroke this is usually a no-op round trip, but the // explicit feedback ("Saved just now") closes the bug.md P1 "用户无法 // 判断修改是否已经保存". const forceSaveAgent = async (id) => { const a = agents.find((x) => x.id === id); if (!a) return; await updateAgent(id, { name: a.name, system: a.system, promptVariables: a.promptVariables || [], maxAgentRounds: a.maxAgentRounds || null, enabledSkills: a.enabledSkills, enabledMCPs: a.enabledMCPs, enabledLocalTools: a.enabledLocalTools || a.localToolNames || [], modelProvider: a.modelProvider, model: a.model, temperature: a.temperature, top_p: a.top_p, max_tokens: a.max_tokens, reasoning_enabled: a.reasoning_enabled, reasoning_visible: a.reasoning_visible, }); }; const installSkillTemplateForAgent = async (agentId, templateId, version) => { try { const installed = await api.installSkillForAgent(agentId, templateId, version); const instanceId = installed.skill_id || installed.instance_id; if (!instanceId) throw new Error("install response missing skill_id"); await updateAgent(agentId, { enabledSkills: [ ...new Set([ ...((agents.find((a) => a.id === agentId)?.enabledSkills) || []), instanceId, ]), ], }); const freshAgents = await api.listAgents(); setAgents(freshAgents); showToast(`Attached ${installed.name || templateId}@${installed.version || version}`); } catch (err) { showToast("Attach skill failed: " + (err.message || err)); } }; const addAgent = async () => { const n = agents.length + 1; try { const created = await api.createAgent({ name: `Untitled agent ${n}`, system_prompt: "You are a helpful assistant.", prompt_variables: [], max_agent_rounds: null, skill_ids: [], enabled_mcp_servers: [], local_tool_names: [], }); api.persistAgentUi(created); setAgents((p) => [...p, created]); setSelectedAgent(created.id); } catch (err) { alert("Create failed: " + (err.message || err)); } }; // PR pg-new-agent-to-manage: when Playground sent us here via "+ New // agent" the URL is /manage?new=agent. Once initial data is loaded, // auto-trigger addAgent so the user lands on a fresh editable agent // (rather than seeing the existing list and having to click again). // Clear ?new=agent so a refresh doesn't re-fire. mUseEffect(() => { if (!loaded) return; try { const params = new URLSearchParams(window.location.search); if (params.get("new") !== "agent") return; params.delete("new"); const newSearch = params.toString(); window.history.replaceState( {}, "", window.location.pathname + (newSearch ? "?" + newSearch : ""), ); addAgent(); } catch (_) { /* URLSearchParams unsupported — skip silently */ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [loaded]); const deleteAgent = async (agent) => { if (agent.id === "agent_default") { showToast("The default agent can't be deleted."); return; } if (!confirm(`Delete agent "${agent.name}"?`)) return; try { await api.deleteAgent(agent.id); setAgents((prev) => prev.filter((a) => a.id !== agent.id)); showToast(`Deleted ${agent.name}`); } catch (err) { showToast("Delete failed: " + (err.message || err)); } }; // ---------- skills ---------- // Local-only draft editing. Persisting to the backend happens in // saveSkillDraft(), bound to the explicit Save button. const updateSkill = async (id, patch) => { let nextLocal = null; setSkills((prev) => prev.map((s) => { if (s.id !== id) return s; nextLocal = { ...s, ...patch }; return nextLocal; })); if (!nextLocal?.editable) { if (nextLocal) setSkillSaveState(id, { status: "readonly" }); return; } setSkillSaveState(id, { status: "dirty", error: null }); }; const saveSkillDraft = async (skill) => { if (!skill?.editable) { if (skill) setSkillSaveState(skill.id, { status: "readonly" }); return false; } const skillMd = skill.files?.find((f) => f.path === "SKILL.md")?.content; if (!skillMd) { setSkillSaveState(skill.id, { status: "error", error: "SKILL.md missing — can't save" }); return false; } const otherFiles = {}; for (const f of skill.files || []) { if (f.path === "SKILL.md") continue; otherFiles[f.path] = f.content || ""; } setSkillSaveState(skill.id, { status: "saving" }); try { await api.updatePersonalSkillDraft(skill.skill_id, skillMd, otherFiles); setSkillSaveState(skill.id, { status: "saved", lastSavedAt: Date.now(), error: null }); return true; } catch (err) { const msg = err.message || String(err); setSkillSaveState(skill.id, { status: "error", error: msg }); showToast("Draft save failed: " + msg); return false; } }; const forceSaveSkill = async (id) => { const s = skills.find((x) => x.id === id); if (!s) return; await saveSkillDraft(s); }; const runSkillTest = async (skill) => { if (!skill?.editable) return; if (skillSaveStates[skill.id]?.status === "dirty") { showToast("Save the draft before testing."); return; } try { const started = await api.startPersonalSkillTest(skill.skill_id); setSkillTestStates((prev) => ({ ...prev, [skill.id]: started })); const poll = async () => { const current = await api.getPersonalSkillTest(skill.skill_id); setSkillTestStates((prev) => ({ ...prev, [skill.id]: current })); if (current.status === "running") window.setTimeout(poll, 1200); else showToast(current.status === "passed" ? "Skill Test passed. You can publish this draft." : "Skill Test finished with findings."); }; window.setTimeout(poll, 600); } catch (err) { showToast("Skill Test failed to start: " + (err.message || err)); } }; const applySkillTestProposal = async (skill, proposalId) => { setSkillSaveState(skill.id, { status: "saving", error: null }); setSkillTestStates((prev) => ({ ...prev, [skill.id]: { ...(prev[skill.id] || {}), applying_proposal_id: proposalId }, })); try { await api.applyPersonalSkillTestProposal(skill.skill_id, proposalId); const draft = await api.getPersonalSkillDraft(skill.skill_id); const nextFiles = [{ path: "SKILL.md", required: true, content: draft.skill_md || "" }, ...Object.entries(draft.other_files || {}).map(([path, content]) => ({ path, content }))]; const beforeByPath = new Map((skill.files || []).map((file) => [file.path, file.content || ""])); const changed = {}; for (const file of nextFiles) { const before = beforeByPath.get(file.path) || ""; if (before !== (file.content || "")) changed[file.path] = changedLineRange(before, file.content || ""); } setSkills((prev) => prev.map((s) => s.id !== skill.id ? s : { ...s, _draftLoaded: true, files: nextFiles, })); setSkillChangedLines((prev) => ({ ...prev, [skill.id]: changed })); setSkillTestStates((prev) => { const prior = prev[skill.id] || {}; const applied = (prior.quality?.proposals || []).find((item) => item.id === proposalId); return { ...prev, // Saving invalidates the published Test pass, but the completed // report remains visible so the user can confirm what changed. [skill.id]: { ...prior, ...(draft.test_state || { status: "stale" }), status: "stale", applying_proposal_id: null, applied_proposal: applied ? { path: applied.path, reason: applied.reason } : { path: "Skill draft" }, quality: { ...(prior.quality || {}), status: "stale", proposals: [] }, }, }; }); setSkillSaveState(skill.id, { status: "saved", lastSavedAt: Date.now(), error: null }); showToast("Applied suggestion. Run Test again before publishing."); } catch (err) { setSkillTestStates((prev) => ({ ...prev, [skill.id]: { ...(prev[skill.id] || {}), applying_proposal_id: null }, })); setSkillSaveState(skill.id, { status: "error", error: err.message || String(err) }); showToast("Apply suggestion failed: " + (err.message || err)); } }; const deleteSkill = async (skill) => { if (!skill?.editable) { showToast("Public skills can't be deleted."); return; } if (!confirm(`Delete personal skill "${skill.name}"? This removes it for all your agents.`)) return; try { await api.deletePersonalSkill(skill.skill_id); setSkills((prev) => prev.filter((s) => s.id !== skill.id)); // Also strip from any agent that referenced it. setAgents((prev) => prev.map((a) => ({ ...a, enabledSkills: (a.enabledSkills || []).filter((id) => id !== skill.skill_id), }))); showToast(`Deleted ${skill.name}`); } catch (err) { showToast("Delete failed: " + (err.message || err)); } }; const addSkill = async () => { const n = skills.length + 1; const name = `new_skill_${n}`; const desc = "A new skill."; const skill_md = SKILL_TEMPLATE_MD.replace(/{name}/g, name).replace(/{desc}/g, desc); try { const created = await api.createPersonalSkill(name, skill_md); const enriched = await refreshSkillState(created.skill_id); setSkills(enriched.map((s) => ( s.skill_id === created.skill_id ? { ...s, files: [{ path: "SKILL.md", required: true, content: skill_md }] } : s ))); showToast(`Created draft "${name}". Publish a version to install it.`); } catch (err) { alert("Create skill failed: " + (err.message || err)); } }; // PR-F.1: import-from-URL skill creation. State drives the inline modal // (urlImportOpen). Submit calls the same backend pipeline as upload but // the bytes come from a URL we fetch server-side. Two URL shapes accepted: // - direct .zip URL (any https endpoint serving a ZIP) // - GitHub /tree/ URL (we extract the subdir + repackage server-side) const [urlImportOpen, setUrlImportOpen] = mUseState(false); const [urlImportValue, setUrlImportValue] = mUseState(""); const [urlImportBusy, setUrlImportBusy] = mUseState(false); const [urlImportError, setUrlImportError] = mUseState(null); const submitUrlImport = async () => { const url = (urlImportValue || "").trim(); if (!url) { setUrlImportError("URL is required"); return; } setUrlImportBusy(true); setUrlImportError(null); try { const created = await api.importPersonalSkillFromUrl(url); await refreshSkillState(created.skill_id); // Close + clear on success. setUrlImportOpen(false); setUrlImportValue(""); // PR-F.1c: if the server skipped binary README assets, surface the // count in the toast so the user isn't surprised when their // SKILL.md preview is "missing" the PNGs they saw on GitHub. const skipped = created.skipped_binary_files; const skippedSuffix = skipped && skipped.count > 0 ? ` (skipped ${skipped.count} binary file${skipped.count > 1 ? "s" : ""})` : ""; showToast(`Imported "${created.name}"${skippedSuffix}. Publish to install.`); } catch (err) { setUrlImportError(err.message || String(err)); } finally { setUrlImportBusy(false); } }; // Upload-ZIP variant of skill creation. Hidden is // anchored in the topbar render below; clicking the "Upload ZIP" button // triggers it via uploadZipInputRef. Backend (POST /v1/personal-skills/upload) // does the ZIP validation; we just refresh the list on success. const uploadZipInputRef = mUseRef(null); const onUploadZipPicked = async (e) => { const file = e.target.files?.[0]; e.target.value = ""; // allow re-uploading the same filename if (!file) return; if (!file.name.toLowerCase().endsWith(".zip")) { alert("Please pick a .zip file. Got: " + file.name); return; } try { const created = await api.uploadPersonalSkillZip(file); await refreshSkillState(created.skill_id); const skipped = created.skipped_binary_files; const skippedSuffix = skipped && skipped.count > 0 ? ` (skipped ${skipped.count} binary file${skipped.count > 1 ? "s" : ""})` : ""; showToast(`Imported "${created.name}" from ${file.name}${skippedSuffix}. Publish a version to make it runnable.`); } catch (err) { alert("Upload failed: " + (err.message || err)); } }; const publishSkillVersion = async (skill) => { if (!skill?.editable) return; if (skillSaveStates[skill.id]?.status === "dirty") { showToast("Save the draft before publishing."); return; } const versionStr = (skill.version === "draft" ? "0.1.0" : (skill.version || "")).trim(); if (!versionStr) { showToast("Enter a version before publishing."); return; } try { await api.publishPersonalSkill(skill.skill_id, versionStr, true); await refreshSkillState(skill.skill_id); showToast(`Published private version ${skill.name}@${versionStr}`); } catch (err) { alert("Publish failed: " + (err.message || err)); } }; const publishSkillToMarketplace = async (skill) => { if (!skill?.editable || !skill.latest_version) return; if (skillSaveStates[skill.id]?.status === "dirty") { showToast("Save the draft before publishing to Marketplace."); return; } if (!confirm(`Publish ${skill.name}@${skill.latest_version} to Marketplace?`)) return; try { await api.publishPersonalSkillToMarketplace(skill.skill_id); await refreshSkillState(skill.skill_id); showToast(`${skill.name} is now available in Marketplace`); } catch (err) { alert("Marketplace publish failed: " + (err.message || err)); } }; const withdrawSkillFromMarketplace = async (skill) => { if (!skill?.editable || skill.marketplace_visibility !== "public") return; if (!confirm(`Remove ${skill.name} from Marketplace? Existing installations keep their pinned version.`)) return; try { await api.withdrawPersonalSkillFromMarketplace(skill.skill_id); await refreshSkillState(skill.skill_id); showToast(`${skill.name} was removed from Marketplace`); } catch (err) { alert("Marketplace withdrawal failed: " + (err.message || err)); } }; // ---------- mcps ---------- // MCP catalog CRUD (admin) — MCP store PR (2026-05-26):.baizhi-data SQLite // 持久化。debounced auto-save 不在本 PR;现在 patch 立即 PATCH 后端。 const updateMCP = async (id, patch) => { setMcps((prev) => prev.map((m) => (m.id !== id ? m : { ...m, ...patch }))); // 真改字段(name / desc / url / headers / args 等)才触发 backend PATCH; // 纯 UI shape(filter 之类的)走前面那行就停。 const backendKeys = [ "name", "description", "transport", "url", "http_url", "headers_template", "auth_env_key", "command", "args", ]; const touchesBackend = backendKeys.some((k) => k in patch); if (!touchesBackend) return; try { const updated = await api.updateMcpServer(id, patch); setMcps((prev) => prev.map((m) => (m.id !== id ? m : { ...m, ...updated }))); checkMCP(id, { quiet: true }); } catch (err) { showToast("MCP save failed: " + (err.message || err)); } }; const checkMCP = async (id, { quiet = false } = {}) => { setMcpCheckState(id, { status: "checking", error: null }); try { const result = await api.checkMcpServer(id); setMcpCheckState(id, { status: "checked", result, error: null }); setMcps((prev) => prev.map((m) => ( m.id !== id ? m : { ...m, available: !!result.available, status: result.available ? "available" : "unavailable", configStatus: result.config, checkStatus: result, } ))); if (!quiet) { const n = result.tools?.count || 0; showToast(result.mcp?.ok ? `MCP connected · ${n} tool${n === 1 ? "" : "s"}` : `MCP check failed: ${result.mcp?.error || "not available"}`); } } catch (err) { const msg = err.message || String(err); setMcpCheckState(id, { status: "error", error: msg }); if (!quiet) showToast("MCP check failed: " + msg); } }; const setMcpEnabledForUser = async (id, enabled) => { setMcpBindingState(id, { status: "saving", error: null }); const curSkills = Array.isArray(bindings.skill_ids) ? bindings.skill_ids : []; const curMcps = Array.isArray(bindings.mcp_ids) ? bindings.mcp_ids : []; const nextMcps = enabled ? [...new Set([...curMcps, id])] : curMcps.filter((mid) => mid !== id); try { const updated = await api.updateUserBindings({ skill_ids: curSkills, mcp_ids: nextMcps }); setBindings({ skill_ids: Array.isArray(updated?.skill_ids) ? updated.skill_ids : curSkills, mcp_ids: Array.isArray(updated?.mcp_ids) ? updated.mcp_ids : nextMcps, }); setMcpBindingState(id, { status: "saved", error: null }); showToast(enabled ? `Enabled ${id} for future runs` : `Disabled ${id} for future runs`); } catch (err) { const msg = err.message || String(err); setMcpBindingState(id, { status: "error", error: msg }); showToast("MCP binding failed: " + msg); } }; // PR-S.16:opens McpAddModal(单击就开),admin 一次填全字段, // confirmAddMCP 真发 POST。失败 modal 内部接住,不 close 让 admin 重试。 const addMCP = () => setMcpAddModalOpen(true); const confirmAddMCP = async (payload) => { // 让 modal 等 await(失败时它 catch 自处理 err display) const created = await api.createMcpServer(payload); setMcps((p) => [...p, created]); setSelectedMCP(created.id); setMcpAddModalOpen(false); }; const removeMCP = async (id) => { if (!window.confirm(`Delete MCP server "${id}"? Agents that referenced it will silently drop the tool.`)) return; try { await api.deleteMcpServer(id); setMcps((prev) => prev.filter((m) => m.id !== id)); if (selectedMCP === id) { const remain = mcps.filter((m) => m.id !== id); setSelectedMCP(remain[0]?.id || null); } } catch (err) { showToast("Delete MCP failed: " + (err.message || err)); } }; const notSupported = (action) => () => { showToast(`${action} not yet supported on the backend.`); }; // ---------- filtered lists ---------- const filt = (xs, fields) => xs.filter((x) => { if (!query.trim()) return true; const q = query.toLowerCase(); return fields.some((k) => (x[k] || "").toString().toLowerCase().includes(q)); }); const fAgents = filt(agents, ["name", "desc", "id"]); const fSkills = filt(skills, ["name", "description", "id"]); const fMCPs = filt(mcps, ["name", "description", "id"]); // PR-S.1: marketplace 视图过滤(同样 query 搜索) const fMarketSkills = marketSkills; const fMarketMcps = filt(marketMcps, ["name", "description", "server_id"]); const enabledUserMcpIds = new Set(bindings.mcp_ids || []); let listTitle = "Agents", listCount = agents.length; let listItems = null, addLabel = "New agent", onAdd = addAgent; if (section === "agents") { listTitle = "Agents"; listCount = agents.length; listItems = fAgents.map((a) => (
Loading…
{loadError}
.zip URLhttps://github.com/owner/repohttps://github.com/owner/repo/tree/main/skills/pdfScopes are comma-separated, for example read,write.
Pick one from the list or create a new one.