// ====== Agent editor ====== // // PR 18: sticky bottom SaveBar gives the user explicit feedback that their // edits were saved. The auto-save path (parent's onUpdate on every keystroke) // stays for keystroke-level safety; the bar shows status + offers a manual // "Save" click so users who expect a button see one. Closes bug.md P1 // "/manage Agent 管理页没有底部 Save 按钮 / 用户无法判断修改是否已经保存". function relTime(ts) { if (!ts) return ""; const s = Math.max(0, Math.floor((Date.now() - ts) / 1000)); if (s < 5) return "just now"; if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.floor(s / 60)}m ago`; return `${Math.floor(s / 3600)}h ago`; } function SaveBar({ saveState, onSave, extraButtons }) { // tick once per second so the "Saved Xs ago" pill stays fresh without // requiring the parent to re-render on a timer. const [, setTick] = React.useState(0); React.useEffect(() => { const id = setInterval(() => setTick((n) => n + 1), 1000); return () => clearInterval(id); }, []); const st = saveState || { status: "idle" }; const status = st.status; let pill; if (status === "saving") { pill = Saving…; } else if (status === "dirty") { pill = Unsaved changes; } else if (status === "saved") { pill = Saved {relTime(st.lastSavedAt)}; } else if (status === "error") { pill = Save failed; } else if (status === "readonly") { pill = Read-only; } else { pill = No unsaved changes; } const disabled = status === "saving" || status === "readonly"; return (
{pill}
{extraButtons}
); } // expose for SkillEditor (sibling file) — same component, different button slot window.SaveBar = SaveBar; window.relTime = relTime; function PickerPopover({ open, onClose, items, selectedIds, onToggle, groupLabel }) { const [q, setQ] = React.useState(""); const ref = React.useRef(null); React.useEffect(() => { if (!open) return; const fn = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); }; document.addEventListener("mousedown", fn); return () => document.removeEventListener("mousedown", fn); }, [open, onClose]); if (!open) return null; const filt = items.filter((it) => !q || it.name.toLowerCase().includes(q.toLowerCase()) || (it.description||"").toLowerCase().includes(q.toLowerCase())); return (
setQ(e.target.value)} placeholder="Search…" />
{groupLabel}
{filt.length === 0 ? (
No matches
) : filt.map((it, i) => { const on = selectedIds.includes(it.id); return (
onToggle(it.id)}> {it.initials}
{it.name}
{it.description || it.desc}
); })}
); } const REMOTE_SKILL_PAGE_SIZE = 50; const REMOTE_SKILL_SEARCH_DEBOUNCE_MS = 300; const REMOTE_SKILL_SCROLL_THRESHOLD_PX = 120; function mergeUniqueSkillItems(current, incoming) { const byId = new Map(); [...(current || []), ...(incoming || [])].forEach((item) => { const id = item.skill_id || item.id; if (id) byId.set(id, item); }); return [...byId.values()]; } function RemoteSkillPickerPopover({ open, onClose, selectedIds, onToggle, loadSkillPage, groupLabel, }) { const [query, setQuery] = React.useState(""); const [items, setItems] = React.useState([]); const [cursor, setCursor] = React.useState(null); const [hasMore, setHasMore] = React.useState(false); const [loading, setLoading] = React.useState(false); const [loadingMore, setLoadingMore] = React.useState(false); const [error, setError] = React.useState(null); const [pendingSkillId, setPendingSkillId] = React.useState(null); const ref = React.useRef(null); const listRef = React.useRef(null); const requestSeqRef = React.useRef(0); const skipQueryEffectRef = React.useRef(false); const loadingMoreRef = React.useRef(false); async function loadFirstPage(requestedQuery) { const requestSeq = ++requestSeqRef.current; setItems([]); setCursor(null); setHasMore(false); setError(null); setLoading(true); setLoadingMore(false); loadingMoreRef.current = false; if (listRef.current) listRef.current.scrollTop = 0; try { const response = await loadSkillPage({ cursor: null, limit: REMOTE_SKILL_PAGE_SIZE, q: requestedQuery.trim(), sort: "name", }); if (requestSeq !== requestSeqRef.current) return; setItems(mergeUniqueSkillItems([], response?.items)); setCursor(response?.next_cursor || null); setHasMore(!!response?.has_more); } catch (err) { if (requestSeq !== requestSeqRef.current) return; setError(err?.message || String(err)); } finally { if (requestSeq === requestSeqRef.current) setLoading(false); } } async function loadNextPage() { if (!open || loading || loadingMoreRef.current || !hasMore || !cursor) return; const requestSeq = ++requestSeqRef.current; loadingMoreRef.current = true; setLoadingMore(true); setError(null); try { const response = await loadSkillPage({ cursor, limit: REMOTE_SKILL_PAGE_SIZE, q: query.trim(), sort: "name", }); if (requestSeq !== requestSeqRef.current) return; setItems((current) => mergeUniqueSkillItems(current, response?.items)); setCursor(response?.next_cursor || null); setHasMore(!!response?.has_more); } catch (err) { if (requestSeq !== requestSeqRef.current) return; setError(err?.message || String(err)); } finally { if (requestSeq === requestSeqRef.current) { loadingMoreRef.current = false; setLoadingMore(false); } } } React.useEffect(() => { if (!open) { requestSeqRef.current += 1; setQuery(""); setItems([]); setCursor(null); setHasMore(false); setLoading(false); setLoadingMore(false); loadingMoreRef.current = false; setError(null); setPendingSkillId(null); return; } skipQueryEffectRef.current = true; loadFirstPage(""); }, [open]); React.useEffect(() => { if (!open) return; if (skipQueryEffectRef.current) { skipQueryEffectRef.current = false; return; } const timer = setTimeout(() => { loadFirstPage(query); }, REMOTE_SKILL_SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); }, [query, open]); React.useEffect(() => { if (!open) return; const closeOnOutsideClick = (event) => { if (ref.current && !ref.current.contains(event.target)) onClose(); }; document.addEventListener("mousedown", closeOnOutsideClick); return () => document.removeEventListener("mousedown", closeOnOutsideClick); }, [open, onClose]); if (!open) return null; const onScroll = (event) => { const { scrollHeight, scrollTop, clientHeight } = event.currentTarget; if ( scrollHeight - scrollTop - clientHeight < REMOTE_SKILL_SCROLL_THRESHOLD_PX ) { loadNextPage(); } }; const retry = () => { if (items.length && cursor) loadNextPage(); else loadFirstPage(query); }; const toggleSkill = async (item) => { const id = item.skill_id || item.id; if (!id || pendingSkillId) return; setPendingSkillId(id); try { await onToggle(item); } finally { setPendingSkillId(null); } }; return (
{ requestSeqRef.current += 1; setItems([]); setCursor(null); setHasMore(false); setError(null); setLoading(true); setLoadingMore(false); loadingMoreRef.current = false; setQuery(event.target.value); }} placeholder="Search…" />
{groupLabel}
{loading && items.length === 0 ? (
Loading skills…
) : error && items.length === 0 ? (
Load skills failed
) : items.length === 0 ? (
No matches
) : ( <> {items.map((item, index) => { const id = item.skill_id || item.id; const on = selectedIds.includes(id); const unavailable = !on && !item.latest_version; const pending = pendingSkillId === id; return (
{ if (!unavailable && !pending) toggleSkill(item); }} > {item.initials}
{item.name}
{item.description || item.desc}
); })} {loadingMore && (
Loading more…
)} {error && (
)} )}
); } function uniquePickerItems(items) { const seen = new Set(); const out = []; for (const item of items || []) { const key = item.id || item.skill_id || item.name; if (!key || seen.has(key)) continue; seen.add(key); out.push(item); } return out; } const PLATFORM_BUILTIN_TOOLS = [ "publish_artifact", "list_skills", "load_skill", "load_skill_resource", "run_skill_script", "skill_read_file", "skill_append_file", "skill_write_file", "skill_list_files", "create_cron_schedule", "list_cron_schedules", "get_cron_schedule", "pause_cron_schedule", "resume_cron_schedule", "delete_cron_schedule", ]; const ADK_ENVIRONMENT_TOOLS = ["Execute", "ReadFile", "WriteFile", "EditFile"]; const APP_GENERIC_TOOL_NAMES = [ "schema.create_table", "schema.list_tables", "data.create", "data.list", "data.get", "data.update", "data.delete", ]; const APP_OPERATIONS = ["list", "get", "create", "update", "delete"]; function appToolNames(app) { const names = APP_GENERIC_TOOL_NAMES.map((name) => `${app.app_id}.${name}`); const tables = app.manifest?.tables || {}; Object.entries(tables).forEach(([resource, table]) => { (table.operations || APP_OPERATIONS).forEach((operation) => { names.push(`${app.app_id}.${resource}.${operation}`); }); }); return [...new Set(names)]; } function mcpToolNames(mcp) { return (mcp.tools || []) .map((tool) => typeof tool === "string" ? tool : tool.name) .filter(Boolean); } function AgentEditor({ agent, skills, mcps, localTools = [], apps = [], loadSkillPage, onUpdate, onInstallSkillTemplate, onDelete, saveState, onSave, }) { const [skillPickerOpen, setSkillPickerOpen] = React.useState(false); const [mcpPickerOpen, setMcpPickerOpen] = React.useState(false); const [localToolPickerOpen, setLocalToolPickerOpen] = React.useState(false); const installedSkillItems = uniquePickerItems(skills); const mcpItems = uniquePickerItems(mcps); const localToolItems = uniquePickerItems(localTools); const installedById = new Map([ ...installedSkillItems.map((s) => [s.id, s]), ...((agent.skills || []).map((s) => [s.id || s.skill_id, s])), ]); const agentSkillTemplateIdByInstanceId = new Map( (agent.skills || []) .filter((s) => (s.id || s.skill_id) && s.forked_from?.template_id) .map((s) => [s.id || s.skill_id, s.forked_from.template_id]) ); const templateIdToAgentInstanceId = new Map( (agent.skills || []) .filter((s) => (s.id || s.skill_id) && s.forked_from?.template_id) .map((s) => [s.forked_from.template_id, s.id || s.skill_id]) ); const selectedSkillTemplateIds = (agent.enabledSkills || []) .map((id) => agentSkillTemplateIdByInstanceId.get(id)) .filter(Boolean); const enabledSkills = (agent.enabledSkills || []).map((id) => ( installedById.get(id) || { id, name: id, description: "Agent-owned skill instance", color: "var(--accent)", initials: "SK", } )); const enabledMCPs = mcpItems.filter((m) => agent.enabledMCPs.includes(m.id)); const executorApps = apps.filter((app) => app.executor_agent_id === agent.id); const appTools = executorApps.flatMap((app) => appToolNames(app)); const selectedLocalToolNames = agent.enabledLocalTools || agent.localToolNames || []; const enabledLocalTools = localToolItems.filter((t) => selectedLocalToolNames.includes(t.id)); const toggleSkill = (id) => { const cur = agent.enabledSkills; onUpdate({ enabledSkills: cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id] }); }; const installSkillTemplate = async (template) => { const templateId = template.skill_id || template.id; const instanceId = templateIdToAgentInstanceId.get(templateId); if (instanceId) { onUpdate({ enabledSkills: (agent.enabledSkills || []).filter((id) => id !== instanceId) }); return; } if (!onInstallSkillTemplate) return; const version = template.latest_version || template.version; if (!version) return; await onInstallSkillTemplate(templateId, version); }; const toggleMCP = (id) => { const cur = agent.enabledMCPs; onUpdate({ enabledMCPs: cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id] }); }; const toggleLocalTool = (id) => { const cur = selectedLocalToolNames; onUpdate({ enabledLocalTools: cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id] }); }; return (
{agent.initials}

{agent.name}

agent · {agent.id} · model {agent.model}

Identity

onUpdate({ name: e.target.value, initials: e.target.value.split(" ").map((w) => w[0]).slice(0, 2).join("").toUpperCase() || "A" })} />
{ const raw = e.target.value; const next = raw === "" ? null : Math.max(1, parseInt(raw, 10) || 1); onUpdate({ maxAgentRounds: next }); }} />
onUpdate({ desc: e.target.value })} />

