// Shared API client + backend-event → UI-event translator.
//
// Exposes `window.api` (typed-ish async fetchers for every endpoint we wire
// in this pass) and `window.startAgentRun` (the polling-based run driver
// that delivers tool/text/done callbacks compatible with the existing
// playground UI shape).
//
// Auth: browser requests use Authorization: Bearer. Trusted X-User-Id headers
// are reserved for the upstream gateway, not emitted by this UI.

(function () {
  const USER_ID = localStorage.getItem("baizhi.auth.user_id") || localStorage.getItem("baizhi.user") || "";

  // ---------------------------------------------------------------------
  // Shared toast + clipboard helpers
  // ---------------------------------------------------------------------
  let toastSeq = 0;
  function toastAnchorElement(anchor) {
    if (!anchor) return null;
    const el = anchor.currentTarget || anchor.target || anchor;
    if (!el || typeof el.getBoundingClientRect !== "function") return null;
    return el;
  }

  function positionToastNear(item, anchor) {
    const rect = anchor.getBoundingClientRect();
    const itemRect = item.getBoundingClientRect();
    const gap = 8;
    const margin = 8;
    const left = Math.min(
      Math.max(rect.left + rect.width / 2 - itemRect.width / 2, margin),
      Math.max(margin, window.innerWidth - itemRect.width - margin),
    );
    let top = rect.bottom + gap;
    if (top + itemRect.height + margin > window.innerHeight) {
      top = Math.max(margin, rect.top - itemRect.height - gap);
    }
    item.style.left = `${left}px`;
    item.style.top = `${top}px`;
  }

  function removeToast(item, host) {
    item.classList.add("leaving");
    window.setTimeout(() => {
      if (item.parentNode) item.parentNode.removeChild(item);
      if (host && !host.childElementCount && host.parentNode) {
        host.parentNode.removeChild(host);
      }
    }, 180);
  }

  function showToast(message, { duration = 1800, anchor = null } = {}) {
    if (!document?.body) return;
    const anchorEl = toastAnchorElement(anchor);
    if (anchorEl) {
      const item = document.createElement("div");
      item.className = "copy-toast copy-toast-anchored";
      item.dataset.toastId = `copy-toast-${++toastSeq}`;
      item.textContent = String(message || "");
      document.body.appendChild(item);
      positionToastNear(item, anchorEl);
      window.setTimeout(() => removeToast(item), duration);
      return;
    }
    let host = document.querySelector(".copy-toast-container");
    if (!host) {
      host = document.createElement("div");
      host.className = "copy-toast-container";
      document.body.appendChild(host);
    }
    const item = document.createElement("div");
    item.className = "copy-toast";
    item.dataset.toastId = `copy-toast-${++toastSeq}`;
    item.textContent = String(message || "");
    host.appendChild(item);
    window.setTimeout(() => removeToast(item, host), duration);
  }

  function copyTextFallback(text) {
    const ta = document.createElement("textarea");
    ta.value = text;
    ta.setAttribute("readonly", "");
    ta.style.position = "fixed";
    ta.style.top = "0";
    ta.style.left = "-9999px";
    ta.style.opacity = "0";
    document.body.appendChild(ta);
    ta.focus();
    ta.select();
    let ok = false;
    try {
      ok = document.execCommand("copy");
    } finally {
      document.body.removeChild(ta);
    }
    return ok;
  }

  async function copyText(text, {
    successMessage = "Copied",
    failureMessage = "Copy failed, please copy manually",
    toast = true,
    anchor = null,
  } = {}) {
    const value = String(text ?? "");
    let ok = false;
    try {
      if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(value);
        ok = true;
      }
    } catch {
      ok = false;
    }
    if (!ok) {
      try {
        ok = copyTextFallback(value);
      } catch {
        ok = false;
      }
    }
    if (toast) showToast(ok ? successMessage : failureMessage, { anchor });
    return ok;
  }

  window.showToast = showToast;
  window.copyText = copyText;

  // ---------------------------------------------------------------------
  // Low-level fetch wrapper
  // ---------------------------------------------------------------------
  // Auth: browser calls must carry a bearer token. EventSource cannot send
  // headers, so only the run-events SSE path receives the same token in query.
  function _authToken() {
    try { return localStorage.getItem("baizhi.auth.token"); } catch { return null; }
  }
  function _eventStreamAuthQuery() {
    const tok = _authToken();
    if (tok) return `token=${encodeURIComponent(tok)}`;
    _redirectToLogin();
    return "token=";
  }
  function _redirectToLogin() {
    try {
      localStorage.removeItem("baizhi.auth.token");
      localStorage.removeItem("baizhi.auth.user_id");
    } catch {}
    if (!location.pathname.startsWith("/login")) location.replace("/login");
  }
  function createRequestId() {
    if (window.crypto?.randomUUID) return window.crypto.randomUUID();
    return `rid-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
  }
  function requestHeaders(extra = {}) {
    return { ...extra, "X-Request-ID": createRequestId() };
  }

  async function call(path, { method = "GET", body, form, accept } = {}) {
    const tok = _authToken();
    if (!tok) {
      _redirectToLogin();
      throw new Error("authentication required");
    }
    const headers = requestHeaders({ "Authorization": "Bearer " + tok });
    if (body !== undefined) headers["Content-Type"] = "application/json";
    if (accept) headers["Accept"] = accept;
    const res = await fetch(path, {
      method,
      headers,
      body: form !== undefined ? form : (body !== undefined ? JSON.stringify(body) : undefined),
    });
    if (!res.ok) {
      let detail;
      const ct = res.headers.get("content-type") || "";
      try {
        detail = ct.includes("application/json") ? await res.json() : await res.text();
      } catch {
        detail = res.statusText || `${res.status}`;
      }
      const msg = typeof detail === "object" && detail?.detail?.message
        ? detail.detail.message
        : (typeof detail === "string" ? detail : JSON.stringify(detail));
      if (res.status === 401) _redirectToLogin();
      const err = new Error(`${method} ${path} → ${res.status}: ${msg}`);
      err.status = res.status;
      err.detail = detail;
      throw err;
    }
    if (res.status === 204) return null;
    const ct = res.headers.get("content-type") || "";
    if (ct.includes("application/json")) return await res.json();
    return await res.text();
  }

  // ---------------------------------------------------------------------
  // Avatar color / initials helpers (UI-side decoration for backend records
  // that don't carry display metadata)
  // ---------------------------------------------------------------------
  const PALETTE = ["#10a37f", "#2563eb", "#c026d3", "#d97706", "#ef4444", "#0ea5e9", "#7c3aed", "#0d0d0d"];

  function hashColor(name) {
    let h = 0;
    for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
    return PALETTE[Math.abs(h) % PALETTE.length];
  }

  function initialsFor(name) {
    if (!name) return "?";
    const cleaned = name.replace(/[_\-]/g, " ").trim();
    const parts = cleaned.split(/\s+/);
    if (parts.length === 1) {
      // For snake/kebab-case skill names, take the first alphanumeric char.
      return cleaned.slice(0, 1).toUpperCase();
    }
    return (parts[0][0] + parts[1][0]).toUpperCase();
  }

  // ---------------------------------------------------------------------
  // Skills / Agents / MCP — list + CRUD
  // ---------------------------------------------------------------------
  async function listSkills({ scope, runnable = false } = {}) {
    const params = new URLSearchParams();
    if (scope) params.set("scope", scope);
    if (runnable) params.set("runnable", "true");
    const qs = params.toString();
    const path = qs ? `/v1/skills?${qs}` : "/v1/skills";
    const { items } = await call(path);
    return items.map(decorateSkill);
  }

  function decorateSkill(s) {
    return {
      id: s.skill_id,
      skill_id: s.skill_id,
      name: s.name,
      description: s.description || "",
      desc: s.description || "",
      visibility: s.visibility,
      owner_type: s.owner_type,
      owner_user_id: s.owner_user_id,
      editable: s.editable,
      latest_version: s.latest_version,
      installed_version: s.installed_version,
      version: s.installed_version || s.latest_version || "draft",
      enabled: s.enabled,
      draft_status: s.draft_status,
      forked_from: s.forked_from,
      marketplace_template_id: s.marketplace_template_id,
      marketplace_visibility: s.marketplace_visibility,
      color: hashColor(s.name),
      initials: initialsFor(s.name),
      // The Manager skill editor expects a file tree. Backend only stores
      // SKILL.md text on personal-skill drafts; multi-file editing is a
      // future feature. We synthesize a one-file tree so the editor still
      // renders without crashing.
      files: [{ path: "SKILL.md", required: true, content: "" }],
    };
  }

  async function installSkill(skillId, version) {
    return await call(`/v1/user/skills/${skillId}/install`, {
      method: "POST",
      body: { version },
    });
  }
  async function installSkillForAgent(agentId, templateId, version) {
    return await call(`/v1/agents/${agentId}/skills/install/${templateId}`, {
      method: "POST",
      body: { version },
    });
  }

  // PR-S.6c:UI install 前先打这个,拿"会不会 shadow 别的同名 skill"信息。
  // 返 { template_name, collisions: [{ name, source }], already_installed }。
  async function preflightInstallSkill(templateId) {
    return await call(`/v1/marketplace/skills/${templateId}/preflight-install`, {
      method: "POST",
    });
  }

  // PR-S.7c MCP install API。
  // preflight 返 { template_name, collisions, already_installed, config_schema }。
  // config_schema 是 [{name, type:"string"|"secret", description, required, default}]
  // —— UI 据此渲染 env+secret 表单。
  async function preflightInstallMcp(templateId) {
    return await call(`/v1/marketplace/mcps/${templateId}/preflight-install`, {
      method: "POST",
    });
  }
  // env 是非 secret 明文,secrets 走 envelope-encrypted 表(后端 mcp_secrets)。
  async function installMcp(templateId, { env, secrets } = {}) {
    return await call(`/v1/user/mcps/${templateId}/install`, {
      method: "POST",
      body: { env: env || {}, secrets: secrets || {} },
    });
  }
  async function uninstallMcp(instanceId) {
    return await call(`/v1/user/mcps/${instanceId}`, { method: "DELETE" });
  }
  // D2=B:agent 也是 installer subject,可填 secrets;所有跑该 agent 的 user 共享。
  async function installMcpForAgent(agentId, templateId, { env, secrets } = {}) {
    return await call(`/v1/agents/${agentId}/mcps/install/${templateId}`, {
      method: "POST",
      body: { env: env || {}, secrets: secrets || {} },
    });
  }

  async function createPersonalSkill(name, skill_md, other_files = undefined) {
    return await call("/v1/personal-skills", {
      method: "POST",
      body: { name, skill_md, ...(other_files != null ? { other_files } : {}) },
    });
  }

  // Upload a ZIP containing SKILL.md (+ optional scripts/references/assets)
  // and create a personal skill from it in one round-trip. Backend
  // validates structure, rejects path traversal / binary / oversize files.
  async function uploadPersonalSkillZip(file, { name } = {}) {
    const form = new FormData();
    form.append("file", file, file.name);
    if (name) form.append("name", name);
    const tok = _authToken();
    if (!tok) {
      _redirectToLogin();
      throw new Error("authentication required");
    }
    const resp = await fetch("/v1/personal-skills/upload", {
      method: "POST",
      headers: requestHeaders({ "Authorization": "Bearer " + tok }),  // DON'T set Content-Type — browser must set boundary
      body: form,
    });
    if (!resp.ok) {
      if (resp.status === 401) _redirectToLogin();
      let detail;
      try { detail = (await resp.json())?.detail || (await resp.text()); }
      catch { detail = resp.statusText; }
      throw new Error(typeof detail === "string" ? detail : detail.message || JSON.stringify(detail));
    }
    return await resp.json();
  }

  // PR-F.1: import a skill from a remote URL (direct .zip or GitHub /tree/).
  // Backend (POST /v1/personal-skills/import-url) does the fetch + the same
  // unpack/validate pipeline as the upload endpoint. Errors come back as 400
  // with a user-readable `detail.message` we surface in the modal.
  async function importPersonalSkillFromUrl(url, { name } = {}) {
    // `call` JSON.stringifies the body itself — pass the raw object.
    return await call("/v1/personal-skills/import-url", {
      method: "POST",
      body: { url, ...(name ? { name } : {}) },
    });
  }

  // PR-S.1: marketplace endpoints. `listSkills` keeps returning the union
  // (mine ∪ public) for back-compat; these two specifically return only
  // marketplace items so the Manage UI can render a clean "browse + install"
  // view without filtering on the frontend.
  function decorateMarketplaceSkill(s) {
    return {
      ...s,
      id: s.skill_id,
      desc: s.description || "",
      color: hashColor(s.name),
      initials: initialsFor(s.name),
    };
  }
  async function listMarketplaceSkillsPage({
    cursor = null,
    limit = 50,
    q = "",
    sort = "name",
    installedOnly = false,
  } = {}) {
    const params = new URLSearchParams();
    params.set("limit", String(limit));
    if (cursor !== null && cursor !== undefined) params.set("cursor", String(cursor));
    if (q && q.trim()) params.set("q", q.trim());
    if (sort) params.set("sort", sort);
    if (installedOnly) params.set("installed_only", "true");
    const resp = await call(`/v1/marketplace/skills?${params.toString()}`);
    const items = Array.isArray(resp.items) ? resp.items.map(decorateMarketplaceSkill) : [];
    return {
      items,
      next_cursor: resp.next_cursor || null,
      has_more: !!resp.has_more,
      total_count: Number.isFinite(Number(resp.total_count)) ? Number(resp.total_count) : items.length,
    };
  }
  async function listMarketplaceSkills() {
    const raw = [];
    let cursor = null;
    do {
      const qs = cursor ? `?limit=200&cursor=${encodeURIComponent(cursor)}` : "?limit=200";
      const { items, next_cursor } = await call(`/v1/marketplace/skills${qs}`);
      if (Array.isArray(items)) raw.push(...items);
      cursor = next_cursor || null;
    } while (cursor);
    // shallow decorate so the chip / list components reuse hashColor + initials
    return raw.map(decorateMarketplaceSkill);
  }
  async function listMarketplaceMcps() {
    const { items } = await call("/v1/marketplace/mcps");
    return items.map((m) => ({
      ...m,
      id: m.server_id,
      color: hashColor(m.name),
      initials: initialsFor(m.name),
    }));
  }

  async function getPersonalSkillDraft(skillId) {
    return await call(`/v1/personal-skills/${skillId}/draft`);
  }

  async function updatePersonalSkillDraft(skillId, skill_md, other_files = undefined) {
    return await call(`/v1/personal-skills/${skillId}/draft`, {
      method: "PATCH",
      body: { skill_md, ...(other_files != null ? { other_files } : {}) },
    });
  }

  async function startPersonalSkillTest(skillId) {
    return await call(`/v1/personal-skills/${skillId}/test`, { method: "POST" });
  }

  async function getPersonalSkillTest(skillId) {
    return await call(`/v1/personal-skills/${skillId}/test`);
  }

  async function applyPersonalSkillTestProposal(skillId, proposalId) {
    return await call(`/v1/personal-skills/${skillId}/test/proposals/${proposalId}/apply`, {
      method: "POST",
    });
  }

  async function publishPersonalSkill(skillId, version, install = true) {
    return await call(`/v1/personal-skills/${skillId}/publish`, {
      method: "POST",
      body: { version, install },
    });
  }

  async function publishPersonalSkillToMarketplace(skillId) {
    return await call(`/v1/personal-skills/${skillId}/marketplace`, {
      method: "POST",
    });
  }

  async function withdrawPersonalSkillFromMarketplace(skillId) {
    return await call(`/v1/personal-skills/${skillId}/marketplace`, {
      method: "DELETE",
    });
  }

  async function deletePersonalSkill(skillId) {
    return await call(`/v1/personal-skills/${skillId}`, { method: "DELETE" });
  }

  async function listAgents() {
    const { items } = await call("/v1/agents");
    return items.map(decorateAgent);
  }

  // Apps management
  async function listApps({ agentId } = {}) {
    const query = agentId ? `?agent_id=${encodeURIComponent(agentId)}` : "";
    const { items } = await call(`/v1/apps${query}`);
    return items || [];
  }
  async function createApp(payload) {
    return await call("/v1/apps", { method: "POST", body: payload });
  }
  async function getApp(appId) {
    return await call(`/v1/apps/${encodeURIComponent(appId)}`);
  }
  async function replaceAppExecutor(appId, payload) {
    return await call(`/v1/apps/${encodeURIComponent(appId)}/executor`, { method: "POST", body: payload });
  }
  async function publishAppManifest(appId, manifest) {
    return await call(`/v1/apps/${encodeURIComponent(appId)}/manifest`, { method: "POST", body: { manifest } });
  }
  async function createH5Release(appId) {
    return await call(`/v1/apps/${encodeURIComponent(appId)}/h5/releases`, { method: "POST" });
  }
  async function listH5Releases(appId) {
    const { items } = await call(`/v1/apps/${encodeURIComponent(appId)}/h5/releases`);
    return items || [];
  }
  async function uploadH5Release(appId, releaseId, file) {
    const form = new FormData();
    form.append("file", file);
    return await call(`/v1/apps/${encodeURIComponent(appId)}/h5/releases/${encodeURIComponent(releaseId)}/upload`, { method: "POST", form });
  }
  async function publishH5Release(appId, releaseId) {
    return await call(`/v1/apps/${encodeURIComponent(appId)}/h5/releases/${encodeURIComponent(releaseId)}/publish`, { method: "POST" });
  }
  function previewH5Release(appId, releaseId, entryFile, previewUrl) {
    return previewUrl || `/v1/apps/${encodeURIComponent(appId)}/h5/releases/${encodeURIComponent(releaseId)}/preview/${encodeURIComponent(entryFile || "index.html")}`;
  }
  async function rollbackH5Release(appId, releaseId) {
    return await call(`/v1/apps/${encodeURIComponent(appId)}/h5/releases/${encodeURIComponent(releaseId)}/rollback`, { method: "POST" });
  }
  async function inspectAppSchema(appId) {
    return await call(`/v1/apps/${encodeURIComponent(appId)}/schema`);
  }
  async function listAppAudit(appId) {
    const { items } = await call(`/v1/apps/${encodeURIComponent(appId)}/audit`);
    return items || [];
  }
  async function grantAppPermission(appId, payload) {
    return await call(`/v1/apps/${encodeURIComponent(appId)}/permissions`, { method: "POST", body: payload });
  }
  async function listAppTables(appId) {
    const { items } = await call(`/v1/apps/${encodeURIComponent(appId)}/tables`);
    return items || [];
  }
  async function listAppRecordPage(appId, resource, { page = 1, pageSize = 50, search = "" } = {}) {
    const params = new URLSearchParams();
    params.set("page", String(page));
    params.set("page_size", String(pageSize));
    params.set("search", search);
    return await call(`/v1/apps/${encodeURIComponent(appId)}/tables/${encodeURIComponent(resource)}/records?${params.toString()}`);
  }
  async function listAppRecords(appId, resource) {
    const { items } = await call(`/v1/apps/${encodeURIComponent(appId)}/records/${encodeURIComponent(resource)}`);
    return items || [];
  }
  async function createAppRecord(appId, resource, data) {
    return await call(`/v1/apps/${encodeURIComponent(appId)}/records/${encodeURIComponent(resource)}`, { method: "POST", body: { data } });
  }

  // Agent configs store skill instance IDs directly.

  function decorateAgent(a) {
    const ui = JSON.parse(localStorage.getItem(`baizhi.agent.${a.agent_id}.ui`) || "{}");
    const modelConfig = a.model_config || {};
    return {
      id: a.agent_id,
      agent_id: a.agent_id,
      name: a.name,
      desc: ui.desc || "",
      color: ui.color || hashColor(a.agent_id),
      initials: ui.initials || initialsFor(a.name),
      modelProvider: modelConfig.provider || "baizhi",
      model: modelConfig.model_name || "deepseek-v4-flash",
      temperature: modelConfig.temperature ?? 0.7,
      top_p: modelConfig.top_p ?? 1,
      max_tokens: modelConfig.max_tokens ?? 12000,
      reasoning_enabled: modelConfig.reasoning_enabled ?? false,
      reasoning_visible: modelConfig.reasoning_visible ?? false,
      system: a.system_prompt || "",
      promptVariables: a.prompt_variables || [],
      maxAgentRounds: a.max_agent_rounds ?? null,
      skillIds: a.skill_ids || [],
      enabledSkills: a.skill_ids || [],
      skills: (a.skills || []).map((s) => ({
        ...s,
        id: s.skill_id || s.id,
        desc: s.description || "",
        color: hashColor(s.name || s.skill_id || s.id),
        initials: initialsFor(s.name || s.skill_id || s.id),
      })),
      enabledMCPs: a.mcp_ids || [],
      localToolNames: a.local_tool_names || [],
      enabledLocalTools: a.local_tool_names || [],
      created_at: a.created_at,
      updated_at: a.updated_at,
    };
  }

  function persistAgentUi(agent) {
    const uiOnly = {
      desc: agent.desc, color: agent.color, initials: agent.initials,
    };
    localStorage.setItem(`baizhi.agent.${agent.id}.ui`, JSON.stringify(uiOnly));
  }

  function modelConfigFromAgentUi(agent = {}) {
    const provider = agent.modelProvider || "baizhi";
    const providerConfig = (window.MODEL_PROVIDERS || []).find((item) => item.id === provider);
    return {
      provider,
      model_name: agent.model || providerConfig?.default_model || "deepseek-v4-flash",
      temperature: agent.temperature ?? 0.7,
      top_p: agent.top_p ?? 1,
      max_tokens: agent.max_tokens ?? 12000,
      reasoning_enabled: agent.reasoning_enabled ?? false,
      reasoning_visible: agent.reasoning_visible ?? false,
    };
  }

  async function createAgent({ name, system_prompt = "", prompt_variables = [], max_agent_rounds = null, enabled_mcp_servers = [], local_tool_names = [], skill_ids = [], mcp_ids, model_config = modelConfigFromAgentUi() } = {}) {
    const sids = skill_ids || [];
    const mids = mcp_ids !== undefined ? mcp_ids : enabled_mcp_servers;
    const created = await call("/v1/agents", {
      method: "POST",
      body: { name, system_prompt, prompt_variables, max_agent_rounds, skill_ids: sids, mcp_ids: mids, local_tool_names, model_config },
    });
    return decorateAgent(created);
  }

  async function updateAgent(agentId, { name, system_prompt = "", prompt_variables = [], max_agent_rounds = null, enabled_mcp_servers = [], local_tool_names = [], skill_ids = [], mcp_ids, model_config } = {}) {
    const sids = skill_ids || [];
    const mids = mcp_ids !== undefined ? mcp_ids : enabled_mcp_servers;
    const body = { name, system_prompt, prompt_variables, max_agent_rounds, skill_ids: sids, mcp_ids: mids, local_tool_names };
    if (model_config !== undefined) body.model_config = model_config;
    const updated = await call(`/v1/agents/${agentId}`, {
      method: "PATCH",
      body,
    });
    return decorateAgent(updated);
  }

  // ---- User bindings(agents-global)----
  // PR-S.1e:从 getTenantBindings / updateTenantBindings 改名。后端路径
  // /v1/user/bindings 早就 user 化了,函数名跟齐。
  async function getUserBindings() {
    return await call("/v1/user/bindings");
  }
  async function updateUserBindings({ skill_ids, mcp_ids } = {}) {
    return await call("/v1/user/bindings", {
      method: "PATCH",
      body: { skill_ids, mcp_ids },
    });
  }

  async function deleteAgent(agentId) {
    return await call(`/v1/agents/${agentId}`, { method: "DELETE" });
  }

  // LLM 目录(后端单一真相):{ items:[{id,label,context_window}], default, max_output_tokens }。
  // app 挂载时 fetch,覆盖 window.MODELS,让 Playground/Manager 显示真实窗口。
  async function listModels() {
    return await call("/v1/models");
  }

  // Traces — list summaries + detail (run + events together).
  async function listTraces({ agent_id, status, q, scope, limit = 200 } = {}) {
    const params = new URLSearchParams();
    if (agent_id) params.set("agent_id", agent_id);
    if (status) params.set("status", status);
    if (q) params.set("q", q);
    if (scope) params.set("scope", scope);
    if (limit) params.set("limit", String(limit));
    const qs = params.toString();
    const { items } = await call(`/v1/traces${qs ? "?" + qs : ""}`);
    return items;
  }

  async function getTraceDetail(runId) {
    return await call(`/v1/traces/${runId}`);
  }

  async function getTraceEvents(runId, { include_model_events = false } = {}) {
    const params = new URLSearchParams();
    if (include_model_events) params.set("include_model_events", "true");
    const qs = params.toString();
    const { items } = await call(`/v1/traces/${runId}/events${qs ? "?" + qs : ""}`);
    return items || [];
  }

  async function getTraceModelIO(runId) {
    const { items } = await call(`/v1/traces/${runId}/model-io`);
    return items || [];
  }

  async function downloadTraceModelIO(runId) {
    const tok = _authToken();
    if (!tok) {
      _redirectToLogin();
      throw new Error("authentication required");
    }
    const response = await fetch(`/v1/traces/${encodeURIComponent(runId)}/model-io-export`, {
      headers: requestHeaders({ "Authorization": "Bearer " + tok }),
    });
    if (!response.ok) {
      const detail = await response.text();
      if (response.status === 401) _redirectToLogin();
      throw new Error(`GET trace Model IO export → ${response.status}: ${detail || response.statusText}`);
    }
    const blob = await response.blob();
    const url = URL.createObjectURL(blob);
    const link = document.createElement("a");
    link.href = url;
    link.download = `trace-${runId}-model-io.zip`;
    document.body.appendChild(link);
    link.click();
    link.remove();
    window.setTimeout(() => URL.revokeObjectURL(url), 0);
  }

  async function replayTrace(runId, { thread_id } = {}) {
    return await call(`/v1/traces/${runId}/replay`, {
      method: "POST",
      body: thread_id ? { thread_id } : {},
    });
  }

  async function listThreadMessages(threadId) {
    const { items } = await call(`/v1/threads/${encodeURIComponent(threadId)}/messages`);
    return items;
  }

  async function listThreadRuns(threadId, limit = 100) {
    const result = await call(
      `/v1/threads/${encodeURIComponent(threadId)}/runs?limit=${encodeURIComponent(limit)}`
    );
    return result.items || [];
  }

  // PR C: resolve a presigned download URL for a run output asset
  // (PPT / DOCX / arbitrary files written by run_script).
  // chat-panel.jsx's AssetDownloadChip calls this on click.
  async function createAssetDownloadUrl(assetId) {
    // 后端返 { download_url, filename, mime_type, expires_at };chip 读 { url } —
    // 在边界归一化成 url(presigned,可直接 window.open)。
    const r = await call(`/v1/assets/${assetId}/download-url`, { method: "POST" });
    return { url: r.download_url, filename: r.filename, mime_type: r.mime_type, expires_at: r.expires_at };
  }

  // ----- Evals: datasets + cases + eval_runs (PR e1 endpoints, PR e2 wires
  //              them in). EvalRunner / graders are PR e3 / e4 — runs
  //              created here stay `status: "pending"` until then.
  async function listDatasets() {
    const { items } = await call("/v1/datasets");
    return items;
  }
  async function createDataset({ name, description = "", tags = [], graders = [] } = {}) {
    return await call("/v1/datasets", {
      method: "POST",
      body: { name, description, tags, graders },
    });
  }
  async function getDataset(datasetId) {
    return await call(`/v1/datasets/${datasetId}`);
  }
  async function updateDataset(datasetId, patch) {
    return await call(`/v1/datasets/${datasetId}`, { method: "PATCH", body: patch });
  }
  async function deleteDataset(datasetId) {
    return await call(`/v1/datasets/${datasetId}`, { method: "DELETE" });
  }
  async function addDatasetCase(datasetId, { input, expected, tools = [], tags = [], source = { type: "manual" } } = {}) {
    return await call(`/v1/datasets/${datasetId}/cases`, {
      method: "POST",
      body: { input, expected, tools, tags, source },
    });
  }
  async function deleteDatasetCase(datasetId, caseId) {
    return await call(`/v1/datasets/${datasetId}/cases/${caseId}`, { method: "DELETE" });
  }
  async function listEvalRuns({ dataset_id, limit = 200 } = {}) {
    const params = new URLSearchParams();
    if (dataset_id) params.set("dataset_id", dataset_id);
    if (limit) params.set("limit", String(limit));
    const qs = params.toString();
    const { items } = await call(`/v1/eval_runs${qs ? "?" + qs : ""}`);
    return items;
  }
  async function getEvalRun(evalRunId) {
    return await call(`/v1/eval_runs/${evalRunId}`);
  }
  async function createEvalRun({ dataset_id, agent_id, model } = {}) {
    return await call("/v1/eval_runs", {
      method: "POST",
      body: { dataset_id, agent_id, model },
    });
  }

  async function listMcpServers() {
    const { items } = await call("/v1/mcp/servers");
    return items.map(decorateMcp);
  }

  async function listLocalTools() {
    const { items } = await call("/v1/local-tools");
    return (items || []).map(decorateLocalTool);
  }

  // ---- MCP CRUD(admin)----
  // .baizhi-data SQLite `mcp_servers` 表 持久化(MCP store PR,2026-05-26)。
  // 真 secret 不进 DB —— `auth_env_key` 只是 env 变量名。

  async function createMcpServer({ server_id, name, description = "",
                                    transport, http_url, headers_template,
                                    auth_env_key, command, args,
                                    runtime_options, config_schema } = {}) {
    const body = { server_id, name, description, transport };
    if (http_url) body.http_url = http_url;
    if (headers_template) body.headers_template = headers_template;
    if (auth_env_key) body.auth_env_key = auth_env_key;
    if (command) body.command = command;
    if (args) body.args = args;
    if (runtime_options) body.runtime_options = runtime_options;
    if (config_schema) body.config_schema = config_schema;
    const created = await call("/v1/mcp/servers", { method: "POST", body });
    return decorateMcp({ ...created, scope: "platform" });
  }

  async function updateMcpServer(serverId, patch) {
    // Translate UI shape → backend shape
    const body = {};
    if ("name" in patch) body.name = patch.name;
    if ("description" in patch) body.description = patch.description;
    if ("transport" in patch) body.transport = patch.transport;
    if ("http_url" in patch || "url" in patch) body.http_url = patch.http_url ?? patch.url;
    if ("headers_template" in patch) body.headers_template = patch.headers_template;
    if ("auth_env_key" in patch) body.auth_env_key = patch.auth_env_key;
    if ("runtime_options" in patch) body.runtime_options = patch.runtime_options;
    if ("command" in patch) body.command = patch.command;
    if ("args" in patch) {
      body.args = Array.isArray(patch.args)
        ? patch.args
        : (patch.args || "").split(/\s+/).filter(Boolean);
    }
    const updated = await call(`/v1/mcp/servers/${serverId}`, { method: "PATCH", body });
    return decorateMcp({ ...updated, scope: "platform" });
  }

  async function deleteMcpServer(serverId) {
    return await call(`/v1/mcp/servers/${serverId}`, { method: "DELETE" });
  }

  async function checkMcpServer(serverId) {
    return await call(`/v1/mcp/servers/${serverId}/check`, { method: "POST" });
  }

  function decorateMcp(s) {
    // Backend wire shape(`application._available_mcp_servers`):
    //   { server_id, name, description, transport,
    //     http_url, headers_template, auth_env_key,           ← HTTP transport
    //     command, args,                                       ← stdio transport
    //     available, scope }
    // 之前这个 mapper 把 `http_url` / `headers_template` 错读成 `url` / `env`,
    // 导致 manage 页 WebSearch 详情参数全空 —— bug fix(2026-05-26)。
    const headers = s.headers_template || {};
    const headerPairs = Object.entries(headers).map(([k, v]) => ({ k, v }));
    return {
      id: s.server_id,
      server_id: s.server_id,
      name: s.name,
      description: s.description || "",
      desc: s.description || "",
      transport: s.transport || "stdio",
      // stdio fields
      command: s.command || "",
      args: Array.isArray(s.args) ? s.args.join(" ") : (s.args || ""),
      // http fields
      url: s.http_url || s.url || "",
      headers: headerPairs,         // [{k, v}] 形态,跟 KV 表格直接绑
      auth_env_key: s.auth_env_key || "",
      // PR-S.18a · install-time form schema(admin add 声明的 user-fill 字段)
      config_schema: Array.isArray(s.config_schema) ? s.config_schema : [],
      runtime_options: s.runtime_options || {},
      // tools / metadata
      tools: s.tools || [],
      configStatus: s.config_status || s.configStatus || null,
      checkStatus: s.check_status || s.checkStatus || null,
      available: !!s.available,
      enabled: !!s.available,       // backward-compat alias
      status: s.available ? "available" : "unavailable",
      scope: s.scope || "platform",
      color: hashColor(s.server_id),
      initials: initialsFor(s.name),
    };
  }

  function decorateLocalTool(t) {
    return {
      ...t,
      id: t.id || t.name,
      name: t.name || t.id,
      label: t.label || t.name || t.id,
      description: t.description || "",
      desc: t.description || "",
      group: t.group || "Local",
      color: hashColor(t.group || t.name || t.id),
      initials: initialsFor(t.label || t.name || t.id),
      type: "local_tool",
    };
  }

  // ---------------------------------------------------------------------
  // Run lifecycle — create + poll until done, translating backend events
  // ---------------------------------------------------------------------
  // PR pg-system-prompt-preview: fetch the runtime SYSTEM_PRELUDE so the
  // Observability panel can show "what the LLM sees" (block 1). Block 2
  // (activated skills) is composed on the client from sessionSkills +
  // their descriptions (already in window.SKILLS). Block 3 (runtime facts)
  // and block 4 (file attachments) intentionally omitted from preview —
  // they're per-run, not stable for the agent.
  async function getSystemPromptPrelude() {
    return await call("/v1/system-prompt-prelude");
  }

  async function createRun({ thread_id, message, agent_id, skill_ids = [], mcp_ids = null, local_tool_names = [], model_config = {}, sandbox_type, attachments = [], context = {}, restrict_to_agent_tools = false }) {
    const runOptions = { agent_id, skill_ids, mcp_ids, local_tool_names, model_config, sandbox_type, attachments, restrict_to_agent_tools };
    if (context && Object.keys(context).length) runOptions.context = context;
    return await call("/v1/agent/runs", {
      method: "POST",
      body: {
        thread_id,
        message,
        // attachments: list of asset_ids from prior uploadAttachment calls.
        // Backend resolves each → InputAttachment + materializes under
        // workspace/input/<filename> before the LLM loop.
        options: runOptions,
      },
    });
  }

  // PR chat-uploads-workspace: multipart upload of one chat attachment.
  // Returns {asset_id, filename, mime_type, size}. Use XHR (not fetch) so
  // we can surface upload progress for >MB files. Server cap default 25 MB
  // (BAIZHI_UPLOAD_MAX_BYTES); MIME whitelist enforced server-side.
  function uploadAttachment(file, { onProgress } = {}) {
    const tok = _authToken();
    if (!tok) {
      _redirectToLogin();
      return Promise.reject(new Error("authentication required"));
    }
    return new Promise((resolve, reject) => {
      const form = new FormData();
      form.append("file", file, file.name || "attachment.bin");
      const xhr = new XMLHttpRequest();
      xhr.open("POST", "/v1/uploads");
      xhr.setRequestHeader("Authorization", "Bearer " + tok);
      xhr.setRequestHeader("X-Request-ID", createRequestId());
      if (typeof onProgress === "function" && xhr.upload) {
        xhr.upload.addEventListener("progress", (e) => {
          if (e.lengthComputable) onProgress(e.loaded / e.total);
        });
      }
      xhr.onload = () => {
        let body = null;
        try { body = JSON.parse(xhr.responseText); } catch (_) { body = null; }
        if (xhr.status >= 200 && xhr.status < 300) {
          resolve(body);
        } else {
          if (xhr.status === 401) _redirectToLogin();
          const msg = (body && body.detail && body.detail.message)
                   || (body && body.detail) || `upload failed (HTTP ${xhr.status})`;
          reject(new Error(typeof msg === "string" ? msg : JSON.stringify(msg)));
        }
      };
      xhr.onerror = () => reject(new Error("upload network error"));
      xhr.send(form);
    });
  }

  async function getRun(runId) {
    return await call(`/v1/runs/${runId}`);
  }

  async function listRunEvents(runId) {
    const { items } = await call(`/v1/runs/${runId}/events`);
    return items;
  }

  async function listWorkspaceFiles(runId) {
    return await call(`/v1/runs/${encodeURIComponent(runId)}/workspace/files`);
  }

  async function readWorkspaceMarkdown(runId, path) {
    return await call(
      `/v1/runs/${encodeURIComponent(runId)}/workspace/markdown?path=${encodeURIComponent(path)}`
    );
  }

  async function downloadWorkspaceFile(runId, path) {
    const tok = _authToken();
    if (!tok) {
      _redirectToLogin();
      throw new Error("authentication required");
    }
    const encodedRunId = encodeURIComponent(runId);
    const encodedPath = encodeURIComponent(path);
    const downloadEndpoint = `/v1/runs/${encodedRunId}/workspace/download?path=${encodedPath}`;
    const target = await call(downloadEndpoint, { method: "POST" });
    const link = document.createElement("a");
    link.href = target.download_url;
    link.rel = "noopener noreferrer";
    document.body.appendChild(link);
    link.click();
    link.remove();
  }

  async function listActiveRuns() {
    return call("/v1/runs/active");
  }

  async function cancelRun(runId, reason = "user_abort") {
    return await call(`/v1/runs/${runId}/cancel`, { method: "POST", body: { reason } });
  }

  // HITL (docs/HIL-design.md): answer an `awaiting_input` run. `values` maps
  // the human_request field names → the human's answers; the backend feeds
  // them back as the pending ask_human FunctionResponse and re-enqueues the
  // SAME run_id to continue. Returns the re-queued run dict.
  async function respondToRun(runId, values, options = {}) {
    return await call(`/v1/runs/${runId}/respond`, {
      method: "POST",
      body: {
        values: values || {},
        decision: options.decision,
        modified_args: options.modified_args,
        rejection_reason: options.rejection_reason,
        interruption_id: options.interruption_id,
      },
    });
  }

  // Drive one agent run: POST, then poll until terminal status, dispatching
  // each new backend event through the provided callbacks. The callbacks
  // are designed to match what app.jsx's executeRun was building up by
  // hand against the mock — so the React components downstream don't need
  // to change shape.
  //
  // Callbacks (all optional):
  //   onQueued({run})                — run accepted, status "queued"
  //   onStarted({run})               — run picked up by worker
  //   onLlmStarted({roundId, roundIndex})
  //   onLlmCompleted({roundId, roundIndex, duration_ms, input_tokens, output_tokens, finish_reason})
  //   onModelIOStarted(call)         — full model input for the round
  //   onModelIOCompleted(call)       — output/usage terminal delta
  //   onModelIOFailed(call)          — error terminal delta
  //   onThinkingStarted({thinking_id, llm_round_id})
  //   onThinkingDelta({thinking_id, delta})
  //   onThinkingCompleted({thinking_id, text})
  //   onSkillActivated({skill_name})
  //   onToolStarted({tool_name, tool_call_id, arguments})
  //   onToolCompleted({tool_name, tool_call_id, duration_ms, is_error, result_bytes, result_text})
  //   onTextDelta({delta})           — incremental assistant text
  //   onAgentEvent({type, payload})  — raw passthrough for every backend event
  //   onCompleted({run, events})     — terminal: completed / failed / cancelled
  //   onError(error)
  //
  // Returns a {cancel()} handle.
  function startAgentRun({ thread_id, message, agent_id, skill_ids = [], mcp_ids = null, local_tool_names = [], model_config = {}, sandbox_type, attachments = [], context = {}, restrict_to_agent_tools = false, callbacks = {}, pollIntervalMs = 250, timeoutMs = 600000, transport = "auto" }) {
    // PR live-sse-events: default transport is "auto" — try EventSource
    // (real SSE, no client-side timeout, server pushes events live as
    // they happen). Fall back to polling if EventSource isn't supported
    // OR errors during connect. transport="poll" forces the old path
    // (handy for debugging). transport="sse" forces SSE without fallback.
    //
    // PR HITL-2-ui: a run can PAUSE mid-flight (`awaiting_input`) when the
    // agent calls `ask_human` — the run exits its worker thread and waits for
    // a human to answer (docs/HIL-design.md). The driver models this as a loop
    // of "segments": stream one segment until the run pauses or terminates;
    // on pause, hand `run.human_request` to the UI via onAwaitingInput and
    // await the human's answer, POST it to /respond, then attach a fresh
    // segment to the SAME run_id to stream the resumed work. Cross-segment
    // event dedup (by event_id) keeps the replayed pre-pause events from
    // double-firing the callbacks.
    let cancelled = false;
    let runId = null;
    let activeEventSource = null;
    let abortSegment = null;
    let abortAwait = null;   // rejects the human-input wait if the user cancels mid-pause
    let rejectCancelled = null;
    let cancelledNotified = false;
    const seenEventIds = new Set();
    const deliveredEvents = [];
    let sawAgUiTextContent = false;
    const agUiToolStarts = new Map();
    const agUiToolStarted = new Set();
    const agUiToolResults = new Set();

    const cancelledError = () => {
      const err = new Error("cancelled");
      err.name = "AbortError";
      return err;
    };
    const isCancelledError = (err) => (
      cancelled || err?.message === "cancelled" || err?.name === "AbortError"
    );
    const notifyCancelled = () => {
      if (cancelledNotified) return;
      cancelledNotified = true;
      callbacks.onCancelled?.({ run_id: runId, events: deliveredEvents });
    };

    const dispatchEvent = (evt) => {
      // Dedup across segments: a resumed segment re-lists/replays events the
      // first segment already dispatched. event_id is stable + present on
      // every backend event (see api.py _event / SSE `id:` line).
      const eid = evt && evt.event_id;
      if (eid !== undefined && eid !== null) {
          if (seenEventIds.has(eid)) return;
          seenEventIds.add(eid);
      }
      deliveredEvents.push(evt);
      const t = evt.type;
      const p = evt.payload || {};
      callbacks.onAgentEvent?.(evt);
      if (t === "TEXT_MESSAGE_CONTENT") {
        sawAgUiTextContent = true;
        callbacks.onTextDelta?.({ delta: p.delta || "", messageId: p.messageId });
        return;
      }
      if (t === "TOOL_CALL_START") {
        const toolCallId = p.toolCallId;
        if (toolCallId) {
          agUiToolStarted.add(toolCallId);
          agUiToolStarts.set(toolCallId, {
            tool_name: p.toolCallName,
            tool_call_id: toolCallId,
            arguments: {},
            llm_round_id: p.parentMessageId,
          });
        }
        return;
      }
      if (t === "TOOL_CALL_ARGS") {
        const toolCallId = p.toolCallId;
        const started = agUiToolStarts.get(toolCallId);
        if (started && !started.dispatched) {
          let args = {};
          try { args = JSON.parse(p.delta || "{}"); } catch {}
          started.arguments = args;
          started.dispatched = true;
          callbacks.onToolStarted?.(started);
        }
        return;
      }
      if (t === "TOOL_CALL_RESULT") {
        const toolCallId = p.toolCallId;
        if (toolCallId) agUiToolResults.add(toolCallId);
        const content = (p.content && typeof p.content === "object") ? p.content : {};
        const message = content.result ?? p.message ?? "";
        const isError = content.is_error ?? p.isError ?? false;
        callbacks.onToolCompleted?.({
          tool_name: p.toolCallName,
          tool_call_id: toolCallId,
          duration_ms: content.duration_ms || p.durationMs || 0,
          is_error: !!isError,
          result_bytes: content.result_bytes || new Blob([message]).size,
          result_text: message,
        });
        return;
      }
      if (t === "RUN_STARTED") {
        callbacks.onStarted?.({ run: { run_id: p.runId || runId, status: "running" } });
        return;
      }
      if (t === "RUN_ERROR") {
        return;
      }
      if (t === "RUN_FINISHED") {
        return;
      }
      if (t === "REASONING_START") {
        callbacks.onThinkingStarted?.({ thinking_id: p.messageId, llm_round_id: p.parentMessageId });
        return;
      }
      if (t === "REASONING_MESSAGE_CONTENT") {
        callbacks.onThinkingDelta?.({ thinking_id: p.messageId, delta: p.delta || "" });
        return;
      }
      if (t === "REASONING_END") {
        callbacks.onThinkingCompleted?.({ thinking_id: p.messageId, text: p.text || "" });
        return;
      }
      if (t === "run.awaiting_input" && (p.response_route === "tool_approval" || p.interruption_type === "tool_approval")) {
        callbacks.onToolApprovalRequired?.({
          run_id: runId,
          payload: p,
          respond: (decision, detail = {}) => respondToRun(runId, {}, {
            decision,
            modified_args: detail.modified_args,
            rejection_reason: detail.rejection_reason,
            interruption_id: p.interruption_id,
          }),
        });
      }
      if (t === "run.cancelled" || t === "agent.cancelled") {
        notifyCancelled();
        return;
      }
      if (t === "llm.started") callbacks.onLlmStarted?.(p);
      else if (t === "llm.completed") callbacks.onLlmCompleted?.(p);
      else if (t === "model_io.started") callbacks.onModelIOStarted?.(p);
      else if (t === "model_io.completed") callbacks.onModelIOCompleted?.(p);
      else if (t === "model_io.failed") callbacks.onModelIOFailed?.(p);
      else if (t === "thinking.started") callbacks.onThinkingStarted?.(p);
      else if (t === "thinking.delta") callbacks.onThinkingDelta?.(p);
      else if (t === "thinking.completed") callbacks.onThinkingCompleted?.(p);
      else if (t === "skill.activated") callbacks.onSkillActivated?.(p);
      else if (t === "tool.started") {
        if (!agUiToolStarted.has(p.tool_call_id)) callbacks.onToolStarted?.(p);
      }
      else if (t === "tool.completed") {
        if (!agUiToolResults.has(p.tool_call_id)) callbacks.onToolCompleted?.(p);
      }
      else if (t === "text.delta") {
        if (!sawAgUiTextContent) callbacks.onTextDelta?.(p);
      }
    };

    const driver = (async () => {
      try {
        const run = await createRun({ thread_id, message, agent_id, skill_ids, mcp_ids, local_tool_names, model_config, sandbox_type, attachments, context, restrict_to_agent_tools });
        runId = run.run_id;
        if (cancelled) {
          if (runId) {
            try { await cancelRun(runId, "user_abort"); } catch {}
          }
          throw cancelledError();
        }
        callbacks.onQueued?.({ run });

        let segmentRun = run;
        let firstSegment = true;
        // Segment loop — re-enters on every HITL resume until the run is
        // terminal. The human-wait (awaiting onAwaitingInput's respond) sits
        // BETWEEN segments, so no segment's timeout counts the human's time.
        while (true) {
          const useSse = firstSegment &&
            (transport === "sse" || (transport === "auto" && typeof EventSource !== "undefined"));
          let finalRun;
          if (useSse) {
            try {
              finalRun = await runViaSse(runId, segmentRun, dispatchEvent, callbacks, () => cancelled,
                                         (es, abort) => {
                                           activeEventSource = es;
                                           abortSegment = abort;
                                         });
            } catch (sseErr) {
              // SSE failed entirely — only fall back when transport=auto.
              if (isCancelledError(sseErr)) throw sseErr;
              if (transport !== "auto") throw sseErr;
              console.warn("startAgentRun: SSE failed, falling back to polling:", sseErr);
              activeEventSource = null;
              finalRun = await runViaPolling(runId, segmentRun, dispatchEvent, callbacks,
                                             () => cancelled, pollIntervalMs, timeoutMs);
            }
          } else {
            finalRun = await runViaPolling(runId, segmentRun, dispatchEvent, callbacks,
                                           () => cancelled, pollIntervalMs, timeoutMs);
          }
          firstSegment = false;
          if (cancelled) throw new Error("cancelled");

          if (["awaiting_input", "interrupted_max_rounds", "interrupted_context_budget"].includes(finalRun.status)) {
            // HITL pause: surface the form + wait for the human. The `respond`
            // we hand the UI just RESOLVES this promise; the driver then does
            // the POST /respond + re-attach below. `cancel` lets the UI abort.
            const values = await new Promise((resolve, reject) => {
              if (!callbacks.onAwaitingInput) {
                reject(new Error(`run ${runId} is resumable but no onAwaitingInput handler was provided`));
                return;
              }
              abortAwait = reject;   // cancel() can unblock this wait
              callbacks.onAwaitingInput({
                run: finalRun,
                respond: (vals) => resolve(vals || {}),
                cancel: () => reject(new Error("cancelled")),
              });
            });
            abortAwait = null;
            if (cancelled) throw new Error("cancelled");
            await respondToRun(runId, values);
            segmentRun = await getRun(runId);   // status flipped queued/running
            // Resume always streams via polling — robust to the replayed
            // history (dedup handles it) without depending on a fresh SSE.
            continue;
          }

          callbacks.onCompleted?.({ run: finalRun, events: deliveredEvents });
          return finalRun;
        }
      } catch (err) {
        if (isCancelledError(err)) {
          notifyCancelled();
        } else {
          callbacks.onError?.(err);
        }
        throw err;
      } finally {
        if (activeEventSource) { try { activeEventSource.close(); } catch {} }
        abortSegment = null;
      }
    })();
    const cancelPromise = new Promise((_, reject) => {
      rejectCancelled = reject;
    });
    const work = Promise.race([driver, cancelPromise]);

    return {
      promise: work,
      // HITL: the UI submits the human's form answer here. We don't expose the
      // promise-resolver directly; callers route through onAwaitingInput's
      // `respond`, which is what actually resolves the segment-loop wait.
      cancel: () => {
        if (cancelled) return;
        cancelled = true;
        const err = cancelledError();
        notifyCancelled();
        if (abortAwait) { try { abortAwait(err); } catch {} abortAwait = null; }
        if (abortSegment) { try { abortSegment(err); } catch {} abortSegment = null; }
        if (activeEventSource) { try { activeEventSource.close(); } catch {} }
        if (runId) cancelRun(runId, "user_abort").catch(() => {});
        if (rejectCancelled) rejectCancelled(err);
      },
    };
  }

  // SSE path: open EventSource at /v1/runs/{id}/events?stream=1&token=...
  // EventSource has no API for custom headers, so the backend accepts token
  // query auth on this path only.
  // Stream ONE segment over SSE. Resolves with the final run dict when the
  // stream closes (`done`) — the caller (startAgentRun's segment loop)
  // decides terminal-vs-awaiting_input from `run.status` and fires
  // onCompleted / onAwaitingInput. (Pre-HITL this fn owned onCompleted; that
  // moved up so the resume loop owns the run's terminal contract.)
  function runViaSse(runId, initialRun, dispatchEvent, callbacks, isCancelled, registerEs) {
    const url = `/v1/runs/${runId}/events?stream=1&${_eventStreamAuthQuery()}`;
    let lastStatus = initialRun.status;
    return new Promise((resolve, reject) => {
      let es = null;
      let reconnectTimer = null;
      let settled = false;

      const clearReconnect = () => {
        if (reconnectTimer !== null) {
          clearTimeout(reconnectTimer);
          reconnectTimer = null;
        }
      };
      const closeCurrent = () => {
        if (!es) return;
        try { es.close(); } catch {}
        es = null;
      };
      const settleResolve = (run) => {
        if (settled) return;
        settled = true;
        clearReconnect();
        closeCurrent();
        resolve(run);
      };
      const settleReject = (err) => {
        if (settled) return;
        settled = true;
        clearReconnect();
        closeCurrent();
        reject(err);
      };

      function scheduleReconnect() {
        if (settled || reconnectTimer !== null) return;
        if (isCancelled()) {
          settleReject(new Error("cancelled"));
          return;
        }
        reconnectTimer = setTimeout(() => {
          reconnectTimer = null;
          openEventSource();
        }, 1000);
      }

      const refreshRunAfterDisconnect = async () => {
        if (settled) return;
        if (isCancelled()) {
          settleReject(new Error("cancelled"));
          return;
        }
        try {
          const latest = await getRun(runId);
          if (latest.status === "queued" || latest.status === "running") {
            scheduleReconnect();
            return;
          }
          settleResolve(latest);
        } catch (err) {
          settleReject(err);
        }
      };

      // EventSource doesn't auto-fire onmessage for named events, only
      // the default "message" event. We bind addEventListener per type
      // we care about. The backend names every event by its type, so one
      // generic handler covers the AG-UI stream plus remaining business events.
      const handleData = (e) => {
        let evt;
        try { evt = JSON.parse(e.data); } catch { return; }
        if (evt.type === "run.started" && lastStatus !== "running") {
          lastStatus = "running";
          callbacks.onStarted?.({ run: { ...initialRun, run_id: runId, status: "running" } });
        }
        dispatchEvent(evt);
      };
      // Bind known event types (incl. HITL pause/resume markers so they flow
      // through dispatchEvent → onAgentEvent + get recorded for dedup).
      const KNOWN = [
        "run.queued", "skill.activated", "skill.asset.prepared",
        "session.created", "session.reused", "workspace.prepared", "workspace.files_synced", "workspace.sync_failed", "workspace.snapshot_saved",
        "sandbox.prepared", "sandbox.released",
        "llm.started", "llm.completed",
        "model_io.started", "model_io.completed", "model_io.failed",
        "ui.card", "asset.created", "run.cancelled",
        "RUN_STARTED", "RUN_FINISHED", "RUN_ERROR",
        "TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END",
        "TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "TOOL_CALL_RESULT",
        "REASONING_START", "REASONING_MESSAGE_START", "REASONING_MESSAGE_CONTENT",
        "REASONING_MESSAGE_END", "REASONING_END",
        "agent.skill_tool_required", "agent.max_rounds_hit", "agent.cancelled",
        "run.awaiting_input", "run.resumed",
      ];

      function openEventSource() {
        if (settled) return;
        if (isCancelled()) {
          settleReject(new Error("cancelled"));
          return;
        }
        closeCurrent();
        try { es = new EventSource(url); }
        catch (err) { settleReject(err); return; }
        const current = es;
        registerEs(current, (err) => {
          settleReject(err || new Error("cancelled"));
        });
        for (const t of KNOWN) current.addEventListener(t, handleData);
        current.addEventListener("done", () => {
          refreshRunAfterDisconnect().catch(settleReject);
        });
        current.addEventListener("error", () => {
          if (settled || current !== es) return;
          // CONNECTING means native EventSource retry is active. CLOSED means
          // it will not retry, so verify the run and create a fresh stream if
          // the backend is still working.
          if (current.readyState === EventSource.CLOSED) {
            closeCurrent();
            refreshRunAfterDisconnect().catch(settleReject);
          }
        });
      }

      openEventSource();
    });
  }

  // Polling path — fallback / debug AND the resume path (HITL re-attach).
  // Stops at terminal OR `awaiting_input` and resolves with the run dict; the
  // segment loop in startAgentRun owns onCompleted / onAwaitingInput. Events
  // are fed through dispatchEvent unconditionally — its event_id dedup means a
  // resumed segment that re-lists the full history won't re-fire callbacks for
  // events already seen in the first segment. timeoutMs default is 600000
  // (10 min) so Office-skill runs have headroom; the human-wait is OUTSIDE
  // this fn (between segments) so it never counts against the deadline.
  async function runViaPolling(runId, initialRun, dispatchEvent, callbacks,
                                isCancelled, pollIntervalMs, timeoutMs) {
    const deadline = Date.now() + timeoutMs;
    let lastStatus = initialRun.status;
    while (!isCancelled() && Date.now() < deadline) {
      const [latest, events] = await Promise.all([getRun(runId), listRunEvents(runId)]);
      if (latest.status !== lastStatus) {
        lastStatus = latest.status;
        if (latest.status === "running") callbacks.onStarted?.({ run: latest });
      }
      for (const ev of events) dispatchEvent(ev);
      if (["completed", "failed", "cancelled", "timeout", "awaiting_input", "interrupted_max_rounds", "interrupted_context_budget"].includes(latest.status)) {
        return latest;
      }
      await sleep(pollIntervalMs);
    }
    if (isCancelled()) {
      try { await cancelRun(runId, "user_abort"); } catch {}
      throw new Error("cancelled");
    }
    throw new Error(`run ${runId} did not finish within ${timeoutMs}ms`);
  }

  function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }

  // ---------------------------------------------------------------------
  // Public surface
  // ---------------------------------------------------------------------
  // Whether the current request set carries a real auth token. UI uses this
  // to decide whether to show the Logout affordance.
  function isAuthenticated() {
    return !!_authToken();
  }
  // PR-S.2: GET /v1/auth/me returns {user_id, is_admin}. UI uses it to
  // decide whether to render admin actions (add/edit/delete MCP catalog,
  // promote-skill-to-public). Server still 403s if the UI is bypassed.
  async function authMe() {
    return await call("/v1/auth/me");
  }
  function logout() {
    _redirectToLogin();
  }

  // ---- Users management(PR-S.17 · admin-only)----
  async function listUsers() {
    const { items } = await call("/v1/users");
    return items;
  }
  async function updateUser(userId, patch) {
    // patch: { is_admin?: bool, status?: 'active'|'banned' }
    return await call(`/v1/users/${userId}`, { method: "PATCH", body: patch });
  }

  window.api = {
    userId: USER_ID,
    isAuthenticated, logout, authMe,
    // skills
    listSkills, installSkill, installSkillForAgent, preflightInstallSkill,
    // PR-S.7c MCP install-as-fork wire
    preflightInstallMcp, installMcp, uninstallMcp, installMcpForAgent,
    listMarketplaceSkills, listMarketplaceSkillsPage, listMarketplaceMcps,
    createPersonalSkill, uploadPersonalSkillZip, importPersonalSkillFromUrl,
    getPersonalSkillDraft, updatePersonalSkillDraft,
    startPersonalSkillTest, getPersonalSkillTest, applyPersonalSkillTestProposal,
    publishPersonalSkill, publishPersonalSkillToMarketplace,
    withdrawPersonalSkillFromMarketplace, deletePersonalSkill,
    // agents
    listAgents, createAgent, updateAgent, deleteAgent,
    // apps
    listApps, createApp, getApp, replaceAppExecutor, publishAppManifest, createH5Release, listH5Releases, uploadH5Release, publishH5Release, previewH5Release, rollbackH5Release,
    inspectAppSchema, listAppAudit, grantAppPermission, listAppTables, listAppRecordPage,
    listAppRecords, createAppRecord,
    persistAgentUi, modelConfigFromAgentUi,
    // LLM 目录(后端单一真相;context_window 等)
    listModels,
    // user bindings(agents-global PR;user 层 skill+mcp 订阅列表)
    getUserBindings, updateUserBindings,
    // mcp catalog(admin CRUD;per-agent enable 走 updateAgent)
    listMcpServers, createMcpServer, updateMcpServer, deleteMcpServer, checkMcpServer,
    // users management(PR-S.17 · admin-only)
    listUsers, updateUser,
    // local function-call tools(session-only in Playground)
    listLocalTools,
    // runs
    createRun, getRun, listRunEvents, listActiveRuns, cancelRun, respondToRun, uploadAttachment,
    listWorkspaceFiles, readWorkspaceMarkdown, downloadWorkspaceFile,
    getSystemPromptPrelude,
    // traces
    listTraces, getTraceDetail, getTraceEvents, getTraceModelIO, downloadTraceModelIO, replayTrace,
    // threads
    listThreadMessages, listThreadRuns,
    // assets
    createAssetDownloadUrl,
    // evals (datasets + cases + eval_runs; PR e2 wires them in)
    listDatasets, createDataset, getDataset, updateDataset, deleteDataset,
    addDatasetCase, deleteDatasetCase,
    listEvalRuns, getEvalRun, createEvalRun,
    // helpers
    hashColor, initialsFor,
  };
  window.startAgentRun = startAgentRun;
})();