Default model configuration

Saved on this Agent and used by every new run
{ const model = e.target.value; const provider = modelProviderFor(model); onUpdate({ model, ...(provider ? { modelProvider: provider } : {}), max_tokens: Math.min(agent.max_tokens, modelContextWindow(model)), }); }} list="manage-model-options" placeholder={defaultModelForProvider(agent.modelProvider || "baizhi")} /> {modelOptionsForProvider(agent.modelProvider || "baizhi").map((m) => )}
onUpdate({ temperature: parseFloat(e.target.value) })} />
onUpdate({ top_p: parseFloat(e.target.value) })} />
onUpdate({ max_tokens: parseInt(e.target.value, 10) })} />

Agent prompt

System message used at the start of every conversation
{ onUpdate({ system, promptVariables }); }} placeholder="You are a careful, concise assistant. When asked for code, respond with…" />

Skills

{enabledSkills.length} attached
setSkillPickerOpen(false)} selectedIds={selectedSkillTemplateIds} onToggle={installSkillTemplate} loadSkillPage={loadSkillPage} groupLabel="Marketplace skills" />
{enabledSkills.length === 0 ? (
No skills attached. Click Add skill to give this agent capabilities.
) : enabledSkills.map((s) => (
{s.initials}
{s.name}
{s.description}
{s.version ? `v${s.version}` : "version unavailable"}
))}

Local Tools

{enabledLocalTools.length} enabled
setLocalToolPickerOpen(false)} items={localToolItems} selectedIds={selectedLocalToolNames} onToggle={toggleLocalTool} groupLabel="All local tools" />
{enabledLocalTools.length === 0 ? (
No local tools attached. Click Add tool to make one available by default.
) : enabledLocalTools.map((t) => (
{t.initials}
{t.label || t.name}
{t.description}
{t.group || "Local"}
))}

MCP Servers

{enabledMCPs.length} enabled
setMcpPickerOpen(false)} items={mcpItems} selectedIds={agent.enabledMCPs} onToggle={toggleMCP} groupLabel="All MCP servers" />
{enabledMCPs.length === 0 ? (
No MCP servers attached.
) : enabledMCPs.map((m) => (
{m.initials}
{m.name}
{m.description}
{(m.tools || []).length} tools
))}

Effective Tools

Available to this Agent at runtime
mcpToolNames(m).map((tool) => `${m.name}.${tool}`))} empty="No discovered tools from bound MCP servers." />
); } function ToolGroup({ title, items, empty }) { return (
{title} {items.length}
{items.length === 0 ? (
{empty || "None"}
) : (
{items.map((name) => {name})}
)}
); } window.AgentEditor = AgentEditor;