// MeshForge — main app. Two screens:
//   home: Gemini/ChatGPT-style hero composer + suggestion chips + community gallery
//   workspace: conversational stream with stage progress, spec, animations, export

const { useState, useRef, useEffect, useMemo } = React;
const { Sidebar, Composer, StageCard, SpecCard, AnimationEditor, ExportCard, PreviewPane, BrandLogo } = window.MFC;

// ---------- Theme bootstrap ----------
// Apply the persisted theme on first paint (before React mounts) so the
// page doesn't flash light → dark. Reads MCSKIN_THEME_v1: "light" | "dark"
// | "auto" — auto follows the OS via prefers-color-scheme.
(function _applyTheme() {
  try {
    const t = localStorage.getItem("MCSKIN_THEME_v1") || "light";
    const resolved = t === "auto"
      ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
      : t;
    document.documentElement.setAttribute("data-theme", resolved);
  } catch {}
})();

// ---------- Demo data ----------
const SUGGESTIONS = [
  { label: "角色", text: "霸气的霸王龙,棕绿配色,带背刺" },
  { label: "载具", text: "四足机甲战车,顶部双管炮,金属白漆带霓虹蓝条纹" },
  { label: "道具", text: "老式 AK47 步枪,木质枪托,金属做旧效果" },
  { label: "场景", text: "悬浮的监视者眼球,深紫底色,中央一只大眼睛" },
];

// Seed list of demo prompts shown in the sidebar BEFORE the user has
// generated anything. They're not real sessions — clicking one just
// fills the prompt and starts a fresh pipeline. Empty by default; if
// the user wants demo content they can generate it.
const RECENT_SESSIONS_DEMO = [
  { id: "s-trex",     title: "霸王龙",          prompt: "霸气的霸王龙",  live: false, model: "trex" },
  { id: "s-mech",     title: "四足机甲战车",     prompt: "机甲战车",     live: false, model: "mech" },
  { id: "s-watcher",  title: "监视者眼球",      prompt: "悬浮的眼球",   live: false, model: "watcher" },
  { id: "s-ak47",     title: "AK47 步枪",       prompt: "老式 AK47",    live: false, model: "mech" },
  { id: "s-dolphin",  title: "海豚",            prompt: "蓝色海豚",     live: false, model: "trex" },
];

const COMMUNITY = [
  { id: "trex", name: "霸王龙", tag: "mc · 角色", model: "trex", anim: "attack" },
  { id: "mech", name: "四足机甲", tag: "mc · 载具", model: "mech", anim: "walk" },
  { id: "watcher", name: "Watcher Pod", tag: "mc · 场景", model: "watcher", anim: "idle" },
  { id: "trex2", name: "AK47 步枪", tag: "mc · 道具", model: "trex", anim: "fire" },
];

// ---------- Home ----------
const WORKFLOW_META = {
  full: {
    eyebrow: "v0.4 · 已支持多方向动画",
    title: "MeshForge",
    sub: "描述你想要的生物或物件 — MeshForge 会自动建模、贴图、绑骨,并按你给的描述生成多向动画。",
    placeholder: "例:四足机甲战车,顶部双管炮,金属白漆带霓虹蓝条纹",
    topbar: "一句话生成可动 3D 资产",
    suggestionsKey: "full",
  },
  animation: {
    eyebrow: "动画工作流 · 上传模型生成动画",
    title: "Animate",
    sub: "上传 .glb / .bbmodel,描述要的动作 — 我们会自动绑骨并生成多向动画。",
    placeholder: "例:走、跑、待机、攻击,8 方向,带循环",
    topbar: "为已有模型生成动画",
    suggestionsKey: "animation",
  },
  texture: {
    eyebrow: "贴图工作流 · 上传模型重新贴图",
    title: "Retexture",
    sub: "上传模型并描述风格 — 重新生成 PBR 或像素贴图,保留原 UV。",
    placeholder: "例:暗夜哥特金属质感,做旧划痕,32×32 像素",
    topbar: "为已有模型重新生成贴图",
    suggestionsKey: "texture",
  },
  ui: {
    eyebrow: "UI 工作流 · 游戏美术组件包",
    title: "Game UI Kit",
    sub: "输入主题后直接生成白底等格组件图，扣掉白底并导出单个 PNG 组件包。",
    placeholder: "例：赛博机甲手游 UI，logo、图标、按钮、状态条、资源计数器",
    topbar: "生成游戏 UI 美术组件包",
    suggestionsKey: "ui",
  },
};

const SUGGESTIONS_BY_WF = {
  full: SUGGESTIONS,
  animation: [
    { label: "战斗", text: "8 方向走 + 跑 + 重击 + 受伤 + 死亡" },
    { label: "待机", text: "呼吸 idle 循环,偶发左右张望" },
    { label: "飞行", text: "起飞、巡航、俯冲、降落,带翅膀循环" },
    { label: "情绪", text: "高兴跳跃 + 愤怒咆哮 + 害怕颤抖" },
  ],
  texture: [
    { label: "像素", text: "16×16 像素,Minecraft 风格,鲜艳描边" },
    { label: "PBR", text: "金属做旧 PBR,带划痕和锈迹,2K" },
    { label: "卡通", text: "卡通赛璐璐,纯色块 + 黑边描边" },
    { label: "霓虹", text: "赛博朋克霓虹,荧光紫粉,自发光通道" },
  ],
  ui: [
    { label: "RPG", text: "蓝金幻想 RPG 手游 UI，logo、头像框、装备槽、技能图标、主按钮" },
    { label: "机甲", text: "霓虹赛博机甲 UI，驾驶员头像、武器图标、能量条、任务按钮、金属边框" },
    { label: "农场", text: "可爱农场经营 UI，作物图标、订单木牌、仓库格、体力条、收获按钮" },
    { label: "星舰", text: "科幻星舰 UI，舰队 logo、雷达窗、飞船模块图标、护盾条、玻璃按钮" },
  ],
};

function Home({ workflow = "full", onSubmit, onPickRecent, authUser, authChecked }) {
  const meta = WORKFLOW_META[workflow] || WORKFLOW_META.full;
  const suggestions = SUGGESTIONS_BY_WF[meta.suggestionsKey] || SUGGESTIONS;
  const [prompt, setPrompt] = useState("");
  const [imageData, setImageData] = useState(null);
  const [imageName, setImageName] = useState(null);
  const [style, setStyle] = useState("pixel");

  const submit = () => {
    if (!prompt.trim()) return;
    onSubmit({ prompt: prompt.trim(), imageData, imageName, style });
  };

  return (
    <div className="home">
      <div className="hero">
        <div className="hero-eyebrow">
          <span className="dot" />
          <span>{meta.eyebrow}</span>
        </div>
        <h1 className="hero-title">
          <em>{meta.title}</em>
        </h1>
        <p className="hero-sub">{meta.sub}</p>
      </div>

      <div className="composer-wrap">
        <Composer
          value={prompt}
          onChange={setPrompt}
          imageData={imageData}
          imageName={imageName}
          onPickImage={(d, n) => { setImageData(d); setImageName(n); }}
          onClearImage={() => { setImageData(null); setImageName(null); }}
          style={style}
          onStyleChange={setStyle}
          onSubmit={submit}
          showStyle={workflow !== "animation"}
          large
          placeholder={meta.placeholder}
        />
      </div>

      <QuotaWindow enabled={!!authChecked && !!authUser} />

      {workflow !== "full" && (
        <UploadCard workflow={workflow} onSubmit={onSubmit} />
      )}

      <div className="suggestions">
        {suggestions.map((s) => (
          <button key={s.label} className="suggestion" onClick={() => setPrompt(s.text)}>
            <div className="label">{s.label}</div>
            <div className="text">{s.text}</div>
          </button>
        ))}
      </div>

      <HomeGallery onPickRecent={onPickRecent} />
    </div>
  );
}

// ---------- Home community gallery (real fetch) ----------
function HomeGallery({ onPickRecent }) {
  const [items, setItems] = useState([]);
  const [loaded, setLoaded] = useState(false);
  useEffect(() => {
    (async () => {
      try {
        const r = await window.MCAPI.community.featured();
        setItems(r.items || []);
      } catch {} finally { setLoaded(true); }
    })();
  }, []);
  if (loaded && items.length === 0) {
    // Empty community → don't show a noisy section. Suggestion chips alone
    // are enough to seed the user's first prompt.
    return null;
  }
  return (
    <div className="gallery-section">
      <div className="gallery-head">
        <h2>社区作品</h2>
        <a className="link" href="#/workflow/full"
           onClick={(e) => { e.preventDefault(); window.location.hash = "#/workflow/full"; }}>
          浏览全部 →
        </a>
      </div>
      <div className="gallery-grid">
        {items.map((c) => (
          <div key={c.id} className="gallery-card"
               onClick={() => onPickRecent({
                 id: c.id, name: c.name, prompt: c.name,
                 model: c.model || "trex",
               })}>
            <div className="gallery-thumb">
              {c.thumb_b64 ? (
                <img src={`data:image/png;base64,${c.thumb_b64}`} alt={c.name}
                     style={{ width: "100%", height: "100%", objectFit: "cover" }} />
              ) : (
                <div style={{
                  width: "100%", height: "100%", display: "grid", placeItems: "center",
                  color: "var(--ink-5)", fontSize: 11, background: "var(--bg-soft)",
                }}>{c.tag || c.model || ""}</div>
              )}
            </div>
            <div className="gallery-meta">
              <div className="gallery-name">{c.name}</div>
              <div className="gallery-tag">{c.author} · {c.anim || c.tag || ""}</div>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

// ---------- File-upload card (animation / texture / model workflows) ----------
function UploadCard({ workflow = "animation", onSubmit }) {
  // Calls /api/upload, gets back a serialized ctx, hands the parent
  // an onSubmit-shaped payload that mimics a "fresh prompt" so the
  // existing startSession flow can mount it as a workspace session.
  const fileRef = useRef();
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const onPick = async (e) => {
    const f = e.target.files?.[0];
    if (!f) return;
    setBusy(true); setErr("");
    try {
      const r = await window.MCAPI.uploadAsset(f, { workflow });
      // Hand the ctx back as if it were a prompt result. startSession
      // accepts an `id` so we adopt the server-issued session_id.
      onSubmit({
        id: r.session_id,
        prompt: f.name,
        imageData: null,
        imageName: null,
        style: "pixel",
        // NB: ctx must be persisted onto the App's session map so the
        // workspace skips the generate phase. We piggyback via a custom
        // field the App reads (see startSession patch elsewhere).
        importedCtx: r.ctx,
        sourceKind: r.source_kind,
        workflow,
      });
    } catch (e) {
      setErr(e && e.message || "上传失败");
    } finally {
      setBusy(false);
      if (fileRef.current) fileRef.current.value = "";
    }
  };
  return (
    <div className="wf-upload">
      <div className="wf-upload-card">
        <I.Upload size={20} />
        <div>
          <div className="wf-upload-title">上传模型</div>
          <div className="wf-upload-sub">
            .glb / .gltf / .bbmodel · 拖入或点击选择
          </div>
        </div>
        <button
          className="ghost-btn"
          style={{ marginLeft: "auto" }}
          onClick={() => fileRef.current?.click()}
          disabled={busy}
        >
          {busy ? "上传中…" : "选择文件"}
        </button>
        <input ref={fileRef} type="file" accept=".glb,.gltf,.bbmodel"
               style={{ display: "none" }} onChange={onPick} />
      </div>
      {err && (
        <div style={{ marginTop: 8, fontSize: 12, color: "var(--danger)" }}>
          {err}
        </div>
      )}
    </div>
  );
}


// ---------- Quota Window ----------
const CYCLE_MS = 5 * 60 * 60 * 1000;

// Quota window now reads from /api/billing/usage — see QuotaWindow.

function fmtMS(ms) {
  if (ms <= 0) return "0s";
  const s = Math.floor(ms / 1000);
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  const ss = s % 60;
  if (h > 0) return `${h}h ${m}m`;
  if (m > 0) return `${m}m ${ss}s`;
  return `${ss}s`;
}

function fmtAgo(ms) {
  const s = Math.floor(ms / 1000);
  if (s < 60) return `${s}s 前`;
  const m = Math.floor(s / 60);
  if (m < 60) return `${m}m 前`;
  const h = Math.floor(m / 60);
  return `${h}h ${m % 60}m 前`;
}

function QuotaWindow({ refreshKey = 0, sessionId = null, sessionCostUsd = null, enabled = true } = {}) {
  // Pulls real LLM/Meshy usage and dollar quota from /api/billing/usage.
  const [usage, setUsage] = useState(null);
  const [sessionCost, setSessionCost] = useState(sessionCostUsd);

  useEffect(() => {
    setSessionCost(sessionCostUsd ?? null);
  }, [sessionId]);

  useEffect(() => {
    if (sessionCostUsd == null) return;
    const hinted = Number(sessionCostUsd || 0);
    setSessionCost((prev) => Math.max(Number(prev || 0), hinted));
  }, [sessionCostUsd]);

  useEffect(() => {
    let alive = true;
    if (!enabled) {
      setUsage(null);
      setSessionCost(sessionCostUsd ?? null);
      return () => { alive = false; };
    }
    const tick = async () => {
      try {
        const u = await window.MCAPI.billing.usage();
        if (alive) setUsage(u);
      } catch {}
      if (sessionId && window.MCAPI?.billing?.transactions) {
        try {
          const t = await window.MCAPI.billing.transactions();
          const cost = (t.transactions || [])
            .filter((row) => row && row.session_id === sessionId)
            .reduce((sum, row) => sum + usageCostUsd(row.usage || row), 0);
          const hinted = Number(sessionCostUsd || 0);
          if (alive) setSessionCost(Math.max(cost, hinted));
        } catch {}
      }
    };
    tick();
    const id = setInterval(tick, 30_000); // refresh every 30s — calls are cheap
    return () => { alive = false; clearInterval(id); };
  }, [refreshKey, sessionId, sessionCostUsd, enabled]);

  const quota = usage?.quota || {};
  const lifetime = usage?.lifetime || {};
  const usedUsd = quota.used_usd ?? lifetime.total_cost_usd ?? lifetime.est_cost_usd ?? 0;
  const quotaUsd = quota.quota_usd ?? 0;
  const remainingUsd = quota.remaining_usd ?? Math.max(0, quotaUsd - usedUsd);
  const pct = quotaUsd ? Math.min(100, (usedUsd / quotaUsd) * 100) : 0;
  const showSessionCost = !!sessionId || sessionCostUsd != null;
  const sessionCostLabel = `本会话 $${Number(sessionCost || 0).toFixed(4)}`;
  const quotaLabel = enabled && quotaUsd ? `剩余 $${Number(remainingUsd).toFixed(4)}` : "未登录";
  const label = enabled && showSessionCost && quotaUsd ? `${sessionCostLabel} · ${quotaLabel}` : quotaLabel;

  return (
    <div className="quota-line">
      <div
        className="quota-ring"
        style={{ background: `conic-gradient(currentColor ${pct * 3.6}deg, var(--bg-sunken) 0)` }}
        title={`${showSessionCost ? `${sessionCostLabel} · ` : ""}已用 $${Number(usedUsd).toFixed(4)} / $${Number(quotaUsd).toFixed(2)}`}>
        <div className="quota-ring-inner" />
      </div>
      <span>{label}</span>
      <a className="link" style={{ marginLeft: "auto", fontSize: 11 }}
         onClick={(e) => { e.preventDefault(); window.location.hash = "#/billing"; }}
         href="#/billing">查看用量 →</a>
    </div>
  );
}

// GLB animation preview for gallery cards — looks like a real model viewer.
function MiniPreview({ model, animLabel = "idle" }) {
  // Mock playhead frame counter that ticks while card is mounted, to feel "live".
  const [frame, setFrame] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setFrame((f) => (f + 1) % 120), 1000 / 24);
    return () => clearInterval(id);
  }, []);
  const sec = (frame / 24).toFixed(1);
  return (
    <div className="glb-preview">
      <div className="glb-bg" />
      <div className="glb-grid-floor" />
      <div className="glb-scale">
        <window.VoxelPreview model={model} />
      </div>
      <div className="glb-chrome-tl">
        <span className="glb-fmt">GLB</span>
        <span className="glb-anim">▶ {animLabel}</span>
      </div>
      <div className="glb-chrome-br">
        <span className="glb-frame">{String(frame).padStart(3, "0")}/120</span>
        <span className="glb-time">{sec}s</span>
      </div>
      <div className="glb-scrub">
        <div className="glb-scrub-fill" style={{ width: `${frame / 120 * 100}%` }} />
      </div>
    </div>
  );
}

// ---------- Workspace (conversation) ----------

const STAGE_PLAN = [
  { name: "Analyze", activeName: "Analyzing", detail: "Prompt analysis -> model spec" },
  { name: "Generate", activeName: "Generating", detail: "Generate DSL" },
  { name: "Validate", activeName: "Validating", detail: "AST validation + sandbox run" },
  { name: "Check", activeName: "Checking", detail: "QA check + auto-fix" },
  { name: "Render", activeName: "Rendering", detail: "Build R3F geometry" },
  { name: "Texture", activeName: "Texturing", detail: "Meshy -> atlas" },
];

// Backend stage names → STAGE_PLAN index. Multiple backend stages can share
// a slot (e.g. material_designer counts as Texturer prep). Anything missing
// from this map gets ignored — the UI only ticks for the canonical six.
const BACKEND_STAGE_TO_INDEX = {
  analyst: 0,
  coder: 1,
  kernel: 2,
  assembler: 1,
  qa: 3,
  adjuster: 3,
  renderer: 4,
  material_designer: 5,
  meshy: 5,
  texturer: 5,
};

const BUILD_CHECKPOINT_DONE = 5;
const MAX_STREAM_TEXT = 60000;

function clipTail(text, max = MAX_STREAM_TEXT) {
  if (!text || text.length <= max) return text || "";
  return text.slice(text.length - max);
}

function isAbortError(e) {
  return e && (e.name === "AbortError" || String(e.message || e).toLowerCase().includes("aborted"));
}

function isMeaningfulWorkflowPrompt(text) {
  const raw = String(text || "").trim();
  if (!raw || !/[\p{L}\p{N}\u4e00-\u9fff]/u.test(raw)) return false;
  const compact = raw.toLowerCase().replace(/[\s.,，。!！?？;；:："'“”‘’~～\-_/\\|()[\]{}]+/g, "");
  if (!compact) return false;
  return !new Set(["hi", "hello", "ok", "test", "测试", "你好", "随便", "不知道", "无", "没有", "继续"]).has(compact);
}

function animationNameFromPrompt(text) {
  const raw = String(text || "").trim();
  const known = ["待机", "走路", "行走", "跑步", "攻击", "跳跃", "飞行", "死亡", "受伤", "挥手", "起跳", "落地", "巡航"];
  const hit = known.find((name) => raw.includes(name));
  const seed = hit || raw.split(/[\s,，。.;；:：]+/).filter(Boolean)[0] || "custom";
  const safe = seed.replace(/[^\p{L}\p{N}\u4e00-\u9fff_-]+/gu, "_").replace(/^_+|_+$/g, "");
  return (safe || "custom").slice(0, 24);
}

function fmtStageMs(ms) {
  const n = Math.max(0, Number(ms || 0));
  if (n < 60_000) return `${(n / 1000).toFixed(1)}s`;
  const total = Math.round(n / 1000);
  const m = Math.floor(total / 60);
  const s = total % 60;
  return `${m}m ${s.toString().padStart(2, "0")}s`;
}

function sessionHasRunningTurn(session) {
  return (session?.refineTurns || []).some((t) => t && t.status === "running");
}

function isSessionRunning(session) {
  if (!session) return false;
  if (isImageWorkflow(session.workflow)) {
    const status = session.workflowState?.activeJob?.status || session.workflowState?.progress?.status;
    return !!session.running || status === "queued" || status === "running" || status === "planning";
  }
  if (session.interruptedBuild) return false;
  if (session.running) return true;
  if (session.animsBuilding) return true;
  if (sessionHasRunningTurn(session)) return true;
  if (session.started && !session.doneAt && !session.backendErr &&
      (session.stagesDone ?? 0) < STAGE_PLAN.length) return true;
  return false;
}

function AssistantHead({ busy = false, children = null }) {
  return (
    <div className="assistant-head">
      <BrandLogo className="mark" busy={busy} size={24} />
      MeshForge
      {children}
    </div>
  );
}

// ---------- Streaming live tail ----------
// Renders thinking + output deltas with auto-scroll. The reasoning panel is
// collapsible (click header) so the user can hide a wall of LLM thoughts
// once they've seen enough.
function StreamPanel({ thought, output, stageName }) {
  const thoughtRef = useRef(null);
  const outputRef = useRef(null);
  const [thoughtOpen, setThoughtOpen] = useState(true);

  useEffect(() => {
    const el = thoughtRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, [thought]);
  useEffect(() => {
    const el = outputRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, [output]);

  if (!thought && !output) return null;
  return (
    <div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 8 }}>
      {thought && (
        <div style={{
          border: "1px solid var(--line)",
          borderRadius: 10,
          background: "var(--bg-soft)",
          overflow: "hidden",
        }}>
          <button
            onClick={() => setThoughtOpen((v) => !v)}
            style={{
              width: "100%",
              textAlign: "left",
              padding: "6px 10px",
              fontSize: 11,
              color: "var(--ink-3)",
              border: "none",
              background: "transparent",
              fontFamily: "var(--font-mono)",
              letterSpacing: 0.4,
              textTransform: "uppercase",
              cursor: "pointer",
              display: "flex",
              alignItems: "center",
              gap: 6,
            }}
          >
            <span>{thoughtOpen ? "▾" : "▸"}</span>
            <span>思考 {stageName && <>· {stageName}</>}</span>
            <span style={{ marginLeft: "auto", opacity: 0.6 }}>{thought.length} chars</span>
          </button>
          {thoughtOpen && (
            <pre
              ref={thoughtRef}
              style={{
                margin: 0,
                padding: "8px 12px",
                fontFamily: "var(--font-mono)",
                fontSize: 11,
                lineHeight: 1.5,
                color: "var(--ink-3)",
                fontStyle: "italic",
                whiteSpace: "pre-wrap",
                maxHeight: 200,
                overflow: "auto",
                background: "rgba(0,0,0,0.02)",
                borderTop: "1px solid var(--line)",
              }}
            >{thought}</pre>
          )}
        </div>
      )}
      {output && (
        <div style={{
          border: "1px solid var(--line)",
          borderRadius: 10,
          background: "var(--bg-elev)",
          overflow: "hidden",
        }}>
          <div style={{
            padding: "6px 10px",
            fontSize: 11,
            color: "var(--ink-2)",
            fontFamily: "var(--font-mono)",
            letterSpacing: 0.4,
            textTransform: "uppercase",
            display: "flex",
            alignItems: "center",
            gap: 6,
            borderBottom: "1px solid var(--line)",
          }}>
            <span style={{
              width: 6, height: 6, borderRadius: "50%",
              background: "var(--good)", display: "inline-block",
              animation: "pulse 1.2s ease-in-out infinite",
            }} />
            <span>输出 {stageName && <>· {stageName}</>}</span>
            <span style={{ marginLeft: "auto", opacity: 0.6 }}>{output.length} chars</span>
          </div>
          <pre
            ref={outputRef}
            style={{
              margin: 0,
              padding: "8px 12px",
              fontFamily: "var(--font-mono)",
              fontSize: 11,
              lineHeight: 1.5,
              color: "var(--ink-2)",
              whiteSpace: "pre-wrap",
              maxHeight: 280,
              overflow: "auto",
              background: "rgba(0,0,0,0.02)",
            }}
          >{output}</pre>
        </div>
      )}
    </div>
  );
}

function fmtUsd(v) {
  const n = Number(v || 0);
  return "$" + n.toFixed(n >= 1 ? 2 : 4);
}

function fmtTokens(n) {
  const v = Number(n || 0);
  if (v >= 1_000_000) return (v / 1_000_000).toFixed(2) + "M";
  if (v >= 1000) return (v / 1000).toFixed(1) + "K";
  return String(Math.round(v));
}

const USAGE_SUMMARY_NUMERIC_FIELDS = [
  "llm_calls",
  "llm_input_tokens",
  "llm_output_tokens",
  "llm_thinking_tokens",
  "llm_total_tokens",
  "llm_cost_usd",
  "image_calls",
  "image_cost_usd",
  "image_equiv_tokens",
  "meshy_calls",
  "meshy_cost_usd",
  "meshy_equiv_tokens",
  "total_tokens",
  "total_cost_usd",
];

function usageCostUsd(summary) {
  return Number(summary?.total_cost_usd ?? summary?.cost_usd ?? summary?.est_cost_usd ?? 0);
}

function imageEquivTokensFromUsd(costUsd, pricing = null) {
  const usdPer1m = Math.max(0.000001, Number(pricing?.token_equiv_usd_per_1m || 12));
  return Math.round(Number(costUsd || 0) / usdPer1m * 1_000_000);
}

function mergeUsageSummary(base, delta) {
  if (!delta) return base || null;
  const next = {
    ...(base || {}),
    currency: delta.currency || base?.currency || "USD",
    pricing: delta.pricing || base?.pricing,
  };
  for (const key of USAGE_SUMMARY_NUMERIC_FIELDS) {
    next[key] = Number(base?.[key] || 0) + Number(delta?.[key] || 0);
  }
  next.llm_tokens = next.llm_total_tokens;
  next.cost_usd = next.total_cost_usd;
  next.est_cost_usd = next.total_cost_usd;
  next.quota_usd = delta.quota_usd ?? base?.quota_usd;
  return next;
}

function UsageSummary({ summary }) {
  if (!summary) return null;
  const cardStyle = {
    padding: 10,
    border: "1px solid var(--line)",
    borderRadius: 8,
    background: "var(--bg-elev)",
    minWidth: 0,
  };
  const labelStyle = { fontSize: 11, color: "var(--ink-4)", marginBottom: 4 };
  const valueStyle = { fontSize: 18, fontWeight: 700, color: "var(--ink)" };
  const subStyle = {
    marginTop: 4,
    fontSize: 11,
    color: "var(--ink-3)",
    whiteSpace: "nowrap",
    overflow: "hidden",
    textOverflow: "ellipsis",
  };
  return (
    <div style={{
      marginTop: 12,
      display: "grid",
      gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
      gap: 8,
    }}>
      <div style={cardStyle}>
        <div style={labelStyle}>Tokens</div>
        <div style={valueStyle}>{fmtTokens(summary.total_tokens)}</div>
        <div style={subStyle}>LLM {fmtTokens(summary.llm_total_tokens)} · Image {fmtTokens(summary.image_equiv_tokens)} · Meshy {fmtTokens(summary.meshy_equiv_tokens)}</div>
      </div>
      <div style={cardStyle}>
        <div style={labelStyle}>Cost</div>
        <div style={valueStyle}>{fmtUsd(summary.total_cost_usd)}</div>
        <div style={subStyle}>LLM {fmtUsd(summary.llm_cost_usd)} · Image {fmtUsd(summary.image_cost_usd)} · Meshy {fmtUsd(summary.meshy_cost_usd)}</div>
      </div>
      <div style={cardStyle}>
        <div style={labelStyle}>Calls</div>
        <div style={valueStyle}>{(summary.llm_calls || 0) + (summary.image_calls || 0) + (summary.meshy_calls || 0)}</div>
        <div style={subStyle}>LLM {summary.llm_calls || 0} · Image {summary.image_calls || 0} · Meshy {summary.meshy_calls || 0}</div>
      </div>
    </div>
  );
}

function MeshyProgress({ progress }) {
  if (!progress) return null;
  const pct = Math.max(0, Math.min(100, Number(progress.progress || 0)));
  const status = progress.status || (pct >= 100 ? "SUCCEEDED" : "PENDING");
  return (
    <div style={{
      marginTop: 12,
      padding: 12,
      border: "1px solid var(--line)",
      borderRadius: 8,
      background: "var(--bg-elev)",
    }}>
      <div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
        <div style={{ fontSize: 13, fontWeight: 600 }}>Meshy</div>
        <div style={{ fontSize: 12, color: "var(--ink-3)", fontFamily: "var(--font-mono)" }}>{status} · {pct}%</div>
      </div>
      <div style={{
        marginTop: 8,
        height: 8,
        borderRadius: 6,
        background: "rgba(0,0,0,0.08)",
        overflow: "hidden",
      }}>
        <div style={{
          width: pct + "%",
          height: "100%",
          background: progress.error ? "var(--danger)" : "var(--accent)",
          transition: "width 180ms ease",
        }} />
      </div>
      {progress.taskId && (
        <div style={{ marginTop: 6, fontSize: 11, color: "var(--ink-4)", fontFamily: "var(--font-mono)" }}>
          task {progress.taskId}
        </div>
      )}
    </div>
  );
}

function Workspace({ session, onUpdate, onBack, onNew, onResizePreview, authUser, authChecked = true, onRequireLogin }) {
  const [previewOpen, setPreviewOpen] = useState(true);

  // Real backend: count finished stages by listening to NDJSON `stage_end`
  // events from /api/stream. No demo fallback — if MCAPI is missing or the
  // call fails, surface the error instead of pretending to make progress.
  // Hydrate from `session` so re-picking a finished session restores its
  // result without re-running the pipeline.
  const [stagesDone, setStagesDone] = useState(session.stagesDone ?? 0);
  const [activeStageIdx, setActiveStageIdx] = useState(session.activeStageIdx ?? -1);
  const [backendCtx, setBackendCtx] = useState(session.ctx ?? null);
  const [backendErr, setBackendErr] = useState(session.backendErr ?? null);
  const [thoughtBuf, setThoughtBuf] = useState(session.buildThought ?? "");
  const [outputBuf, setOutputBuf] = useState(session.buildOutput ?? "");
  const [stageDurationsMs, setStageDurationsMs] = useState(session.stageDurationsMs || {});
  const [stageStartedAtByIndex, setStageStartedAtByIndex] = useState(session.stageStartedAtByIndex || {});
  const stagesDoneRef = useRef(session.stagesDone ?? 0);
  const stageDurationsRef = useRef(session.stageDurationsMs || {});
  const stageStartedAtRef = useRef(session.stageStartedAtByIndex || {});
  const [usageSummary, setUsageSummary] = useState(session.usageSummary ?? null);
  const usageSummaryRef = useRef(session.usageSummary ?? null);
  const [liveUsageHint, setLiveUsageHint] = useState(null);
  const [usageRefreshKey, setUsageRefreshKey] = useState(0);
  const [meshyProgress, setMeshyProgress] = useState(session.meshyProgress ?? null);
  const workflowMode = session.workflow || "full";
  const isWorkflowOnly = !!session.uploadComplete && (workflowMode === "animation" || workflowMode === "texture");
  const workflowOnlyLabel = workflowMode === "texture" ? "贴图" : "动画";
  const requireLogin = () => {
    if (authUser) return false;
    setBackendErr("请先登录账号再进行对话。");
    if (typeof onRequireLogin === "function") onRequireLogin();
    return true;
  };
  const rememberUsage = (summary) => {
    if (!summary) return;
    const next = mergeUsageSummary(usageSummaryRef.current, summary);
    usageSummaryRef.current = next;
    setUsageSummary(next);
    setLiveUsageHint(null);
    setUsageRefreshKey((n) => n + 1);
    if (typeof onUpdate === "function") onUpdate({ usageSummary: next });
  };
  const rememberLiveUsage = (summary) => {
    if (!summary) return;
    setLiveUsageHint(summary);
    setUsageRefreshKey((n) => n + 1);
  };
  const rememberMeshyProgress = (patch) => {
    const next = { ...(meshyProgress || {}), ...patch };
    setMeshyProgress((prev) => ({ ...(prev || {}), ...patch }));
    if (typeof onUpdate === "function") onUpdate({ meshyProgress: next });
  };
  const appendBuildThought = (text) => {
    if (!text) return;
    setThoughtBuf((p) => clipTail(p + text));
  };
  const appendBuildOutput = (text) => {
    if (!text) return;
    setOutputBuf((p) => clipTail(p + text));
  };
  const clearBuildStream = () => {
    setThoughtBuf("");
    setOutputBuf("");
  };
  const setStagesDoneTracked = (updater) => {
    setStagesDone((prev) => {
      const next = typeof updater === "function" ? updater(prev) : updater;
      stagesDoneRef.current = next;
      return next;
    });
  };
  const visibleStageIndexForEvent = (stage) => {
    const idx = BACKEND_STAGE_TO_INDEX[stage];
    if (idx === undefined) return undefined;
    if (stage === "renderer" && stagesDoneRef.current >= BUILD_CHECKPOINT_DONE) {
      return 5;
    }
    return idx;
  };
  const startStageTimer = (idx) => {
    const key = String(idx);
    const now = Date.now();
    const nextStarts = { ...stageStartedAtRef.current };
    for (const openKey of Object.keys(nextStarts)) {
      if (openKey === key) continue;
      const started = Number(nextStarts[openKey] || 0);
      if (started) {
        stageDurationsRef.current = {
          ...stageDurationsRef.current,
          [openKey]: Number(stageDurationsRef.current[openKey] || 0) + Math.max(0, now - started),
        };
      }
      delete nextStarts[openKey];
    }
    if (!nextStarts[key]) nextStarts[key] = now;
    stageStartedAtRef.current = nextStarts;
    setStageDurationsMs(stageDurationsRef.current);
    setStageStartedAtByIndex(nextStarts);
  };
  const closeStageTimer = (idx, at = Date.now()) => {
    const key = String(idx);
    const started = Number(stageStartedAtRef.current[key] || 0);
    if (!started) return;
    const nextDurations = {
      ...stageDurationsRef.current,
      [key]: Number(stageDurationsRef.current[key] || 0) + Math.max(0, at - started),
    };
    const nextStarts = { ...stageStartedAtRef.current };
    delete nextStarts[key];
    stageDurationsRef.current = nextDurations;
    stageStartedAtRef.current = nextStarts;
    setStageDurationsMs(nextDurations);
    setStageStartedAtByIndex(nextStarts);
  };
  const markStageSkipped = (idx) => {
    const key = String(idx);
    if (Object.prototype.hasOwnProperty.call(stageDurationsRef.current, key)) return;
    const nextDurations = { ...stageDurationsRef.current, [key]: 0 };
    stageDurationsRef.current = nextDurations;
    setStageDurationsMs(nextDurations);
  };
  const clearOpenStageTimers = () => {
    stageStartedAtRef.current = {};
    setStageStartedAtByIndex({});
  };
  const closeAllStageTimers = () => {
    const now = Date.now();
    for (const key of Object.keys(stageStartedAtRef.current)) {
      closeStageTimer(Number(key), now);
    }
  };
  const applyBuildStageEvent = (ev) => {
    const idx = visibleStageIndexForEvent(ev.stage);
    if (idx === undefined) return false;
    if (ev.type === "stage_start") {
      setActiveStageIdx(idx);
      setStagesDoneTracked((prev) => Math.max(prev, idx));
      startStageTimer(idx);
      clearBuildStream();
      return true;
    }
    if (ev.type === "stage_end") {
      // The texture sub-pipeline has material/meshy/texturer/renderer events
      // under one visible "Texture" row. Keep it active until a final ctx with
      // texture pixels arrives.
      const doneThrough = idx === 5 ? BUILD_CHECKPOINT_DONE : idx + 1;
      closeStageTimer(idx);
      setStagesDoneTracked((prev) => Math.max(prev, doneThrough));
      return true;
    }
    if (ev.type === "stage_skip") {
      closeStageTimer(idx);
      markStageSkipped(idx);
      setStagesDoneTracked((prev) => Math.max(prev, idx + 1));
      appendBuildOutput(`\n[skip:${ev.stage}] ${ev.reason || ""}\n`);
      return true;
    }
    return false;
  };
  const handleMeterEvent = (ev, appendText) => {
    if (!ev) return false;
    const append = (text) => {
      if (typeof appendText === "function" && text) appendText(text);
    };
    if (ev.type === "usage_summary") {
      const summary = ev.summary || ev.usage;
      rememberUsage(summary);
      return true;
    }
    if (ev.type === "meshy_start" || ev.type === "meshy_remesh_start") {
      const usage = ev.usage;
      if (usage) rememberLiveUsage(usage);
      rememberMeshyProgress({
        taskId: ev.task_id,
        progress: 0,
        status: "STARTED",
        error: false,
      });
      append(`\n[meshy] task ${ev.task_id || ""} started\n`);
      return true;
    }
    if (ev.type === "meshy_progress" || ev.type === "meshy_remesh_progress") {
      rememberMeshyProgress({
        progress: Number(ev.progress || 0),
        status: ev.status || "IN_PROGRESS",
        error: false,
      });
      return true;
    }
    if (ev.type === "meshy_texture_ready") {
      rememberMeshyProgress({ status: "TEXTURE_READY", progress: Math.max(meshyProgress?.progress || 0, 90) });
      append("\n[meshy] texture ready\n");
      return true;
    }
    if (ev.type === "meshy_done" || ev.type === "meshy_remesh_done") {
      rememberMeshyProgress({ progress: 100, status: "SUCCEEDED", error: false });
      append("\n[meshy] completed\n");
      return true;
    }
    if (ev.type === "meshy_error" || ev.type === "meshy_remesh_error") {
      rememberMeshyProgress({
        progress: Number(meshyProgress?.progress || 0),
        status: ev.stage || "ERROR",
        error: true,
      });
      append(`\n[meshy:error] ${ev.message || "Meshy failed"}\n`);
      return true;
    }
    return false;
  };
  // Real-time elapsed timer for the build pipeline. startedAt is persisted
  // on the session so a session swap+return shows the cumulative time
  // instead of resetting. nowTick triggers re-render every second while
  // the pipeline is in flight.
  const [startedAt, setStartedAt] = useState(session.startedAt ?? null);
  const [doneAt, setDoneAt] = useState(session.doneAt ?? null);
  const [nowTick, setNowTick] = useState(Date.now());
  useEffect(() => {
    if (!startedAt || doneAt) return;
    const id = setInterval(() => setNowTick(Date.now()), 1000);
    return () => clearInterval(id);
  }, [startedAt, doneAt]);
  const elapsedMs = startedAt
    ? (doneAt || nowTick) - startedAt
    : 0;
  const elapsedLabel = (() => {
    if (!startedAt) return null;
    const s = Math.floor(elapsedMs / 1000);
    const m = Math.floor(s / 60);
    const r = s % 60;
    return m > 0 ? `${m}m${r.toString().padStart(2, "0")}s` : `${s}s`;
  })();
  // launchedRef removed — guard is now `restartTick` deps + the
  // session.started flag persisted across remounts.

  // True iff the user-initiated build pipeline has ever run for this session.
  // Persists across session-tab swaps: the initial /api/stream call lives in
  // the browser tab — switching session unmounts WorkspaceView and abandons
  // the in-flight reader. Without this guard, switching back would
  // auto-relaunch generate from scratch (analyst → coder → ...) and the
  // user's partial progress would be lost. We only auto-launch ONCE per
  // session, ever; if it didn't finish, the user has to explicitly retry.
  const [interruptedBuild, setInterruptedBuild] = useState(!!session.interruptedBuild);
  const buildAbortRef = useRef(null);
  const animAbortRef = useRef(null);
  const refineAbortRef = useRef(null);

  // Bumping this re-runs the build useEffect (deps depend on it). The
  // 'retry' button on the interrupted-build banner increments it.
  const [restartTick, setRestartTick] = useState(0);

  useEffect(() => {
    if (typeof onUpdate !== "function") return;
    onUpdate({
      stagesDone,
      activeStageIdx,
      buildThought: thoughtBuf,
      buildOutput: outputBuf,
      stageDurationsMs,
      stageStartedAtByIndex,
      backendErr,
      interruptedBuild,
    });
  }, [stagesDone, activeStageIdx, thoughtBuf, outputBuf, stageDurationsMs, stageStartedAtByIndex, backendErr, interruptedBuild]);

  useEffect(() => {
    if (!authChecked) return;
    // Already finished (rehydrated from sessions store) → no relaunch.
    if ((session.stagesDone ?? 0) >= STAGE_PLAN.length && session.ctx) {
      setStagesDoneTracked(STAGE_PLAN.length);
      return;
    }
    if (session.interruptedBuild && restartTick === 0) {
      setInterruptedBuild(true);
      if (typeof onUpdate === "function") {
        onUpdate({ running: false, started: false });
      }
      return;
    }
    if (requireLogin()) return;
    if (!window.MCAPI) {
      setBackendErr("无法访问后端 API（window.MCAPI 未加载）。请确保 FastAPI 已启动且 /web/api.js 可访问。");
      return;
    }
    if (!session.prompt) {
      setBackendErr("缺少提示词 — 无法启动管线。");
      return;
    }

    setInterruptedBuild(false);
    // Mark started + start the elapsed timer. startedAt persists on
    // session so cross-mount the user sees cumulative time, not a reset.
    const startTs = session.startedAt || Date.now();
    setStartedAt(startTs);
    setDoneAt(null);
    clearOpenStageTimers();
    if (typeof onUpdate === "function") {
      onUpdate({ started: true, startedAt: startTs, doneAt: null, running: true, backendErr: null, interruptedBuild: false, stageStartedAtByIndex: {} });
    }

    let cancelled = false;
    const buildController = new AbortController();
    buildAbortRef.current = buildController;
    (async () => {
      try {
        const { ctx } = await window.MCAPI.streamGenerate(
          {
            prompt: session.prompt,
            image: session.imageData || null,
            style: session.style,
            // RESUME: feed the latest checkpoint so analyst/coder/etc. that
            // already ran in a previous mount are skipped.
            resumeCtx: session.ctxPartial || session.ctx || null,
            sessionId: session.backendSessionId || null,
          },
          (ev) => {
            if (cancelled) return;
            if (handleMeterEvent(ev, appendBuildOutput)) return;
            // ---- Standard pipeline events ----
            if (
              ev.type === "stage_start" ||
              ev.type === "stage_end" ||
              ev.type === "stage_skip"
            ) {
              applyBuildStageEvent(ev);
            } else if (ev.type === "ctx_snapshot" && ev.ctx) {
              // Persist the per-stage checkpoint to the session so a later
              // interruption can resume from here instead of re-running.
              if (typeof onUpdate === "function") {
                onUpdate({ ctxPartial: ev.ctx,
                           backendSessionId: ev.ctx.session_id || null });
              }
            } else if (ev.type === "thought_delta") {
              appendBuildThought(ev.delta || "");
            } else if (
              ev.type === "analyst_stream"
              || ev.type === "coder_stream"
              || ev.type === "material_stream"
              || ev.type === "animator_stream"
            ) {
              appendBuildOutput(ev.delta || "");
            } else if (ev.type === "pipeline_done") {
              // generate finished through render. Keep the final "贴图" row
              // active until the chained texture pass returns real pixels.
              setStagesDoneTracked((p) => Math.max(p, BUILD_CHECKPOINT_DONE));
            } else if (ev.type === "error") {
              setBackendErr(ev.message || "pipeline error");
            } else if (ev.type === "warn") {
              // Surface warns in the output buffer too — non-fatal but useful.
              appendBuildOutput(`\n[warn:${ev.stage}] ${ev.message}\n`);
            }
          },
          { signal: buildController.signal },
        );
        if (!cancelled && ctx) {
          setBackendCtx(ctx);
          // Generate finished through render. Don't claim "all done" yet —
          // the chained build_texture below is responsible for the texture row.
          setStagesDoneTracked((p) => Math.max(p, BUILD_CHECKPOINT_DONE));
          if (typeof onUpdate === "function") {
            onUpdate({ ctx, stagesDone: BUILD_CHECKPOINT_DONE });
          }
          // Chain a build_texture pass: the `generate` action only runs
          // analyst→coder→kernel→renderer (no texturer), so without this
          // the DSL preview is real but the Texture preview stays empty.
          // Texture is required before the build is considered complete:
          // do not unlock animation/export with an untextured ctx.
          let textureOk = false;
          try {
            clearBuildStream();
            const styleHint = (session.style || "pixel") === "pbr" ? "realistic" : "pixel";
            let textureStreamError = "";
            const texCtx = await window.MCAPI.streamTextureBuild(
              {
                ctx,
                user_prompt: "",
                texture_style: styleHint,
                // Initial-build auto-retexture uses the same image the
                // user uploaded for the generate phase, so Meshy paints
                // the atlas to match.
                image: followUpImage || session.imageData || null,
              },
              (ev) => {
                if (cancelled) return;
                if (handleMeterEvent(ev, appendBuildOutput)) return;
                if (
                  ev.type === "stage_start" ||
                  ev.type === "stage_end" ||
                  ev.type === "stage_skip"
                ) {
                  applyBuildStageEvent(ev);
                } else if (ev.type === "thought_delta") {
                  appendBuildThought(ev.delta || "");
                } else if (
                  ev.type === "material_stream" || ev.type === "coder_stream"
                ) {
                  appendBuildOutput(ev.delta || "");
                } else if (ev.type === "warn") {
                  appendBuildOutput(`\n[warn:${ev.stage}] ${ev.message}\n`);
                } else if (ev.type === "error") {
                  textureStreamError = ev.message || "texture pipeline error";
                  appendBuildOutput(`\n[error] ${ev.message}\n`);
                }
              },
              { signal: buildController.signal },
            );
            if (!cancelled && !texCtx) {
              throw new Error("贴图生成失败 — 后端没有返回最终 ctx。");
            }
            if (!cancelled && texCtx) {
              // Verify the texturer actually shipped pixels — Meshy is now
              // the only source, so an empty texture means the upstream
              // call failed and the user needs to know explicitly.
              if (textureStreamError) {
                throw new Error(textureStreamError);
              }
              if (!texCtx.texture_png_base64) {
                throw new Error(
                  "贴图生成失败 — 没有收到有效的 texture_png_base64。"
                );
              }
              textureOk = true;
              setBackendCtx(texCtx);
              if (typeof onUpdate === "function") {
                onUpdate({ ctx: texCtx, stagesDone: STAGE_PLAN.length });
              }
            }
          } catch (e) {
            if (isAbortError(e) || buildController.signal.aborted) {
              throw e;
            }
            if (!cancelled) {
              setBackendErr("贴图生成异常: " + (e && e.message || e));
            }
          }
          // Only a real texture completes the build.
          if (!cancelled && textureOk) {
            closeAllStageTimers();
            setStagesDoneTracked(STAGE_PLAN.length);
            const finishedAt = Date.now();
            setDoneAt(finishedAt);
            if (typeof onUpdate === "function") {
              onUpdate({ stagesDone: STAGE_PLAN.length, doneAt: finishedAt, running: false });
            }
          }
        }
      } catch (e) {
        if (!cancelled && (isAbortError(e) || buildController.signal.aborted)) {
          closeAllStageTimers();
          setInterruptedBuild(true);
          setBackendErr(null);
          if (typeof onUpdate === "function") {
            onUpdate({ running: false, started: false, interruptedBuild: true, stageStartedAtByIndex: {} });
          }
        } else if (!cancelled) {
          // Paywall: convert 402 errors into a friendly upgrade prompt
          // instead of a raw HTTP message. Auto-jump the user to /#/upgrade
          // after a short delay so they don't have to hunt for the CTA.
          if (window.MCAPI?.isPaywallError && window.MCAPI.isPaywallError(e)) {
            setBackendErr(window.MCAPI.paywallMessage(e));
            setTimeout(() => window.MCAPI.openUpgrade(), 1500);
          } else {
            setBackendErr(String(e && e.message || e));
          }
        }
      } finally {
        if (buildAbortRef.current === buildController) {
          buildAbortRef.current = null;
        }
      }
    })();
    return () => {
      cancelled = true;
      if (buildAbortRef.current === buildController) {
        buildAbortRef.current = null;
      }
    };
  }, [restartTick, authChecked]);

  // After build done, ask for animation
  const buildDone = stagesDone >= STAGE_PLAN.length;

  // Animations the user is requesting in the post-build editor
  const [anims, setAnims] = useState(session.anims || [
    { id: "a-idle", name: "待机", dirs: 1 },
    { id: "a-walk", name: "走路", dirs: 4 },
  ]);
  const [animsBuilt, setAnimsBuilt] = useState(!!session.animsBuilt);
  const [animsBuilding, setAnimsBuilding] = useState(!!session.animsBuilding);
  const [animProgress, setAnimProgress] = useState(session.animProgress || {}); // {animId: {kind, kfs, bones}}

  useEffect(() => {
    if (typeof onUpdate !== "function") return;
    onUpdate({ anims, animsBuilt, animsBuilding, animProgress });
  }, [anims, animsBuilt, animsBuilding, animProgress]);

  useEffect(() => {
    if (!animsBuilding) return;
    if (!authChecked) return;
    if (requireLogin()) {
      setAnimsBuilding(false);
      return;
    }
    if (!window.MCAPI) {
      setBackendErr("无法访问后端 API（window.MCAPI 未加载）。");
      setAnimsBuilding(false);
      return;
    }
    if (!backendCtx) {
      setBackendErr("当前会话没有有效 ctx — 请先完成基础建模。");
      setAnimsBuilding(false);
      return;
    }
    let cancelled = false;
    const animController = new AbortController();
    animAbortRef.current = animController;
    (async () => {
      try {
        // Plan directional jobs CLIENT-side, mirroring the contract from
        // cli/anim_directions.py so the UI label set matches what the
        // backend ultimately puts on bbmodel.animations. Each anim row
        // expands to N directions; L/R-pair sources go to LLM, the
        // partner is filled by /api/stream type=mirror_animation (no
        // LLM cost, guarantees pixel-symmetry).
        const DIRS = {
          1: [null],
          2: ["L", "R"],
          4: ["F", "B", "L", "R"],
          8: ["N", "NE", "E", "SE", "S", "SW", "W", "NW"],
        };
        const MIRROR_PAIRS = { L: "R", W: "E", NW: "NE", SW: "SE" };
        const HINTS = {
          F:  "(direction: facing/moving FORWARD — bbmodel -Z axis, into the scene)",
          B:  "(direction: moving BACKWARD — bbmodel +Z axis)",
          L:  "(direction: moving LEFT — bbmodel -X axis)",
          R:  "(direction: moving RIGHT — bbmodel +X axis)",
          N:  "(direction: moving NORTH/forward — -Z axis)",
          S:  "(direction: moving SOUTH/backward — +Z axis)",
          E:  "(direction: moving EAST/right — +X axis)",
          W:  "(direction: moving WEST/left — -X axis)",
          NE: "(direction: forward-right diagonal)",
          NW: "(direction: forward-left diagonal)",
          SE: "(direction: backward-right diagonal)",
          SW: "(direction: backward-left diagonal)",
        };
        const planJobs = (animName, n) => {
          const dset = DIRS[n] || DIRS[1];
          if (n === 1) return [{ name: animName, kind: "llm", hint: "" }];
          const llm = [];
          const mirror = [];
          const set = new Set(dset);
          for (const d of dset) {
            const fullName = `${animName}_${d}`;
            if (MIRROR_PAIRS[d] && set.has(MIRROR_PAIRS[d])) {
              llm.push({ name: fullName, kind: "llm", hint: HINTS[d] || "" });
              mirror.push({
                name: `${animName}_${MIRROR_PAIRS[d]}`, kind: "mirror",
                source: fullName,
              });
            } else if (Object.values(MIRROR_PAIRS).includes(d)) {
              continue; // filled by mirror
            } else {
              llm.push({ name: fullName, kind: "llm", hint: HINTS[d] || "" });
            }
          }
          return [...llm, ...mirror];
        };

        let ctx = backendCtx;
        let animationOutputCount = 0;
        let animationStreamError = "";
        for (const a of anims) {
          if (cancelled) return;
          const jobs = planJobs(a.name, a.dirs || 1);
          let perAnim = { kfs: 0, bones: 0, names: [] };
          for (const job of jobs) {
            if (cancelled) return;
            clearBuildStream();
            const onEv = (ev) => {
              if (handleMeterEvent(ev, appendBuildOutput)) return;
              if (ev.type === "thought_delta") {
                appendBuildThought(ev.delta || "");
              } else if (ev.type === "animator_stream") {
                appendBuildOutput(ev.delta || "");
              } else if (ev.type === "animator_output" && ev.animation) {
                animationOutputCount += 1;
                perAnim.kfs = Math.max(perAnim.kfs, ev.animation.keyframe_count || 0);
                perAnim.bones = Math.max(perAnim.bones, ev.animation.bones_animated || 0);
                perAnim.names.push(ev.animation.name);
                setAnimProgress((prev) => ({
                  ...prev,
                  [a.id]: { name: perAnim.names.join(", "), kfs: perAnim.kfs, bones: perAnim.bones },
                }));
              } else if (ev.type === "animator_error") {
                animationStreamError = ev.error || "animator error";
                setBackendErr(animationStreamError);
              } else if (ev.type === "animator_skip") {
                animationStreamError = ev.reason || "animator skipped";
                setBackendErr(animationStreamError);
              } else if (ev.type === "error") {
                animationStreamError = ev.message || "animator error";
                setBackendErr(animationStreamError);
              }
            };
            const next = job.kind === "llm"
              ? await window.MCAPI.streamAnimation(
                  { ctx, animation_name: job.name, user_prompt: job.hint }, onEv, { signal: animController.signal })
              : await window.MCAPI.streamMirrorAnimation(
                  { ctx, source_name: job.source, new_name: job.name }, onEv, { signal: animController.signal });
            // Only adopt the new ctx if state_sync actually returned one;
            // a stream that crashed mid-flight returns null and would
            // otherwise wipe out the working ctx, breaking subsequent
            // animations AND the post-anim Composer (which gates on
            // backendCtx being truthy).
            if (next) {
              ctx = next;
              setBackendCtx(next);
            }
            if (animationStreamError) {
              throw new Error(animationStreamError);
            }
          }
        }
        if (!cancelled) {
          const realAnimationCount = (ctx?.bbmodel?.animations || []).length;
          if (animationOutputCount === 0 || realAnimationCount === 0) {
            throw new Error("动画生成失败 — 后端没有返回可预览的 keyframes。");
          }
          setAnimsBuilding(false);
          setAnimsBuilt(true);
          if (typeof onUpdate === "function") {
            onUpdate({ ctx, anims, animsBuilt: true });
          }
        }
      } catch (e) {
        if (!cancelled && (isAbortError(e) || animController.signal.aborted)) {
          setBackendErr(null);
          setAnimsBuilding(false);
          if (typeof onUpdate === "function") {
            onUpdate({ running: false, animsBuilding: false });
          }
        } else if (!cancelled) {
          if (window.MCAPI?.isPaywallError && window.MCAPI.isPaywallError(e)) {
            setBackendErr(window.MCAPI.paywallMessage(e));
            setTimeout(() => window.MCAPI.openUpgrade(), 1500);
          } else {
            setBackendErr(String(e && e.message || e));
          }
          setAnimsBuilding(false);
        }
      } finally {
        if (animAbortRef.current === animController) {
          animAbortRef.current = null;
        }
      }
    })();
    return () => {
      cancelled = true;
      if (animAbortRef.current === animController) {
        animAbortRef.current = null;
      }
    };
  }, [animsBuilding, authChecked]);

  const onDownloadGlb = async () => {
    if (!window.MCAPI || !backendCtx) {
      setBackendErr("无法导出 GLB — 当前会话没有有效 ctx。");
      return;
    }
    try {
      // /api/export/glb persists to My Assets; this redundant save is
      // fire-and-forget insurance so the asset shows up even on partial
      // backend failure.
      try { window.MCAPI.library?.save?.(backendCtx); } catch {}
      const blob = await window.MCAPI.exportGlb(backendCtx);
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = `${backendCtx?.model_spec?.name || backendCtx?.bbmodel?.name || session.spec.name || "model"}.glb`;
      a.click();
      setTimeout(() => URL.revokeObjectURL(url), 5000);
    } catch (e) {
      setBackendErr(String(e && e.message || e));
    }
  };

  // Inject real bbmodel stats into the static stage detail strings so
  // the StageCard reflects what actually got built (e.g. "生成 DSL · 127
  // cubes"), not the seed default. Falls back to the static description
  // when the ctx hasn't reached that stage yet.
  const realCubeCount = (backendCtx?.bbmodel?.elements || []).length;
  const realAnimCount = (backendCtx?.bbmodel?.animations || []).length;
  const realResolution = backendCtx?.bbmodel?.resolution;
  const generatedSpec = backendCtx?.model_spec || session.spec || {};
  const generatedName = generatedSpec.name || backendCtx?.bbmodel?.name || "model";
  const generatedGroups = ((generatedSpec.groups || [])
    .map((g) => g && g.name)
    .filter(Boolean)
    .join(", ")) || "—";
  const generatedResolution = Array.isArray(generatedSpec.resolution)
    ? `[${generatedSpec.resolution.join(", ")}]`
    : (realResolution ? `[${realResolution.width}, ${realResolution.height}]` : "—");
  const _detailWithStats = (i, base) => {
    if (!realCubeCount) return base;
    if (i === 1 || i === 4) return `${base} · ${realCubeCount} cubes`;
    if (i === 5 && realResolution) {
      return `Meshy -> atlas · ${realResolution.width}×${realResolution.height}`;
    }
    return base;
  };
  const visibleActiveStageIdx = !buildDone
    ? (stagesDone >= BUILD_CHECKPOINT_DONE
        ? 5
        : (activeStageIdx >= 0 ? activeStageIdx : Math.min(stagesDone, STAGE_PLAN.length - 1)))
    : -1;
  const stageRows = STAGE_PLAN.map((s, i) => {
    const status = i < stagesDone ? "done" : i === visibleActiveStageIdx ? "active" : "pending";
    const key = String(i);
    const started = Number(stageStartedAtByIndex[key] || 0);
    const recorded = Number(stageDurationsMs[key] || 0);
    const hasRecorded = Object.prototype.hasOwnProperty.call(stageDurationsMs, key);
    const elapsed = recorded + (started ? Math.max(0, nowTick - started) : 0);
    const time = (hasRecorded || started) ? fmtStageMs(elapsed) : "—";
    const name = status === "active" ? s.activeName : s.name;
    return { name, detail: _detailWithStats(i, s.detail), time, status };
  });
  const coreBusy = (!buildDone && !backendErr && !interruptedBuild) || animsBuilding;

  // Build-time follow-up
  const followUp = (
    <div className="turn assistant">
      <AssistantHead busy={coreBusy} />
      <div className="assistant-body" style={{ width: "100%" }}>
        <p>
          已完成基础建模 — <span className="ic">{session.style}</span> 风格,
          <span className="ic">{realCubeCount} cubes</span>。
          要生成动画吗?可以同时生成多个,左右方向自动镜像。
        </p>
        <div style={{ marginTop: 14 }}>
          <AnimationEditor
            anims={anims}
            setAnims={setAnims}
            onConfirm={() => setAnimsBuilding(true)}
          />
        </div>
      </div>
    </div>
  );

  // Animation building turn
  const animBuildTurn = animsBuilding && (
    <div className="turn assistant">
      <AssistantHead busy />
      <div className="assistant-body" style={{ width: "100%" }}>
        <p>正在并行生成 {anims.length} 个动画…</p>
        <StageCard stages={anims.map((a, i) => ({
          name: a.name,
          detail: `LLM 解析 ${a.dirs} 向 · ${a.dirs > 1 ? "镜像 " + (a.dirs / 2) + " 个" : "无镜像"}`,
          time: i < anims.length - 1 ? "1.2s" : "running…",
          status: i < anims.length - 1 ? "done" : "active",
        }))} />
      </div>
    </div>
  );

  // Reusable export block — re-rendered after the initial build AND
  // after every chat refine. The body adapts to the refine intent:
  //   "texture"  → atlas thumbnail + 下载贴图 PNG
  //   "animation"→ animation list + 下载 GLB (with anims)
  //   "adjuster" → cube/group geometry stats + 下载 GLB
  //   default    → full file tile grid (initial build / unknown intent)
  const renderExportBlock = (headerText, intent = null) => {
    const animations = backendCtx?.bbmodel?.animations || [];
    const elements = backendCtx?.bbmodel?.elements || [];
    const texB64 = backendCtx?.texture_png_base64;
    const texSrc = texB64
      ? (texB64.startsWith("data:") ? texB64 : `data:image/png;base64,${texB64}`)
      : null;

    const onDownloadPng = () => {
      if (!texB64) return;
      // Texture-only download path — no backend round-trip, so save to
      // My Assets explicitly. Fire-and-forget.
      try { window.MCAPI?.library?.save?.(backendCtx); } catch {}
      const a = document.createElement("a");
      a.href = texSrc;
      a.download = `${session.spec.name || "model"}_texture.png`;
      document.body.appendChild(a);
      a.click();
      a.remove();
    };

    if (intent === "texture") {
      return (
        <>
          <p>{headerText} — Meshy 重新生成了贴图,以下是新的 atlas。</p>
          <div style={{ marginTop: 12, display: "flex", gap: 12, alignItems: "flex-start" }}>
            {texSrc ? (
              <div style={{
                width: 200, height: 200, padding: 6,
                background: "var(--bg-elev)", border: "1px solid var(--line)",
                borderRadius: 10, overflow: "hidden",
              }}>
                <img src={texSrc} alt="texture atlas"
                     style={{ width: "100%", height: "100%", objectFit: "contain",
                              imageRendering: "pixelated" }} />
              </div>
            ) : (
              <div style={{ fontSize: 13, color: "var(--ink-3)" }}>(无可预览的贴图)</div>
            )}
            <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
              <button className="primary-btn" onClick={onDownloadPng} disabled={!texB64}>
                <I.Download size={14} /> 下载贴图 PNG
              </button>
              <button className="ghost-btn" onClick={onDownloadGlb} disabled={!backendCtx}>
                <I.Download size={14} /> 下载 GLB(含新贴图)
              </button>
            </div>
          </div>
        </>
      );
    }

    if (intent === "animation") {
      return (
        <>
          <p>
            {headerText} — 模型现在共有 <span className="ic">{animations.length}</span> 个动画。
          </p>
          {animations.length > 0 && (
            <ul style={{ marginTop: 10, paddingLeft: 18,
                         fontSize: 13, color: "var(--ink-2)", lineHeight: 1.7 }}>
              {animations.map((a) => {
                const kfs = Object.values(a.animators || {}).reduce(
                  (s, an) => s + ((an && an.keyframes) || []).length, 0,
                );
                return (
                  <li key={a.uuid || a.name}>
                    <span style={{ fontFamily: "var(--font-mono)" }}>{a.name}</span>
                    <span style={{ color: "var(--ink-3)", marginLeft: 8 }}>
                      {(a.length || 0).toFixed(2)}s · {kfs} 关键帧
                      {a.loop ? " · 循环" : ""}
                    </span>
                  </li>
                );
              })}
            </ul>
          )}
          {backendCtx && (
            <div style={{ marginTop: 12 }}>
              <button className="primary-btn" onClick={onDownloadGlb}>
                <I.Download size={14} /> 下载 GLB(含 {animations.length} 个动画)
              </button>
            </div>
          )}
        </>
      );
    }

    if (intent === "adjuster") {
      let cubes = elements.length;
      let bbox = null;
      if (cubes) {
        const xs = elements.flatMap(e => [e.from?.[0], e.to?.[0]]).filter(v => Number.isFinite(v));
        const ys = elements.flatMap(e => [e.from?.[1], e.to?.[1]]).filter(v => Number.isFinite(v));
        const zs = elements.flatMap(e => [e.from?.[2], e.to?.[2]]).filter(v => Number.isFinite(v));
        if (xs.length && ys.length && zs.length) {
          bbox = {
            x: [Math.min(...xs), Math.max(...xs)],
            y: [Math.min(...ys), Math.max(...ys)],
            z: [Math.min(...zs), Math.max(...zs)],
          };
        }
      }
      return (
        <>
          <p>{headerText} — 几何已重建。</p>
          <div style={{ marginTop: 10, padding: 10,
                        background: "var(--bg-elev)", borderRadius: 8,
                        fontFamily: "var(--font-mono)", fontSize: 12,
                        color: "var(--ink-2)" }}>
            <div>cubes: <span className="ic">{cubes}</span></div>
            {bbox && (
              <div style={{ marginTop: 4 }}>
                bbox: x[{bbox.x[0].toFixed(1)}, {bbox.x[1].toFixed(1)}] ·
                y[{bbox.y[0].toFixed(1)}, {bbox.y[1].toFixed(1)}] ·
                z[{bbox.z[0].toFixed(1)}, {bbox.z[1].toFixed(1)}]
              </div>
            )}
            <div style={{ marginTop: 4 }}>
              animations preserved: <span className="ic">{animations.length}</span>
              {" "}(refine 已经清掉旧的并重建了)
            </div>
          </div>
          {backendCtx && (
            <div style={{ marginTop: 12 }}>
              <button className="primary-btn" onClick={onDownloadGlb}>
                <I.Download size={14} /> 下载 GLB
              </button>
            </div>
          )}
        </>
      );
    }

    // Default — initial build / unknown intent
    return (
      <>
        <p>
          {headerText} — 已生成 <span className="ic">
            {animations.length || anims.length}
          </span> 个动画。可以直接导入 Minecraft / Blockbench。
        </p>
        <div style={{ marginTop: 14 }}>
          <ExportCard
            ctx={backendCtx}
            name={session.spec.name}
            files={[
              { name: `${session.spec.name}.bbmodel`, ext: "BB",   size: "—" },
              { name: `${session.spec.name}_pack.zip`,ext: "ZIP",  size: "—" },
              { name: `${session.spec.name}.glb`,     ext: "GLB",  size: "—" },
              { name: `${session.spec.name}_texture.png`,ext: "PNG",size: "—" },
            ]} />
        </div>
        {backendCtx && (
          <div style={{ marginTop: 12 }}>
            <button className="primary-btn" onClick={onDownloadGlb}>
              <I.Download size={14} /> 下载 GLB
            </button>
          </div>
        )}
      </>
    );
  };

  // Export turn (after the initial multi-anim build).
  // Two states:
  //   bbmodel has ≥1 animation → '全部完成 — N 个动画'
  //   animsBuilt but bbmodel has zero animations → the build loop ran
  //   but every job failed (Meshy timeout / animator error). Show a
  //   clear retry banner instead of pretending '全部完成'.
  const realAnims = (backendCtx?.bbmodel?.animations || []).length;
  const exportTurn = animsBuilt && (
    <div className="turn assistant">
      <AssistantHead busy={coreBusy} />
      <div className="assistant-body" style={{ width: "100%" }}>
        {realAnims === 0 ? (
          <>
            <p style={{ color: "var(--warn, #d6982a)" }}>
              动画生成结束 — 但 <span className="ic">0 个动画</span> 实际写入 bbmodel。
              可能每条 LLM 调用都被超时切断或返回错误。
              下面是当前的几何/贴图导出（不含动画）；想再试动画就在 Composer 里说一声（比如 "加一个待机动画"）。
            </p>
            <div style={{ marginTop: 14 }}>
              <ExportCard
                ctx={backendCtx}
                name={session.spec.name}
                files={[
                  { name: `${session.spec.name}.bbmodel`, ext: "BB",   size: "—" },
                  { name: `${session.spec.name}_pack.zip`,ext: "ZIP",  size: "—" },
                  { name: `${session.spec.name}.glb`,     ext: "GLB",  size: "—" },
                  { name: `${session.spec.name}_texture.png`,ext: "PNG",size: "—" },
                ]} />
            </div>
            {backendCtx && (
              <div style={{ marginTop: 12, display: "flex", gap: 8 }}>
                <button className="primary-btn" onClick={onDownloadGlb}>
                  <I.Download size={14} /> 下载 GLB(无动画)
                </button>
                <button className="ghost-btn" onClick={() => {
                  setAnimsBuilt(false);
                  setAnimsBuilding(true);
                }}>
                  <I.Refresh size={14} /> 重试动画生成
                </button>
              </div>
            )}
          </>
        ) : (
          renderExportBlock("全部完成")
        )}
      </div>
    </div>
  );

  // Composer for follow-up edits
  const [followUpPrompt, setFollowUpPrompt] = useState("");
  const [followUpStyle, setFollowUpStyle] = useState(session.followUpStyle || session.style);
  // Reference image attached on the workspace's bottom Composer. Lets the
  // user provide a visual brief for texture / retexture flows (the upload
  // GLB in 仅贴图 has no image option of its own). Seeded from session
  // imageData when an upload flow carried one in, otherwise null.
  const [followUpImage, setFollowUpImage] = useState(session.imageData || null);
  const [followUpImageName, setFollowUpImageName] = useState(session.imageName || null);
  // Persisted refine turns (accumulating chat history). Each entry:
  //   { id, prompt, thought, output, status: "running"|"done"|"error", error?, intent? }
  const [refineTurns, setRefineTurns] = useState(session.refineTurns || []);
  const [activeRefineId, setActiveRefineId] = useState(session.activeRefineId || null);
  const hasRunningRefine = refineTurns.some((t) => t.status === "running");
  const sessionBusy = (!buildDone && !backendErr && !interruptedBuild) || animsBuilding || hasRunningRefine;

  const stopCurrentProcess = () => {
    const stoppingBuild = !!buildAbortRef.current || (!buildDone && !interruptedBuild && !backendErr);
    buildAbortRef.current?.abort();
    animAbortRef.current?.abort();
    refineAbortRef.current?.abort();
    closeAllStageTimers();
    setBackendErr(null);
    if (stoppingBuild) setInterruptedBuild(true);
    if (animsBuilding) setAnimsBuilding(false);
    setActiveRefineId(null);
    setRefineTurns((prev) => prev.map((t) =>
      t.status === "running"
        ? { ...t, status: "done", summary: "已中止，已保留当前输出。", stopped: true }
        : t
    ));
    if (typeof onUpdate === "function") {
      const patch = {
        running: false,
        animsBuilding: false,
        activeRefineId: null,
        stageStartedAtByIndex: {},
      };
      if (stoppingBuild) {
        patch.started = false;
        patch.interruptedBuild = true;
      }
      onUpdate(patch);
    }
  };

  useEffect(() => {
    if (typeof onUpdate !== "function") return;
    onUpdate({ refineTurns, activeRefineId, followUpStyle, running: sessionBusy });
  }, [refineTurns, activeRefineId, followUpStyle, sessionBusy]);

  // Re-submit a paused refine with confirm:true. Used by the warning UI
  // when the user accepts that texture/animations will be cleared.
  const runConfirmedRefine = async (turnId) => {
    if (requireLogin()) return;
    const turn = refineTurns.find((x) => x.id === turnId);
    if (!turn) return;
    const updateTurn = (patch) =>
      setRefineTurns((prev) => prev.map((x) =>
        x.id === turnId ? { ...x, ...(typeof patch === "function" ? patch(x) : patch) } : x));
    updateTurn({ status: "running", thought: "", output: "",
                 confirmInfo: null, error: null });
    setActiveRefineId(turnId);
    let lastAnimOutput = null;
    let lastTextureBytes = 0;
    const refineController = new AbortController();
    refineAbortRef.current = refineController;
    try {
      const newCtx = await window.MCAPI.streamRefine(
        {
          ctx: backendCtx,
          user_prompt: turn.prompt,
          confirm: true,
          // Carry the workspace's reference image across the
          // confirm-and-continue round too.
          image: followUpImage || null,
        },
        (ev) => {
          if (handleMeterEvent(ev, (text) =>
            updateTurn((x) => ({ output: clipTail((x.output || "") + text) })))) return;
          if (ev.type === "thought_delta") {
            updateTurn((x) => ({ thought: clipTail((x.thought || "") + (ev.delta || "")) }));
          } else if (
            ev.type === "coder_stream" || ev.type === "analyst_stream"
            || ev.type === "material_stream" || ev.type === "animator_stream"
            || ev.type === "adjuster_stream"
          ) {
            updateTurn((x) => ({ output: clipTail((x.output || "") + (ev.delta || "")) }));
          } else if (ev.type === "animator_output" && ev.animation) {
            lastAnimOutput = ev.animation;
          } else if (ev.type === "texturer_done") {
            lastTextureBytes = ev.size_bytes || 0;
          } else if (ev.type === "warn") {
            updateTurn((x) => ({ output: clipTail((x.output || "") + `\n[warn:${ev.stage}] ${ev.message}\n`) }));
          } else if (ev.type === "error") {
            updateTurn({ status: "error", error: ev.message || "refine error" });
          }
        },
        { signal: refineController.signal },
      );
      if (newCtx) {
        setBackendCtx(newCtx);
        if (typeof onUpdate === "function") onUpdate({ ctx: newCtx });
      }
      let summary = null;
      const animCount = newCtx?.bbmodel?.animations?.length;
      if (lastAnimOutput) {
        summary = `新增动画 “${lastAnimOutput.name}”（${lastAnimOutput.bones_animated} 骨, ${lastAnimOutput.keyframe_count} 关键帧），共 ${animCount || "?"} 个动画`;
      } else if (lastTextureBytes) {
        summary = `贴图已重生（${Math.round(lastTextureBytes / 1024)} KB）`;
      } else if (newCtx?.bbmodel?.elements?.length) {
        summary = `已应用，${newCtx.bbmodel.elements.length} cubes`;
      }
      updateTurn((x) => ({
        status: x.status === "error" ? "error" : "done",
        summary,
        addedAnimation: !!lastAnimOutput,
      }));
    } catch (e) {
      if (isAbortError(e) || refineController.signal.aborted) {
        updateTurn((x) => ({
          status: "done",
          summary: "已中止，已保留当前输出。",
          stopped: true,
        }));
        return;
      }
      updateTurn({ status: "error", error: String(e && e.message || e) });
    } finally {
      if (refineAbortRef.current === refineController) {
        refineAbortRef.current = null;
      }
      setActiveRefineId(null);
    }
  };

  const runWorkflowOnlyTurn = async (text) => {
    const clean = String(text || "").trim();
    const mode = workflowMode === "texture" ? "texture" : "animation";
    const turnId = "r_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
    setFollowUpPrompt("");
    if (!isMeaningfulWorkflowPrompt(clean)) {
      setRefineTurns((prev) => [...prev, {
        id: turnId,
        prompt: clean,
        thought: "",
        output: "",
        status: "unclear",
        intent: mode,
        summary: mode === "texture"
          ? "意图不清晰。请描述贴图风格、材质、颜色或分辨率。"
          : "意图不清晰。请描述动画动作、方向数、循环或节奏。",
      }]);
      return;
    }
    setRefineTurns((prev) => [...prev, {
      id: turnId,
      prompt: clean,
      thought: "",
      output: "",
      status: "running",
      error: null,
      intent: mode,
      workflowOnly: true,
    }]);
    setActiveRefineId(turnId);
    const updateTurn = (patch) =>
      setRefineTurns((prev) => prev.map((t) =>
        t.id === turnId ? { ...t, ...(typeof patch === "function" ? patch(t) : patch) } : t));
    const refineController = new AbortController();
    refineAbortRef.current = refineController;
    let lastAnimOutput = null;
    let lastTextureBytes = 0;
    let streamError = "";
    try {
      const appendToTurn = (chunk) =>
        updateTurn((t) => ({ output: clipTail((t.output || "") + chunk) }));
      const onEv = (ev) => {
        if (handleMeterEvent(ev, appendToTurn)) return;
        if (ev.type === "thought_delta") {
          updateTurn((t) => ({ thought: clipTail((t.thought || "") + (ev.delta || "")) }));
        } else if (
          ev.type === "animator_stream" ||
          ev.type === "material_stream" ||
          ev.type === "coder_stream" ||
          ev.type === "texturer_stream"
        ) {
          appendToTurn(ev.delta || "");
        } else if (ev.type === "animator_output" && ev.animation) {
          lastAnimOutput = ev.animation;
        } else if (ev.type === "texturer_done") {
          lastTextureBytes = ev.size_bytes || 0;
        } else if (ev.type === "warn") {
          appendToTurn(`\n[warn:${ev.stage}] ${ev.message}\n`);
        } else if (ev.type === "error" || ev.type === "animator_error" || ev.type === "animator_skip") {
          streamError = ev.message || ev.error || ev.reason || "workflow error";
        }
      };
      const newCtx = mode === "texture"
        ? await window.MCAPI.streamTextureBuild(
            {
              ctx: backendCtx,
              user_prompt: clean,
              texture_style: (followUpStyle || session.style) === "pbr" ? "realistic" : "pixel",
              // Hand the workspace's reference image (if any) to Meshy
              // via image_style_url + Material Designer's LLM input.
              image: followUpImage || null,
            },
            onEv,
            { signal: refineController.signal },
          )
        : await window.MCAPI.streamAnimation(
            {
              ctx: backendCtx,
              animation_name: animationNameFromPrompt(clean),
              user_prompt: clean,
            },
            onEv,
            { signal: refineController.signal },
          );
      if (streamError) throw new Error(streamError);
      if (!newCtx) throw new Error(mode === "texture" ? "贴图生成失败：后端没有返回 ctx。" : "动画生成失败：后端没有返回 ctx。");
      if (mode === "texture" && !newCtx.texture_png_base64) {
        throw new Error("贴图生成失败：没有收到可预览的贴图。");
      }
      setBackendCtx(newCtx);
      if (typeof onUpdate === "function") onUpdate({ ctx: newCtx });
      const animCount = newCtx?.bbmodel?.animations?.length || 0;
      updateTurn({
        status: "done",
        intent: mode,
        summary: mode === "texture"
          ? `贴图已更新${lastTextureBytes ? `（${Math.round(lastTextureBytes / 1024)} KB）` : ""}`
          : `已添加动画${lastAnimOutput?.name ? ` ${lastAnimOutput.name}` : ""}，共 ${animCount} 个动画`,
        addedAnimation: mode === "animation",
      });
    } catch (e) {
      if (isAbortError(e) || refineController.signal.aborted) {
        updateTurn({
          status: "done",
          summary: "已中止，已保留当前输出。",
          stopped: true,
        });
      } else {
        updateTurn({ status: "error", error: String(e && e.message || e) });
      }
    } finally {
      if (refineAbortRef.current === refineController) {
        refineAbortRef.current = null;
      }
      setActiveRefineId(null);
    }
  };

  const refineTurnsView = refineTurns.map((t) => (
    <React.Fragment key={t.id}>
      <div className="turn user">
        <div className="bubble-user">{t.prompt}</div>
      </div>
      <div className="turn assistant">
        <AssistantHead busy={t.status === "running"}>
          {t.intent ? <span className="small" style={{ marginLeft: 8 }}>· {t.intent}</span> : null}
        </AssistantHead>
        <div className="assistant-body" style={{ width: "100%" }}>
          {t.status === "error" && (
            <p style={{ color: "var(--danger)" }}>refine 失败：{t.error || "unknown"}</p>
          )}
          {t.status === "unclear" && (
            <div style={{
              marginTop: 12,
              padding: 12,
              border: "1px solid var(--warn, #d6982a)",
              background: "rgba(214, 152, 42, 0.08)",
              borderRadius: 12,
              color: "var(--ink-2)",
              fontSize: 13,
            }}>
              {t.summary || "意图不清晰，请换一种说法。"}
            </div>
          )}
          {t.status === "running" && !(t.thought || t.output) && (
            <p>正在处理…<span className="caret" /></p>
          )}
          {(t.thought || t.output) && (
            <StreamPanel thought={t.thought} output={t.output}
                         stageName={t.status === "running" ? "Refine" : null} />
          )}
          {t.status === "awaiting_confirm" && t.confirmInfo && (
            <div style={{
              marginTop: 12, padding: 14,
              border: "1px solid var(--warn, #d6982a)",
              background: "rgba(214, 152, 42, 0.08)",
              borderRadius: 12,
            }}>
              <div style={{ fontWeight: 600, marginBottom: 6 }}>
                几何修改 — 需要确认
              </div>
              <div style={{ fontSize: 13, color: "var(--ink-2)", lineHeight: 1.6 }}>
                {t.confirmInfo.message}
                {t.confirmInfo.animation_names && t.confirmInfo.animation_names.length > 0 && (
                  <div style={{ marginTop: 6, fontSize: 12, color: "var(--ink-3)" }}>
                    将清除：{t.confirmInfo.animation_names.join("、")}
                  </div>
                )}
              </div>
              <div style={{ marginTop: 12, display: "flex", gap: 8 }}>
                <button className="primary-btn"
                        onClick={() => runConfirmedRefine(t.id)}>
                  继续（清除并重建）
                </button>
                <button className="ghost-btn"
                        onClick={() => setRefineTurns((prev) =>
                          prev.map((x) => x.id === t.id
                            ? { ...x, status: "done", summary: "已取消（保留旧贴图/动画）",
                                confirmInfo: null }
                            : x))}>
                  取消
                </button>
              </div>
            </div>
          )}
          {t.status === "done" && t.stopped && (
            <div style={{ marginTop: 12, display: "flex", alignItems: "center", gap: 8,
                          fontSize: 13, color: "var(--ink-3)" }}>
              <span style={{
                width: 14, height: 14, borderRadius: 4,
                border: "1px solid var(--line-strong)",
                display: "inline-flex",
                alignItems: "center", justifyContent: "center",
                fontSize: 9, fontWeight: 700,
              }}>■</span>
              <span>{t.summary || "已中止，已保留当前输出。"}</span>
            </div>
          )}
          {t.status === "done" && !t.stopped && (
            <>
              <div style={{ marginTop: 12, display: "flex", alignItems: "center", gap: 8,
                            fontSize: 13, color: "var(--good)" }}>
                <span style={{
                  width: 14, height: 14, borderRadius: 999,
                  background: "var(--good)", display: "inline-flex",
                  alignItems: "center", justifyContent: "center",
                  color: "white", fontSize: 10, fontWeight: 700,
                }}>✓</span>
                <span>
                  已完成
                  {t.summary ? ` — ${t.summary}` : ""}
                </span>
              </div>
              {/* Re-render the export block inline on every successful
                  refine — content adapts to intent: texture refines show
                  the atlas thumbnail + PNG download; animation refines
                  show the animation list; adjuster refines show
                  geometry stats. */}
              <div style={{ marginTop: 14 }}>
                {renderExportBlock(
                  t.intent === "texture"   ? "贴图已更新" :
                  t.intent === "animation" ? "动画已更新" :
                  t.intent === "adjuster"  ? "几何已更新" :
                  "已更新",
                  t.intent || null
                )}
              </div>
            </>
          )}
        </div>
      </div>
    </React.Fragment>
  ));

  const currentSessionId =
    backendCtx?.session_id ||
    session.ctx?.session_id ||
    session.backendSessionId ||
    session.id;
  const currentSessionCostUsd = usageCostUsd(usageSummary) + usageCostUsd(liveUsageHint);

  return (
    <>
      <header className="topbar">
        <button className="icon-btn" onClick={onBack} title="返回首页">
          <I.X size={16} />
        </button>
        <div className="topbar-title">
          {session.spec.name}
          <span className="sep">/</span>
          <span className="small">{session.id}</span>
        </div>
        <div className="spacer" />
        <button className="ghost-btn" onClick={() => setPreviewOpen(!previewOpen)}>
          <I.Eye size={14} /> {previewOpen ? "隐藏预览" : "显示预览"}
        </button>
        <button className="ghost-btn">
          <I.Code size={14} /> 导出 DSL
        </button>
        <button className="primary-btn">
          <I.Download size={14} /> 下载全部
        </button>
      </header>

      <div className={"workspace" + (previewOpen ? "" : " no-preview")}>
        <section className="convo">
          <div className="convo-stream">
            <div className="convo-inner">
              {/* Initial user turn */}
              <div className="turn user">
                <div className="bubble-user">{session.prompt}</div>
                <div className="bubble-meta-row">
                  <span className="meta-chip">
                    <I.Sparkle size={12} />
                    {session.style === "pixel" ? "像素" : "PBR"}
                  </span>
                  {session.imageData && (
                    <span className="meta-chip">
                      <img src={session.imageData} alt="ref" />
                      {session.imageName || "参考图"}
                    </span>
                  )}
                </div>
              </div>

              {/* Assistant turn — build progress */}
              <div className="turn assistant">
                <AssistantHead busy={coreBusy} />
                <div className="assistant-body" style={{ width: "100%" }}>
                  {isWorkflowOnly ? (
                    <>
                      <p>
                        上传成功。已载入 <em>{generatedName}</em>
                        {realCubeCount ? <>，共 <span className="ic">{realCubeCount} cubes</span></> : null}
                        {realAnimCount ? <>，已有 <span className="ic">{realAnimCount}</span> 个动画</> : null}
                        {backendCtx?.texture_png_base64 ? <>，已检测到贴图</> : null}。
                      </p>
                      <p style={{ color: "var(--ink-3)" }}>
                        当前是仅{workflowOnlyLabel}工作流。请在下方输入框直接描述要添加的{workflowOnlyLabel}要求。
                      </p>
                    </>
                  ) : (
                    <>
                      {!buildDone ? (
                        <p>明白 — 我先做一只 <em>{session.spec.name}</em>,
                          用 {session.style === "pixel" ? "像素" : "PBR"} 风格。
                          {elapsedLabel && <span className="ic" style={{ marginLeft: 8 }}>⏱ {elapsedLabel}</span>}
                          <span className="caret" /></p>
                      ) : (
                        <p>建模完成{elapsedLabel ? ` （耗时 ${elapsedLabel}）` : ""}。
                          共 <span className="ic">{realCubeCount} cubes</span>。
                        </p>
                      )}
                      <div style={{ marginTop: 12 }}>
                        <StageCard stages={stageRows} />
                      </div>
                      <MeshyProgress progress={meshyProgress} />

                      {/* Live streaming: thinking + output buffers tail. Shown
                           while the pipeline is still running OR an animation
                           job is mid-flight. Auto-scrolls to the bottom. */}
                      {(thoughtBuf || outputBuf) && !buildDone && (
                        <StreamPanel
                          thought={thoughtBuf}
                          output={outputBuf}
                          stageName={
                            activeStageIdx >= 0
                              ? (STAGE_PLAN[activeStageIdx]?.activeName || STAGE_PLAN[activeStageIdx]?.name)
                              : null
                          }
                        />
                      )}

                      {animsBuilding && (thoughtBuf || outputBuf) && (
                        <StreamPanel thought={thoughtBuf} output={outputBuf} stageName="Animator" />
                      )}

                      {buildDone && (
                        <div style={{ marginTop: 14 }}>
                          <SpecCard spec={{
                            name: generatedName,
                            resolution: generatedResolution,
                            symmetric: String(generatedSpec.symmetric ?? "—"),
                            groups: generatedGroups,
                          }} />
                        </div>
                      )}
                    </>
                  )}
                </div>
              </div>

              {/* Backend error banner */}
              {backendErr && (
                <div className="turn assistant">
                  <div className="assistant-body" style={{
                    width: "100%",
                    border: "1px solid var(--danger)",
                    background: "rgba(220,40,40,0.06)",
                    color: "var(--danger)",
                    padding: 12,
                    borderRadius: 12,
                  }}>
                    <strong>后端错误：</strong>{backendErr}
                  </div>
                </div>
              )}
              {/* Build was interrupted by the user. Keep the partial stream
                  and resume only when they explicitly ask to continue. */}
              {interruptedBuild && !buildDone && (
                <div className="turn assistant">
                  <div className="assistant-body" style={{
                    width: "100%",
                    border: "1px solid var(--warn, #d6982a)",
                    background: "rgba(214,152,42,0.08)",
                    padding: 14, borderRadius: 12,
                  }}>
                    <p style={{ marginBottom: 10 }}>
                      生成已中止,已保留当前聊天记录和已收到的输出。
                      {session.ctxPartial ? (
                        <> 我保存了上次跑到 <span className="ic">
                          {session.ctxPartial.bbmodel ? "建模" :
                           session.ctxPartial.model_spec ? "分析" : "?"}
                        </span> 的检查点。直接在输入框发送“继续”,会从这里接着跑（已完成的阶段会跳过）。</>
                      ) : (
                        <> 还没有保存到检查点,发送“继续”会从头重新开始。</>
                      )}
                    </p>
                    <button className="primary-btn" onClick={() => {
                      // Clear started flag (so the effect runs) + interruption
                      // banner state, but KEEP ctxPartial — that's what enables
                      // resume on the backend's run_pipeline.
                      if (typeof onUpdate === "function") {
                        onUpdate({ started: false, interruptedBuild: false, running: true, backendErr: null });
                      }
                      setInterruptedBuild(false);
                      setBackendErr(null);
                      setRestartTick((t) => t + 1);
                    }}>
                      {session.ctxPartial ? "继续构建（从检查点恢复）" : "重新构建"}
                    </button>
                  </div>
                </div>
              )}
              {/* After build — show animation request prompt */}
              {buildDone && !isWorkflowOnly && !animsBuilt && !animsBuilding && followUp}
              {animBuildTurn}
              {exportTurn}
              {/* Refine chat — each follow-up adds a user→assistant pair
                   that stays in the convo so the user has scrollback. */}
              {refineTurnsView}
            </div>
          </div>

          <div className="convo-composer">
            <div className="convo-composer-inner">
              <Composer
                value={followUpPrompt}
                onChange={setFollowUpPrompt}
                imageData={followUpImage}
                imageName={followUpImageName}
                onPickImage={(d, n) => { setFollowUpImage(d); setFollowUpImageName(n); }}
                onClearImage={() => { setFollowUpImage(null); setFollowUpImageName(null); }}
                style={followUpStyle}
                onStyleChange={setFollowUpStyle}
                onSubmit={async () => {
                  // Refine pass — POST /api/stream type=refine. We push
                  // a NEW turn into refineTurns and stream into THAT
                  // turn's buffers so the user sees the response inline
                  // (the global thoughtBuf / outputBuf only render during
                  // the initial build / animation flows).
                  const text = (followUpPrompt || "").trim();
                  if (!text) return;
                  if (requireLogin()) return;
                  const wantsContinue = /(继续|接着|恢复|continue|resume)/i.test(text);
                  if (interruptedBuild && !buildDone) {
                    if (!wantsContinue) {
                      setBackendErr("当前生成已中止。发送“继续”即可从已保存的检查点恢复。");
                      return;
                    }
                    setFollowUpPrompt("");
                    setBackendErr(null);
                    setInterruptedBuild(false);
                    if (typeof onUpdate === "function") {
                      onUpdate({
                        started: false,
                        interruptedBuild: false,
                        running: true,
                        backendErr: null,
                      });
                    }
                    setRestartTick((t) => t + 1);
                    return;
                  }
                  if (!window.MCAPI || !backendCtx) {
                    setBackendErr("无法 refine: 还没有可用的 ctx,请先完成基础建模");
                    return;
                  }
                  if (isWorkflowOnly) {
                    await runWorkflowOnlyTurn(text);
                    return;
                  }
                  const turnId = "r_" + Date.now().toString(36)
                                 + Math.random().toString(36).slice(2, 6);
                  setFollowUpPrompt("");
                  setRefineTurns((prev) => [...prev,
                    { id: turnId, prompt: text, thought: "", output: "",
                      status: "running", error: null, intent: null }]);
                  setActiveRefineId(turnId);
                  const updateTurn = (patch) =>
                    setRefineTurns((prev) => prev.map((t) =>
                      t.id === turnId ? { ...t, ...(typeof patch === "function" ? patch(t) : patch) } : t));
                  let lastAnimOutput = null;
                  let lastTextureBytes = 0;
                  let needsConfirm = null;  // populated if backend pauses
                  const refineController = new AbortController();
                  refineAbortRef.current = refineController;
                  try {
                    const newCtx = await window.MCAPI.streamRefine(
                      {
                        ctx: backendCtx,
                        user_prompt: text,
                        confirm: false,
                        // Reference image flows through so a refine like
                        // "更接近图里的金属感" or "换成图中那种风格" can
                        // be honored even if the user attached the image
                        // mid-conversation.
                        image: followUpImage || null,
                      },
                      (ev) => {
                        if (handleMeterEvent(ev, (chunk) =>
                          updateTurn((t) => ({ output: clipTail((t.output || "") + chunk) })))) return;
                        if (ev.type === "thought_delta") {
                          updateTurn((t) => ({ thought: clipTail((t.thought || "") + (ev.delta || "")) }));
                        } else if (
                          ev.type === "coder_stream" || ev.type === "analyst_stream"
                          || ev.type === "material_stream" || ev.type === "animator_stream"
                          || ev.type === "adjuster_stream"
                        ) {
                          updateTurn((t) => ({ output: clipTail((t.output || "") + (ev.delta || "")) }));
                        } else if (ev.type === "intent_classified") {
                          updateTurn({ intent: ev.intent || ev.decision || null });
                        } else if (ev.type === "needs_confirmation") {
                          needsConfirm = {
                            reason: ev.reason,
                            message: ev.message,
                            has_texture: !!ev.has_texture,
                            animation_count: ev.animation_count || 0,
                            animation_names: ev.animation_names || [],
                          };
                        } else if (ev.type === "animator_output" && ev.animation) {
                          lastAnimOutput = ev.animation;
                        } else if (ev.type === "texturer_done") {
                          lastTextureBytes = ev.size_bytes || 0;
                        } else if (ev.type === "warn") {
                          updateTurn((t) => ({ output: clipTail((t.output || "") + `\n[warn:${ev.stage}] ${ev.message}\n`) }));
                        } else if (ev.type === "error") {
                          updateTurn({ status: "error", error: ev.message || "refine error" });
                        }
                      },
                      { signal: refineController.signal },
                    );
                    if (newCtx) {
                      setBackendCtx(newCtx);
                      if (typeof onUpdate === "function") onUpdate({ ctx: newCtx });
                    }
                    if (needsConfirm) {
                      // Backend paused — surface the warning + confirm/cancel buttons
                      // and stop. The user's click on 继续 will run the second pass
                      // via runConfirmedRefine() below.
                      updateTurn({
                        status: "awaiting_confirm",
                        confirmInfo: needsConfirm,
                      });
                      return;
                    }
                    // Build a one-line summary the user can read at a glance
                    // — intent + the most useful artifact each branch produces.
                    let summary = null;
                    const animCount = newCtx?.bbmodel?.animations?.length;
                    if (lastAnimOutput) {
                      summary = `新增动画 “${lastAnimOutput.name}”（${lastAnimOutput.bones_animated} 骨, ${lastAnimOutput.keyframe_count} 关键帧），共 ${animCount || "?"} 个动画`;
                    } else if (lastTextureBytes) {
                      summary = `贴图已重生（${Math.round(lastTextureBytes / 1024)} KB）`;
                    } else if (newCtx?.bbmodel?.elements?.length) {
                      summary = `已应用，${newCtx.bbmodel.elements.length} cubes`;
                    }
                    updateTurn((t) => ({
                      status: t.status === "error" ? "error" : "done",
                      summary,
                      addedAnimation: !!lastAnimOutput,
                    }));
                  } catch (e) {
                    if (isAbortError(e) || refineController.signal.aborted) {
                      updateTurn((t) => ({
                        status: "done",
                        summary: "已中止，已保留当前输出。",
                        stopped: true,
                      }));
                      return;
                    }
                    updateTurn({ status: "error", error: String(e && e.message || e) });
                  } finally {
                    if (refineAbortRef.current === refineController) {
                      refineAbortRef.current = null;
                    }
                    setActiveRefineId(null);
                  }
                }}
                busy={sessionBusy}
                onStop={stopCurrentProcess}
                showStyle={!isWorkflowOnly || workflowMode === "texture"}
                placeholder={interruptedBuild && !buildDone
                  ? "发送“继续”即可从已保存的检查点恢复"
                  : isWorkflowOnly
                    ? (workflowMode === "texture"
                        ? "描述贴图建议，例如 “暗夜金属质感，蓝色发光纹”"
                        : "描述动画建议，例如 “走路 4 向，循环，步伐有重量”")
                    : "继续提要求,比如 “牙齿再大一点” 或 “加一个起跳动画@4 向”"}
              />
              <QuotaWindow
                refreshKey={usageRefreshKey}
                sessionId={currentSessionId}
                sessionCostUsd={currentSessionCostUsd}
                enabled={!!authUser}
              />
            </div>
          </div>
        </section>

        {previewOpen && onResizePreview && (
          <Resizer onResize={onResizePreview} />
        )}
        {previewOpen && (
          <PreviewPane
            session={{
              model: session.model,
              spec: session.spec,
              cubeCount: session.cubeCount,
              tris: session.cubeCount * 12,
              anims: animsBuilt ? anims.map((a) => a.name) : ["pose"],
              // ctx is what PreviewPane needs to render REAL cubes /
              // texture / DSL — without it the preview falls back to
              // the canned MODELS lookup. Prefer the live `backendCtx`
              // (just-resolved from the stream) and fall back to the
              // session's persisted ctx for already-completed sessions.
              ctx: backendCtx || session.ctx || null,
            }}
            onClose={() => setPreviewOpen(false)}
          />
        )}
      </div>
    </>
  );
}

// ---------- Settings · Models ----------
const MODEL_CATALOG = [
  { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", vendor: "Google", tag: "PRO", desc: "推理最强,上下文 1M", added: true },
  { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", vendor: "Google", tag: "FAST", desc: "极速、低延迟,适合 spec 解析", added: true },
  { id: "claude-opus-4.7", name: "Claude Opus 4.7", vendor: "Anthropic", tag: "OPUS", desc: "代码与结构化输出最稳", added: true },
  { id: "claude-sonnet-4.7", name: "Claude Sonnet 4.7", vendor: "Anthropic", tag: "SON", desc: "性价比之选", added: false },
  { id: "gpt-5.5-xhigh-3", name: "GPT 5.5 xHigh", vendor: "OpenAI", tag: "XHI", desc: "复杂提示分解能力强", added: true },
  { id: "gpt-5-mini", name: "GPT 5 mini", vendor: "OpenAI", tag: "MINI", desc: "轻量、高吞吐", added: false },
  { id: "deepseek-v4", name: "DeepSeek V4", vendor: "DeepSeek", tag: "V4", desc: "开源、成本极低", added: false },
  { id: "deepseek-r2", name: "DeepSeek R2", vendor: "DeepSeek", tag: "R2", desc: "推理特化,适合复杂 DSL", added: false },
];

// ---------- Settings (multi-section) ----------
const SETTINGS_TABS = [
  { id: "account", label: "账户" },
  { id: "quota", label: "额度" },
  { id: "models", label: "模型" },
  { id: "notifications", label: "通知" },
  { id: "personalization", label: "个性化" },
];

function SettingsPage({ onBack }) {
  const [tab, setTab] = useState(() => {
    const h = window.location.hash;
    const m = h.match(/#\/settings\/(\w+)/);
    return m && SETTINGS_TABS.find((t) => t.id === m[1]) ? m[1] : "account";
  });
  useEffect(() => { window.location.hash = `#/settings/${tab}`; }, [tab]);

  return (
    <>
      <header className="topbar">
        <button className="ghost-btn" onClick={onBack}>
          <I.ChevronRight size={14} style={{ transform: "rotate(180deg)" }} /> 返回
        </button>
        <div className="topbar-title">设置 <span className="sep">·</span> <span style={{ color: "var(--ink-3)" }}>{SETTINGS_TABS.find((t) => t.id === tab)?.label}</span></div>
        <div className="spacer" />
      </header>
      <div className="settings-shell">
        <nav className="settings-nav">
          {SETTINGS_TABS.map((t) => (
            <button
              key={t.id}
              className={"settings-nav-item" + (tab === t.id ? " active" : "")}
              onClick={() => setTab(t.id)}
            >
              {t.label}
            </button>
          ))}
        </nav>
        <div className="settings-content">
          {tab === "account" && <AccountSection />}
          {tab === "quota" && <QuotaSection />}
          {tab === "models" && <ModelsSection />}
          {tab === "notifications" && <NotificationsSection />}
          {tab === "personalization" && <PersonalizationSection />}
        </div>
      </div>
    </>
  );
}

function AccountSection() {
  // Pull the real user from /api/auth/me. Settings is gated by the
  // outer App auth check, but cookies may have expired by the time
  // we land here; fall through to "未登录" if so.
  const [user, setUser] = useState(null);
  useEffect(() => {
    (async () => {
      try {
        const r = await window.MCAPI.auth.me();
        if (r && r.authenticated) setUser(r);
      } catch {}
    })();
  }, [refreshKey]);

  const onUpgrade = () => { window.location.hash = "#/upgrade"; };
  const onInvite = () => {
    const email = prompt("输入邀请的邮箱（团队功能暂未开通，此邮箱仅记录到候补名单）:", "");
    if (email && email.trim()) {
      alert("已记录到候补名单 — 真实邀请功能等多工作室协作后端上线。");
    }
  };
  const onDelete = async () => {
    if (!confirm("确认永久注销账户？所有本地资产保留。")) return;
    if (!confirm("再确认一次：注销后无法恢复账号本身。")) return;
    try {
      await window.MCAPI.auth.deleteAccount();
      alert("账户已注销。");
      window.location.hash = "#/login";
    } catch (e) {
      alert("注销失败: " + (e && e.message || ""));
    }
  };

  if (!user) {
    return (
      <section className="settings-section">
        <h1>账户</h1>
        <p className="settings-desc">还未登录—— <a href="#/login">点这里登录或注册</a>。</p>
      </section>
    );
  }
  return (
    <section className="settings-section">
      <h1>账户</h1>
      <p className="settings-desc">管理身份与登录方式。</p>
      <div className="settings-card">
        <div className="settings-row">
          <label>用户名</label>
          <div className="settings-static">{user.username}</div>
        </div>
        <div className="settings-row">
          <label>用户 ID</label>
          <div className="settings-static" style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
            {user.id || "—"}
          </div>
        </div>
        <div className="settings-row">
          <label>计划</label>
          <div className="settings-static">
            <span className="plan-pill">Free</span>
            <span style={{ color: "var(--ink-4)", fontSize: 12 }}>本地账号</span>
            <button className="ghost-btn" style={{ marginLeft: "auto" }} onClick={onUpgrade}>
              升级到 Studio
            </button>
          </div>
        </div>
        <div className="settings-row">
          <label>团队席位</label>
          <div className="settings-static">
            未启用（多工作室后端待上线）
            <button className="ghost-btn" style={{ marginLeft: "auto" }} onClick={onInvite}>
              邀请成员
            </button>
          </div>
        </div>
      </div>
      <div className="settings-card danger">
        <div className="settings-row">
          <label>注销账户</label>
          <div className="settings-static">
            永久删除账户 — 本地 sessions / community 不受影响
            <button className="ghost-btn danger" style={{ marginLeft: "auto" }} onClick={onDelete}>
              注销…
            </button>
          </div>
        </div>
      </div>
    </section>
  );
}

function QuotaSection() {
  // Real usage counters from /api/billing/usage. The server has a
  // single lifetime counter (LLM + Meshy calls); we display it
  // alongside a static plan cap (Free = 200/month) for context.
  const [usage, setUsage] = useState(null);
  useEffect(() => {
    (async () => {
      try { setUsage(await window.MCAPI.billing.usage()); } catch {}
    })();
  }, []);
  const quota = usage?.quota || {};
  const lifetime = usage?.lifetime || {};
  const liveUsage = usage?.live || {};
  const quotaUsd = quota.quota_usd ?? 0;
  const usedUsd = quota.used_usd ?? lifetime.total_cost_usd ?? lifetime.est_cost_usd ?? 0;
  const totalTokens = lifetime.total_tokens || 0;
  const liveUsd = liveUsage.total_cost_usd || 0;

  // Settings switches → localStorage
  const [autoRecharge, setAutoRecharge] = useState(() => lsLoad("MCSKIN_AUTO_RECHARGE_v1", false));
  const [usageAlert, setUsageAlert] = useState(() => lsLoad("MCSKIN_USAGE_ALERT_v1", true));
  useEffect(() => { lsSaveSafely("MCSKIN_AUTO_RECHARGE_v1", autoRecharge); }, [autoRecharge]);
  useEffect(() => { lsSaveSafely("MCSKIN_USAGE_ALERT_v1", usageAlert); }, [usageAlert]);

  return (
    <section className="settings-section">
      <h1>额度</h1>
      <p className="settings-desc">真实 LLM token、Meshy 成本和美元额度统计。</p>

      <div className="quota-block">
        <div className="quota-block-head">
          <div>
            <div className="quota-block-title">美元额度</div>
            <div className="quota-block-sub">本进程 ${Number(liveUsd).toFixed(4)}（重启清零）</div>
          </div>
          <div className="quota-block-num">
            <b>${Number(usedUsd).toFixed(4)}</b> <span>/ ${Number(quotaUsd).toFixed(2)}</span>
          </div>
        </div>
        <div className="quota-progress">
          <div className="quota-progress-fill" style={{ width: (quotaUsd ? Math.min(100, usedUsd / quotaUsd * 100) : 0) + "%" }} />
        </div>
      </div>

      <div className="quota-block">
        <div className="quota-block-head">
          <div>
            <div className="quota-block-title">Token 与成本</div>
            <div className="quota-block-sub">
              LLM {Number(lifetime.llm_total_tokens || 0).toLocaleString()} tokens ·
              Image 等价 {Number(lifetime.image_equiv_tokens || 0).toLocaleString()} tokens ·
              Meshy 等价 {Number(lifetime.meshy_equiv_tokens || 0).toLocaleString()} tokens
            </div>
          </div>
          <div className="quota-block-num">
            <b>{Number(totalTokens).toLocaleString()}</b> <span>tokens</span>
          </div>
        </div>
      </div>

      <div className="settings-card">
        <div className="settings-row"><label>自动充值</label>
          <div className="settings-static">
            额度低于 10% 时自动购买 5,000 credits（真实充值未开通）
            <Toggle defaultOn={autoRecharge} onChange={setAutoRecharge} />
          </div>
        </div>
        <div className="settings-row"><label>用量提醒</label>
          <div className="settings-static">
            额度耗尽 80% 时邮件提醒
            <Toggle defaultOn={usageAlert} onChange={setUsageAlert} />
          </div>
        </div>
        <div className="settings-row"><label>查看明细</label>
          <div className="settings-static">
            逐条交易记录 + 模型分布
            <button className="ghost-btn" style={{ marginLeft: "auto" }}
                    onClick={() => { window.location.hash = "#/billing"; }}>
              打开账单
            </button>
          </div>
        </div>
      </div>
    </section>
  );
}

function ModelsSection() {
  // Real LLM model catalog. The "active" model is what the backend
  // will use on the next pipeline call — we POST /api/admin/llm_model
  // immediately when the user clicks Activate. The UI also lets the
  // user mark models as "added to my catalog" (per-user UI preference,
  // localStorage only — pure aesthetic).
  const [activeModel, setActiveModel] = useState(null);
  const [available, setAvailable] = useState([]);
  const [enabled, setEnabled] = useState(() => lsLoad("MCSKIN_MODELS_ENABLED_v1", []));

  useEffect(() => {
    (async () => {
      try {
        const r = await window.MCAPI.admin.getLlmModel();
        setActiveModel(r.model);
        setAvailable(r.available || []);
      } catch {}
    })();
  }, []);

  useEffect(() => { lsSaveSafely("MCSKIN_MODELS_ENABLED_v1", enabled); }, [enabled]);

  const enabledSet = new Set(enabled);
  const toggle = (id) => {
    setEnabled((p) => (p.includes(id) ? p.filter((x) => x !== id) : [...p, id]));
  };
  const activate = async (id) => {
    try {
      const r = await window.MCAPI.admin.setLlmModel(id);
      setActiveModel(r.model);
    } catch (e) {
      alert("切换失败: " + (e && e.message || ""));
    }
  };

  // Group by vendor heuristic.
  const grouped = useMemo(() => {
    const map = {};
    for (const id of available) {
      const vendor = id.startsWith("gemini") ? "Google" : id.startsWith("claude") ? "Anthropic" : "Other";
      (map[vendor] = map[vendor] || []).push(id);
    }
    return Object.entries(map);
  }, [available]);

  return (
    <section className="settings-section">
      <h1>模型</h1>
      <p className="settings-desc">
        当前活动模型: <code style={{ background: "var(--bg-soft)", padding: "1px 6px",
          borderRadius: 4 }}>{activeModel || "—"}</code>。
        切换会**立即**生效在后端运行时（影响下一次管线调用）。
      </p>
      {grouped.map(([vendor, items]) => (
        <div key={vendor} className="vendor-group">
          <div className="vendor-head">{vendor}</div>
          <div className="model-list">
            {items.map((id) => {
              const isActive = id === activeModel;
              const isEnabled = enabledSet.has(id);
              return (
                <div key={id} className={"model-row" + (isActive ? " added" : "")}>
                  <span className="model-row-tag">
                    {id.includes("flash") ? "FAST" : id.includes("haiku") ? "FAST" :
                     id.includes("opus") ? "PRO" : id.includes("3.1") ? "PRO" : "STD"}
                  </span>
                  <div className="model-row-meta">
                    <div className="model-row-name">{id}</div>
                    <div className="model-row-desc">
                      {isActive && <span style={{ color: "var(--good)" }}>● 当前活动</span>}
                      {!isActive && (isEnabled ? "已加入快捷选择" : "未加入快捷选择")}
                    </div>
                  </div>
                  {!isActive && (
                    <button className="primary-btn" onClick={() => activate(id)}
                            style={{ marginRight: 6 }}>
                      切换为
                    </button>
                  )}
                  <button className={isEnabled ? "ghost-btn" : "ghost-btn"}
                          onClick={() => toggle(id)}>
                    {isEnabled ? <><I.Check size={13} /> 已添加</> : "+ 添加"}
                  </button>
                </div>
              );
            })}
          </div>
        </div>
      ))}
    </section>
  );
}

function NotificationsSection() {
  // Each row gets its own localStorage key so we don't have to schema
  // up an "all notifs" object. Browser-only — server doesn't actually
  // act on these (no SMTP, no push) — they record intent for when the
  // notification backend ships.
  return (
    <section className="settings-section">
      <h1>通知</h1>
      <p className="settings-desc">
        控制 MeshForge 在哪些事件发生时通知你。
        <br />
        <span style={{ fontSize: 11, color: "var(--ink-4)" }}>
          注意：通知后端尚未对接邮件 / 推送服务，开关仅本地记录意向。
        </span>
      </p>
      <div className="settings-card">
        <NotifRow lsKey="MCSKIN_NOTIF_BUILD_DONE_v1" label="生成完成" desc="模型/贴图/动画跑完后" defaultOn />
        <NotifRow lsKey="MCSKIN_NOTIF_ERROR_v1" label="生成失败" desc="任意阶段报错" defaultOn />
        <NotifRow lsKey="MCSKIN_NOTIF_LIKED_v1" label="社区作品被收藏" desc="你的作品被点赞或收藏时" />
        <NotifRow lsKey="MCSKIN_NOTIF_WEEKLY_v1" label="周报" desc="每周一汇总本周用量" defaultOn />
        <NotifRow lsKey="MCSKIN_NOTIF_FEATURES_v1" label="新功能上线" desc="MeshForge 新模型、新动画类型" />
      </div>
      <div className="settings-card">
        <div className="settings-row"><label>通知渠道</label>
          <div className="settings-static" style={{ flexDirection: "column", alignItems: "stretch", gap: 6 }}>
            <ChannelRow lsKey="MCSKIN_CHAN_INAPP_v1" label="站内推送" defaultOn />
            <ChannelRow lsKey="MCSKIN_CHAN_EMAIL_v1" label="邮件" defaultOn />
            <ChannelRow lsKey="MCSKIN_CHAN_WEBHOOK_v1" label="飞书 Webhook" />
          </div>
        </div>
      </div>
    </section>
  );
}

function NotifRow({ lsKey, label, desc, defaultOn }) {
  const [on, setOn] = useState(() => lsLoad(lsKey, !!defaultOn));
  useEffect(() => { lsSaveSafely(lsKey, on); }, [on, lsKey]);
  return (
    <div className="settings-row">
      <label>{label}<span className="settings-row-desc">{desc}</span></label>
      <Toggle defaultOn={on} onChange={setOn} />
    </div>
  );
}

function ChannelRow({ lsKey, label, defaultOn }) {
  const [on, setOn] = useState(() => lsLoad(lsKey, !!defaultOn));
  useEffect(() => { lsSaveSafely(lsKey, on); }, [on, lsKey]);
  return (
    <div className="channel-row">
      <span>{label}</span>
      <Toggle defaultOn={on} onChange={setOn} />
    </div>
  );
}

function PersonalizationSection() {
  // Theme + density truly apply to the page (data-theme attr, density
  // CSS hook). Default style + lang are remembered for new sessions
  // and shown to users; lang doesn't switch UI strings yet (i18n is
  // a future job — see plan note).
  const [theme, setTheme] = useState(() => lsLoad("MCSKIN_THEME_v1", "light"));
  const [density, setDensity] = useState(() => lsLoad("MCSKIN_DENSITY_v1", "comfortable"));
  const [defaultStyle, setDefaultStyle] = useState(() => lsLoad("MCSKIN_DEFAULT_STYLE_v1", "pixel"));
  const [lang, setLang] = useState(() => lsLoad("MCSKIN_LANG_v1", "zh-CN"));
  const [autoRotate, setAutoRotate] = useState(() => lsLoad("MCSKIN_AUTO_ROTATE_v1", true));
  const [chime, setChime] = useState(() => lsLoad("MCSKIN_CHIME_v1", false));
  const [shortcuts, setShortcuts] = useState(() => lsLoad("MCSKIN_SHORTCUTS_v1", true));

  // Apply the theme on every change. We also write data-density so
  // any future CSS rule that needs it can read it.
  useEffect(() => {
    lsSaveSafely("MCSKIN_THEME_v1", theme);
    const resolved = theme === "auto"
      ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
      : theme;
    document.documentElement.setAttribute("data-theme", resolved);
  }, [theme]);
  useEffect(() => {
    lsSaveSafely("MCSKIN_DENSITY_v1", density);
    document.documentElement.setAttribute("data-density", density);
  }, [density]);
  useEffect(() => { lsSaveSafely("MCSKIN_DEFAULT_STYLE_v1", defaultStyle); }, [defaultStyle]);
  useEffect(() => { lsSaveSafely("MCSKIN_LANG_v1", lang); }, [lang]);
  useEffect(() => { lsSaveSafely("MCSKIN_AUTO_ROTATE_v1", autoRotate); }, [autoRotate]);
  useEffect(() => { lsSaveSafely("MCSKIN_CHIME_v1", chime); }, [chime]);
  useEffect(() => { lsSaveSafely("MCSKIN_SHORTCUTS_v1", shortcuts); }, [shortcuts]);

  return (
    <section className="settings-section">
      <h1>个性化</h1>
      <p className="settings-desc">让 MeshForge 看起来、用起来更顺手。</p>
      <div className="settings-card">
        <div className="settings-row">
          <label>主题</label>
          <SegRow value={theme} onChange={setTheme} options={[
            { v: "light", l: "浅色" }, { v: "dark", l: "深色" }, { v: "auto", l: "跟随系统" },
          ]} />
        </div>
        <div className="settings-row">
          <label>密度</label>
          <SegRow value={density} onChange={setDensity} options={[
            { v: "compact", l: "紧凑" }, { v: "comfortable", l: "舒适" },
          ]} />
        </div>
        <div className="settings-row">
          <label>默认风格</label>
          <SegRow value={defaultStyle} onChange={setDefaultStyle} options={[
            { v: "pixel", l: "像素 (mc)" }, { v: "pbr", l: "PBR (写实)" },
          ]} />
        </div>
        <div className="settings-row">
          <label>语言</label>
          <SegRow value={lang} onChange={setLang} options={[
            { v: "zh-CN", l: "简体中文" }, { v: "en", l: "English" },
          ]} />
        </div>
      </div>
      <div className="settings-card">
        <div className="settings-row"><label>预览自动旋转</label>
          <div className="settings-static">
            在 3D 预览中持续旋转模型
            <Toggle defaultOn={autoRotate} onChange={setAutoRotate} />
          </div>
        </div>
        <div className="settings-row"><label>提示音</label>
          <div className="settings-static">
            生成完成时播放提示音
            <Toggle defaultOn={chime} onChange={setChime} />
          </div>
        </div>
        <div className="settings-row"><label>显示快捷键</label>
          <div className="settings-static">
            按 <span className="kbd-inline">?</span> 展开快捷键面板
            <Toggle defaultOn={shortcuts} onChange={setShortcuts} />
          </div>
        </div>
      </div>
    </section>
  );
}

function Toggle({ defaultOn = false, onChange }) {
  // Controllable + uncontrolled hybrid. When `onChange` is given, the
  // parent owns persistence; defaultOn re-runs only on toggle changes
  // to the local mirror state to avoid React warning.
  const [on, setOn] = useState(defaultOn);
  useEffect(() => { setOn(defaultOn); }, [defaultOn]);
  const flip = () => {
    setOn((v) => {
      const next = !v;
      if (typeof onChange === "function") onChange(next);
      return next;
    });
  };
  return (
    <button
      className={"toggle" + (on ? " on" : "")}
      onClick={flip}
      style={{ marginLeft: "auto" }}
      role="switch"
      aria-checked={on}
    >
      <span className="toggle-knob" />
    </button>
  );
}

function SegRow({ value, onChange, options }) {
  return (
    <div className="seg-row">
      {options.map((o) => (
        <button
          key={o.v}
          className={"seg-btn" + (value === o.v ? " active" : "")}
          onClick={() => onChange(o.v)}
        >{o.l}</button>
      ))}
    </div>
  );
}

const VFX_ELEMENT_LABELS = {
  fire: "火焰",
  frost: "冰霜",
  storm: "雷电",
  dark: "暗影",
  utility: "通用",
};

const VFX_CATEGORY_LABELS = {
  projectile: "弹道",
  melee: "近战",
  area: "范围",
  direct_effect: "直击",
  effect: "效果",
};

const VFX_ROLE_LABELS = {
  core: "核心",
  trail: "轨迹",
  impact: "命中",
  decal: "地表",
};

const VFX_STEP_LABELS = {
  workspace: "进入管线",
  describe: "描述转规格",
  generate: "生成贴图",
  postprocess: "后处理",
  mount: "挂载网页",
};

let VFX_FALLBACK_STYLES = [
  { id: "ink_fire", name: "墨线火焰", element: "fire", category: "projectile", tone: "爆发,弹道,冲击", description: "黑色手绘描边,高饱和橙红火焰,适合火球和爆炸。", prompt: "hand-drawn jagged ink outline, vivid orange fire, crimson embers" },
  { id: "ember_burst", name: "余烬爆裂", element: "fire", category: "area", tone: "爆炸,碎片,冲击波", description: "中心爆开,带火星碎片和短促速度线。", prompt: "radial ember burst, angular flame shards, black scratch marks" },
  { id: "frost_slash", name: "冰霜月弧", element: "frost", category: "melee", tone: "挥砍,弧线,冰晶", description: "横向冰刃弧光,适合攻击轨迹和近战命中。", prompt: "icy crescent slash, cyan crystal shards, left-to-right melee arc" },
  { id: "crystal_shatter", name: "晶体破碎", element: "frost", category: "area", tone: "冰裂,碎片,冻结", description: "冰晶聚合后碎裂,适合冻结或破防效果。", prompt: "frost crystal shatter, angular blue shards, snow particles" },
  { id: "storm_arc", name: "雷弧缠绕", element: "storm", category: "effect", tone: "电弧,持续,麻痹", description: "细碎电弧和紫白能量,适合持续触电。", prompt: "electric arc coils, violet white plasma sparks, jagged lightning branches" },
  { id: "thunder_strike", name: "雷击落点", element: "storm", category: "direct_effect", tone: "直击,闪电,爆闪", description: "纵向雷击和落点扩散,适合从天而降的打击。", prompt: "vertical thunder strike impact, branching lightning, bright core" },
  { id: "skull_hex", name: "骷髅咒焰", element: "dark", category: "area", tone: "暗影,诅咒,爆弹", description: "暗色烟团和骷髅轮廓,适合诅咒炸弹。", prompt: "dark skull bomb, smoky charcoal burst, sickly green sparks" },
  { id: "shadow_tendrils", name: "暗影触须", element: "dark", category: "effect", tone: "缠绕,生长,收束", description: "从地面伸出暗影触须,适合控制和束缚。", prompt: "shadow tendrils rising from ground, deep purple fragments" },
  { id: "holy_flash", name: "圣光闪爆", element: "utility", category: "direct_effect", tone: "净化,闪光,命中", description: "高对比放射闪光,适合治疗、净化或神圣命中。", prompt: "radiant holy flash, gold white angular burst, crisp black outline" },
  { id: "poison_spore", name: "毒孢云雾", element: "dark", category: "area", tone: "毒雾,扩散,持续", description: "绿色孢子和低饱和暗烟,适合范围毒伤。", prompt: "poison spore cloud, sickly green bubbles, dark smoke outline" },
  { id: "arcane_rune", name: "奥术符文", element: "storm", category: "effect", tone: "符文,蓄力,法阵", description: "几何符号和能量碎片,适合施法蓄力。", prompt: "arcane rune circle, angular glyph fragments, electric blue purple glow" },
  { id: "impact_dust", name: "落地烟尘", element: "utility", category: "area", tone: "冲击,尘土,落地", description: "短促地表冲击,适合脚步、砸地和落点反馈。", prompt: "ground impact dust puff, outlined debris chunks, compact radial expansion" },
];

VFX_FALLBACK_STYLES = [
  {
    id: "ink_outline",
    name: "黑墨描边",
    tone: "高对比,手绘,硬边",
    tags: ["线稿", "高对比", "清晰轮廓"],
    description: "用粗黑轮廓和手绘断线统一任何特效形态。",
    prompt: "visual style only: bold expressive black ink contour, hand-drawn broken-line energy, high contrast silhouette, controlled rough edges, crisp negative space, no change to requested VFX element or action",
    swatch: ["#111111", "#f46b2f", "#f7f4ea"],
  },
  {
    id: "pixel_clean",
    name: "像素清晰",
    tone: "低分辨率,硬边,有限色",
    tags: ["像素", "硬边", "低分辨率"],
    description: "有限色阶和硬边块面,适合游戏内小尺寸读取。",
    prompt: "visual style only: pixel-art readable VFX, hard square edges, limited palette clusters, deliberate stepped motion, no soft blur, optimized for tiny in-game display, preserve requested VFX content",
    swatch: ["#202020", "#35a7ff", "#ffd166"],
  },
  {
    id: "anime_glow",
    name: "动画高光",
    tone: "赛璐璐,亮边,速度线",
    tags: ["赛璐璐", "亮边", "动势"],
    description: "干净块面、亮边和速度线,让动作感更强。",
    prompt: "visual style only: cel-shaded anime VFX, clean flat color blocks, sharp rim highlights, confident speed lines, energetic impact shapes, preserve requested VFX content and timing",
    swatch: ["#0f172a", "#38bdf8", "#fef08a"],
  },
  {
    id: "neon_arcane",
    name: "霓光奥术",
    tone: "发光,符文感,冷暖渐变",
    tags: ["霓光", "奥术", "发光"],
    description: "霓虹亮边和奥术质感,不限定具体元素类型。",
    prompt: "visual style only: neon arcane rendering, cyan-magenta luminous edges, controlled bloom, glyph-like energy accents only when useful, crisp magical silhouettes, preserve requested VFX content",
    swatch: ["#111827", "#22d3ee", "#d946ef"],
  },
  {
    id: "soft_smoke",
    name: "柔化烟雾",
    tone: "半透明,扩散,颗粒",
    tags: ["烟雾", "柔化", "扩散"],
    description: "轻柔半透明边缘和颗粒层次,适合缓慢消散的质感。",
    prompt: "visual style only: soft translucent smoke treatment, layered wisps, airy particle diffusion, low-noise edges, readable silhouette, preserve requested VFX content and motion",
    swatch: ["#374151", "#9ca3af", "#f3f4f6"],
  },
  {
    id: "crystal_prism",
    name: "晶体折射",
    tone: "棱面,碎片,折射",
    tags: ["晶体", "棱面", "折射"],
    description: "用棱面切割和高光碎片塑造硬质能量感。",
    prompt: "visual style only: prismatic faceted treatment, sharp polygonal highlights, translucent shard edges, refractive color breaks, clean high contrast, preserve requested VFX content",
    swatch: ["#0e7490", "#67e8f9", "#ffffff"],
  },
  {
    id: "golden_flash",
    name: "金白闪耀",
    tone: "金白,锐利,神圣",
    tags: ["金白", "闪耀", "锐利"],
    description: "金白高光和锐利星芒,用于明亮、正向的视觉语言。",
    prompt: "visual style only: gold-white radiant treatment, sharp starburst highlights, clean sacred luminosity, premium fantasy readability, preserve requested VFX content and action",
    swatch: ["#92400e", "#facc15", "#fff7ed"],
  },
  {
    id: "toxic_acid",
    name: "酸蚀毒性",
    tone: "荧绿,气泡,腐蚀",
    tags: ["酸蚀", "荧绿", "气泡"],
    description: "荧绿色、气泡和腐蚀边缘,只控制质感不指定特效种类。",
    prompt: "visual style only: fluorescent toxic acid treatment, bubbling particles, corroded edge shapes, sickly green accents, dark readable outline, preserve requested VFX content",
    swatch: ["#132a13", "#7ddf64", "#d9f99d"],
  },
  {
    id: "shadow_noir",
    name: "暗影剪影",
    tone: "低明度,剪影,紫黑",
    tags: ["暗影", "剪影", "紫黑"],
    description: "低明度剪影和紫黑碎片,适合阴影系视觉表达。",
    prompt: "visual style only: dark noir shadow treatment, strong black-violet silhouette, smoky edge breakup, restrained highlights, ominous but readable shape language, preserve requested VFX content",
    swatch: ["#09090b", "#581c87", "#a78bfa"],
  },
  {
    id: "retro_arcade",
    name: "街机复古",
    tone: "高饱和,简洁,复古",
    tags: ["街机", "复古", "高饱和"],
    description: "高饱和纯色和简洁节奏,偏街机游戏反馈。",
    prompt: "visual style only: retro arcade VFX, saturated chunky shapes, punchy timing cues, minimal gradients, bold readable silhouette, preserve requested VFX content",
    swatch: ["#1d4ed8", "#ef4444", "#f97316"],
  },
  {
    id: "paper_cut",
    name: "剪纸层叠",
    tone: "层叠,纸感,平面",
    tags: ["剪纸", "层叠", "平面"],
    description: "平面纸片层叠和干净阴影,形成手工质感。",
    prompt: "visual style only: layered paper-cutout treatment, flat stacked shapes, subtle paper texture, clean cast-shadow separation, preserve requested VFX content",
    swatch: ["#14532d", "#f9a8d4", "#fef3c7"],
  },
  {
    id: "minimal_readable",
    name: "极简可读",
    tone: "少细节,强轮廓,小尺寸",
    tags: ["极简", "强轮廓", "小尺寸"],
    description: "减少噪点和碎细节,优先保证缩小后的可读性。",
    prompt: "visual style only: minimal readable VFX, reduced noise, strong silhouette, restrained detail, clear motion beats, optimized for small in-game display, preserve requested VFX content",
    swatch: ["#18181b", "#e5e7eb", "#22c55e"],
  },
];

VFX_FALLBACK_STYLES = [
  {
    id: "realistic",
    name: "写实",
    tone: "realistic, cinematic, physically plausible",
    tags: ["写实", "真实光影", "粒子"],
    description: "真实光照、能量衰减和粒子层次。",
    prompt: "visual style only: realistic game VFX, physically plausible light scattering, volumetric energy, natural sparks and smoke, detailed but readable particles",
    swatch: ["#1f2937", "#f8fafc", "#f59e0b"],
  },
  {
    id: "pixel",
    name: "像素",
    tone: "pixel art, hard edges, limited palette",
    tags: ["像素", "硬边", "有限色"],
    description: "像素化硬边和有限色阶。",
    prompt: "visual style only: pixel-art readable VFX, hard square edges, limited palette clusters, stepped animation",
    swatch: ["#111827", "#38bdf8", "#facc15"],
  },
  {
    id: "cartoon",
    name: "卡通",
    tone: "cartoon, cel-shaded, bold readable",
    tags: ["卡通", "亮边", "动势"],
    description: "卡通块面、清晰轮廓和夸张动势。",
    prompt: "visual style only: stylized cartoon VFX, cel-shaded flat color blocks, bold clean outlines, exaggerated readable shapes",
    swatch: ["#0f172a", "#f97316", "#fef08a"],
  },
  {
    id: "custom",
    name: "自定义",
    tone: "user custom style, auto translated",
    tags: ["自定义", "自动翻译", "仅风格"],
    description: "输入自己的风格描述，系统自动转换为英文视觉 prompt。",
    prompt: "visual style only: user custom VFX style, auto translated into concise English visual instructions",
    swatch: ["#18181b", "#a855f7", "#22c55e"],
    requires_input: true,
  },
];

const VFX_FALLBACK_MODELS = [
  { id: "gpt-image-2", name: "GPT Image 2", provider: "openai", api_model: "gpt-image-2", description: "OpenAI latest image model", estimated_cost_usd: 0.053 },
  { id: "gpt-image-1", name: "GPT Image 1", provider: "openai", api_model: "gpt-image-1", description: "OpenAI previous image model", estimated_cost_usd: 0.042 },
  { id: "nanobanana-1", name: "Nano Banana 1", provider: "gemini", api_model: "gemini-2.5-flash-image", description: "Gemini 2.5 Flash Image", estimated_cost_usd: 0.039 },
  { id: "nanobanana-2", name: "Nano Banana 2", provider: "gemini", api_model: "gemini-3.1-flash-image-preview", description: "Gemini 3.1 Flash Image Preview", estimated_cost_usd: 0.067 },
  { id: "nanobanana-pro", name: "Nano Banana Pro", provider: "gemini", api_model: "gemini-3-pro-image-preview", description: "Gemini 3 Pro Image Preview", estimated_cost_usd: 0.134 },
];

const VFX_FALLBACK_MANIFEST = {
  generatedAt: "",
  source: "tools/ai_vfx_pipeline",
  assetBaseUrl: "/web/vfx/assets/fx",
  pipeline: [
    { step: "describe", command: 'node describe.js "火焰爆炸"' },
    { step: "generate", command: '$env:AI_VFX_PIPELINE_ENABLED="1"; node generate.js --sequences --id seq_fire_fireball' },
    { step: "postprocess", command: "node postprocess.js" },
    { step: "mount", command: "node mount.js" },
  ],
  singles: [
    { id: "ai_fire_core", filename: "ai_fire_core.png", url: "/web/vfx/assets/fx/ai_fire_core.png", element: "fire", role: "core" },
    { id: "ai_fire_trail", filename: "ai_fire_trail.png", url: "/web/vfx/assets/fx/ai_fire_trail.png", element: "fire", role: "trail" },
    { id: "ai_fire_impact", filename: "ai_fire_impact.png", url: "/web/vfx/assets/fx/ai_fire_impact.png", element: "fire", role: "impact" },
    { id: "ai_fire_decal", filename: "ai_fire_decal.png", url: "/web/vfx/assets/fx/ai_fire_decal.png", element: "fire", role: "decal" },
  ],
  sequences: [
    { id: "seq_fire_fireball", filename: "ai_seq_fire_fireball.png", url: "/web/vfx/assets/fx/ai_seq_fire_fireball.png", element: "fire", role: "sequence", category: "projectile", frames: 9, cols: 3, rows: 3 },
    { id: "seq_frost_crescent_slash", filename: "ai_seq_frost_crescent_slash.png", url: "/web/vfx/assets/fx/ai_seq_frost_crescent_slash.png", element: "frost", role: "sequence", category: "melee", frames: 9, cols: 3, rows: 3 },
    { id: "seq_skull_bomb", filename: "ai_seq_skull_bomb.png", url: "/web/vfx/assets/fx/ai_seq_skull_bomb.png", element: "dark", role: "sequence", category: "area", frames: 9, cols: 3, rows: 3 },
    { id: "seq_storm_electrocute", filename: "ai_seq_storm_electrocute.png", url: "/web/vfx/assets/fx/ai_seq_storm_electrocute.png", element: "storm", role: "sequence", category: "effect", frames: 9, cols: 3, rows: 3 },
    { id: "seq_thunder_strike", filename: "ai_seq_thunder_strike.png", url: "/web/vfx/assets/fx/ai_seq_thunder_strike.png", element: "storm", role: "sequence", category: "direct_effect", frames: 9, cols: 3, rows: 3 },
  ],
};

function pickLocalVfxSequence(manifest, prompt, style) {
  const sequences = manifest.sequences || [];
  if (!sequences.length) return null;
  const text = `${prompt || ""}`.toLowerCase();
  const groups = [
    { words: ["火", "fire", "flame", "爆", "ember"], target: "fire" },
    { words: ["冰", "frost", "ice", "冻", "crystal"], target: "frost" },
    { words: ["雷", "电", "storm", "thunder", "lightning", "arc"], target: "storm" },
    { words: ["暗", "影", "skull", "shadow", "curse", "毒", "poison"], target: "dark" },
    { words: ["斩", "挥", "slash", "melee"], target: "slash" },
    { words: ["球", "弹", "projectile", "fireball"], target: "fireball" },
    { words: ["炸", "bomb", "area", "范围"], target: "bomb" },
    { words: ["击", "strike", "落"], target: "strike" },
  ];
  groups.push(
    { words: ["火", "炎", "爆", "燃", "fire", "flame", "ember", "burn"], target: "fire" },
    { words: ["冰", "霜", "雪", "晶", "冷", "蓝", "ice", "frost", "snow", "crystal", "cold", "blue"], target: "frost" },
    { words: ["雷", "电", "闪", "storm", "thunder", "lightning", "arc"], target: "storm" },
    { words: ["暗", "影", "骷", "髅", "毒", "shadow", "skull", "curse", "poison"], target: "dark" },
    { words: ["斩", "劈", "刀", "剑", "slash", "melee", "sword"], target: "slash" },
    { words: ["球", "弹", "飞", "projectile", "fireball", "missile"], target: "fireball" },
    { words: ["炸", "范围", "爆开", "bomb", "area", "burst"], target: "bomb" },
    { words: ["击", "落", "直击", "strike", "impact"], target: "strike" },
  );
  const score = (seq) => {
    const filename = String(seq.filename || "").toLowerCase();
    let n = 0;
    if (seq.element === style?.element) n += 12;
    if (seq.category === style?.category) n += 8;
    for (const group of groups) {
      if (group.words.some((w) => text.includes(w))) {
        if (filename.includes(group.target) || seq.element === group.target || seq.category === group.target) n += 10;
      }
    }
    return n;
  };
  return sequences.slice().sort((a, b) => score(b) - score(a))[0];
}

function LegacyEffectsWorkspacePage({ onBack }) {
  const [styles, setStyles] = useState(VFX_FALLBACK_STYLES);
  const [manifest, setManifest] = useState(VFX_FALLBACK_MANIFEST);
  const [input, setInput] = useState("");
  const [styleId, setStyleId] = useState("ink_fire");
  const [busy, setBusy] = useState(false);
  const [result, setResult] = useState(null);
  const [messages, setMessages] = useState([
    { role: "assistant", text: "描述你想要的特效,选择一个预制风格后生成。生成后可以继续输入修改要求。" },
  ]);
  const [selectedId, setSelectedId] = useState(VFX_FALLBACK_MANIFEST.sequences[0]?.id || "");
  const [frame, setFrame] = useState(0);
  const [fps, setFps] = useState(8);
  const [playing, setPlaying] = useState(true);
  const [status, setStatus] = useState("读取本地 manifest");

  useEffect(() => {
    let alive = true;
    fetch("vfx/manifest.json", { cache: "no-store" })
      .then((r) => {
        if (!r.ok) throw new Error("manifest " + r.status);
        return r.json();
      })
      .then((data) => {
        if (!alive) return;
        setManifest(data);
        setStatus("manifest 已同步");
        setSelectedId((id) =>
          (data.sequences || []).some((asset) => asset.id === id)
            ? id
            : ((data.sequences || [])[0]?.id || "")
        );
      })
      .catch((err) => {
        if (!alive) return;
        console.warn("VFX manifest fallback:", err);
        setStatus("使用内置资产索引");
      });
    if (window.MCAPI?.vfx?.styles) {
      window.MCAPI.vfx.styles()
        .then((r) => { if (alive && r?.styles?.length) setStyles(r.styles); })
        .catch((err) => console.warn("VFX styles fallback:", err));
    }
    return () => { alive = false; };
  }, []);

  const activeStyle = useMemo(() => {
    return styles.find((s) => s.id === styleId) || styles[0] || VFX_FALLBACK_STYLES[0];
  }, [styles, styleId]);

  const selected = useMemo(() => {
    return (result?.sequence && result.sequence.id === selectedId ? result.sequence : null)
      || (manifest.sequences || []).find((asset) => asset.id === selectedId)
      || (manifest.sequences || [])[0]
      || null;
  }, [manifest, result, selectedId]);

  const commands = useMemo(() => ([
    { step: "workspace", command: "cd D:\\GithubClone\\MCskin\\tools\\ai_vfx_pipeline" },
    ...(manifest.pipeline || []),
  ]), [manifest.pipeline]);

  useEffect(() => { setFrame(0); }, [selected?.id]);

  useEffect(() => {
    if (!playing || !selected) return;
    const t = window.setInterval(() => {
      setFrame((v) => (v + 1) % (selected.frames || 9));
    }, 1000 / fps);
    return () => window.clearInterval(t);
  }, [playing, fps, selected?.id, selected?.frames]);

  const refreshManifest = () => {
    setStatus("刷新 manifest");
    fetch("vfx/manifest.json", { cache: "reload" })
      .then((r) => {
        if (!r.ok) throw new Error("manifest " + r.status);
        return r.json();
      })
      .then((data) => {
        setManifest(data);
        setStatus("manifest 已同步");
      })
      .catch((err) => {
        console.warn("VFX manifest refresh failed:", err);
        setStatus("刷新失败,保留当前索引");
      });
  };

  const copyCommand = (cmd) => {
    if (navigator.clipboard?.writeText) navigator.clipboard.writeText(cmd).catch(() => {});
  };

  const localResult = (prompt, style, previous) => {
    const seq = pickLocalVfxSequence(manifest, prompt, style);
    const singles = (manifest.singles || []).filter((s) => s.element === seq.element);
    const resolved = `${previous?.resolved_prompt || ""} ${prompt}. Style preset: ${style.name}. Internal VFX prompt: ${style.prompt}.`;
    return {
      id: "fx_local_" + Date.now().toString(36),
      mode: "local_manifest",
      prompt,
      resolved_prompt: resolved.trim(),
      style,
      sequence: seq,
      singles: singles.length ? singles : (manifest.singles || []),
      download: {
        sheet_url: seq.url,
        player_url: "/web/vfx/player.html",
        manifest_url: "/web/vfx/manifest.json",
      },
      summary: `已按「${style.name}」生成可预览特效,当前序列为 ${seq.id}。`,
      suggested_next: ["让爆发更大", "换成冷色调", "减少烟雾,突出轮廓", "加快收尾帧"],
    };
  };

  const runGenerate = async (refine = false, quickText = "") => {
    const text = String(quickText || input || "").trim();
    if (!text || busy) return;
    const styleForRun = activeStyle;
    const nextMessages = [...messages, { role: "user", text }];
    setMessages(nextMessages);
    setInput("");
    setBusy(true);
    setStatus(refine ? "???" : "???");
    try {
      if (!window.MCAPI?.vfx) throw new Error("VFX API ???");
      const payload = { prompt: text, style_id: styleForRun.id, history: nextMessages, previous: result };
      const out = refine && window.MCAPI.vfx.refine
        ? await window.MCAPI.vfx.refine(payload)
        : await window.MCAPI.vfx.generate(payload);
      if (!out?.sequence?.url) throw new Error("?????????????");
      setResult(out);
      setFrame(0);
      setMessages((prev) => [...prev, { role: "assistant", text: resultSummary(out, styleForRun) }]);
      setStatus("????");
    } catch (err) {
      console.warn("VFX generate failed:", err);
      const detail = err?.message || String(err || "????");
      setMessages((prev) => [...prev, { role: "assistant", text: `???????${detail}` }]);
      setStatus("????");
    } finally {
      setBusy(false);
    }
  };

  const downloadJson = () => {
    const payload = JSON.stringify({ result, manifest, style: activeStyle }, null, 2);
    const blob = new Blob([payload], { type: "application/json" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `${result?.id || selected?.id || "vfx"}_recipe.json`;
    a.click();
    URL.revokeObjectURL(url);
  };

  const suggestions = result?.suggested_next || [
    "黑色手绘描边火球,命中时爆开",
    "冰霜挥砍弧线,带碎裂冰晶",
    "从天而降的雷击,落点有电弧",
    "暗影骷髅炸弹,爆开后有毒雾",
  ];

  return (
    <div className="vfx-page">
      <div className="vfx-shell">
        <section className="vfx-panel">
          <div className="vfx-panel-head">
            <div>
              <div className="vfx-panel-title"><I.Effects size={16} /> 对话生成</div>
              <div className="vfx-panel-sub">多轮提示词修改</div>
            </div>
            <span className="vfx-pill"><I.Check size={12} /> {busy ? "生成中" : status}</span>
          </div>
          <div className="vfx-chat">
            {messages.map((m, idx) => (
              <div key={idx} className={"vfx-msg " + m.role}>
                <div className="vfx-msg-role">{m.role === "user" ? "你" : "VFX"}</div>
                <div className="vfx-msg-text">{m.text}</div>
              </div>
            ))}
          </div>
          <div className="vfx-prompt">
            <div className="vfx-active-style">
              <span>{activeStyle.name}</span>
              <small>{VFX_ELEMENT_LABELS[activeStyle.element] || activeStyle.element} · {VFX_CATEGORY_LABELS[activeStyle.category] || activeStyle.category}</small>
            </div>
            <textarea
              value={input}
              onChange={(e) => setInput(e.target.value)}
              placeholder={result ? "继续输入修改要求,例如: 爆炸更大,收尾更快" : "描述要生成的特效,例如: 火球向前飞出,命中时爆炸"}
              onKeyDown={(e) => {
                if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
                  e.preventDefault();
                  runGenerate(!!result);
                }
              }}
            />
            <div className="vfx-quick-row">
              {suggestions.slice(0, 4).map((s) => (
                <button key={s} className="vfx-chip" onClick={() => result ? runGenerate(true, s) : setInput(s)}>
                  {s}
                </button>
              ))}
            </div>
            <button className={"send-btn vfx-send" + (busy ? " stop" : "")}
                    disabled={busy || !input.trim()}
                    aria-label={busy ? "生成中" : result ? "发送修改" : "生成特效"}
                    title={busy ? "生成中" : result ? "发送修改" : "生成特效"}
                    onClick={() => runGenerate(!!result)}>
              {busy ? <span className="send-stop-square" /> : <I.Send size={16} />}
            </button>
          </div>

          <details className="vfx-dev-pipeline">
            <summary>开发管线命令</summary>
            <div className="vfx-steps">
              {commands.map((item, idx) => (
                <div className="vfx-step" key={item.step + idx}>
                  <div className="vfx-step-top">
                    <span className="vfx-step-num">{idx + 1}</span>
                    <span style={{ flex: 1 }}>{VFX_STEP_LABELS[item.step] || item.step}</span>
                    <button className="icon-btn" title="复制命令" onClick={() => copyCommand(item.command)}>
                      <I.Code size={14} />
                    </button>
                  </div>
                  <code className="vfx-code">{item.command}</code>
                </div>
              ))}
            </div>
          </details>
        </section>

        <section className="vfx-panel">
          <div className="vfx-panel-head">
            <div className="vfx-panel-title"><I.Play size={14} /> 预览与下载</div>
            <div className="row">
              <button className="ghost-btn" onClick={refreshManifest}><I.Refresh size={14} /> 刷新</button>
              <a className="primary-btn" href="vfx/player.html" target="_blank" rel="noreferrer">
                <I.ChevronRight size={14} /> 播放器
              </a>
            </div>
          </div>
          {selected ? (
            <div className="vfx-viewer">
              <div>
                <div className="vfx-frame">
                  <VfxSpriteFrame asset={selected} frame={frame} />
                </div>
                <div className="vfx-controls">
                  <button className="icon-btn" title={playing ? "暂停" : "播放"} onClick={() => setPlaying((v) => !v)}>
                    {playing ? <I.Pause size={14} /> : <I.Play size={14} />}
                  </button>
                  <input
                    min="0"
                    max={(selected.frames || 9) - 1}
                    value={frame}
                    type="range"
                    onChange={(e) => setFrame(Number(e.target.value))}
                  />
                  <span>{frame + 1}/{selected.frames || 9}</span>
                  <input
                    min="2"
                    max="16"
                    value={fps}
                    type="range"
                    onChange={(e) => setFps(Number(e.target.value))}
                  />
                  <span>{fps} fps</span>
                </div>
              </div>

              <div>
                <div className="vfx-detail-kicker">{VFX_ELEMENT_LABELS[selected.element] || selected.element}</div>
                <h2 className="vfx-detail-title">{selected.id}</h2>
                {result && <div className="vfx-result-note">{result.summary}</div>}
                <img className="vfx-sheet" src={selected.url} alt={selected.id} />
                <div className="vfx-download-row">
                  <a className="primary-btn" href={selected.url} download={selected.filename}>
                    <I.Download size={14} /> 下载序列 PNG
                  </a>
                  <button className="ghost-btn" onClick={downloadJson}>
                    <I.Code size={14} /> 下载生成参数
                  </button>
                </div>
                <div className="vfx-stats">
                  <VfxStat label="文件" value={selected.filename} />
                  <VfxStat label="规格" value={`${selected.cols || 3}x${selected.rows || 3} / ${selected.frames || 9} 帧`} />
                  <VfxStat label="路径" value={selected.url} />
                  <VfxStat label="类别" value={VFX_CATEGORY_LABELS[selected.category] || selected.category || "效果"} />
                </div>
              </div>
            </div>
          ) : (
            <div style={{ padding: 28, color: "var(--ink-4)" }}>暂无序列资产</div>
          )}
        </section>

        <section className="vfx-panel">
          <div className="vfx-panel-head">
            <div className="vfx-panel-title"><I.Wand size={16} /> 预制风格</div>
            <span className="vfx-pill">{styles.length} presets</span>
          </div>
          <div className="vfx-style-list">
            {styles.map((style) => (
              <button
                key={style.id}
                type="button"
                className={"vfx-style-card" + (style.id === activeStyle.id ? " active" : "")}
                onClick={() => setStyleId(style.id)}
              >
                <span className="vfx-style-name">{style.name}</span>
                <span className="vfx-style-desc">{style.description}</span>
                <span className="vfx-style-tags">
                  <span>{VFX_ELEMENT_LABELS[style.element] || style.element}</span>
                  <span>{VFX_CATEGORY_LABELS[style.category] || style.category}</span>
                  <span>内置提示词</span>
                </span>
              </button>
            ))}
          </div>
          <div className="vfx-panel-head" style={{ borderTop: "1px solid var(--line)" }}>
            <div className="vfx-panel-title"><I.Image size={16} /> 产物资产</div>
            <span className="vfx-pill">{(manifest.sequences || []).length} seq / {(manifest.singles || []).length} tex</span>
          </div>
          <div className="vfx-list">
            {(manifest.sequences || []).map((asset) => (
              <button
                key={asset.id}
                type="button"
                className={"vfx-seq-btn" + (asset.id === selected?.id ? " active" : "")}
                onClick={() => setSelectedId(asset.id)}
              >
                <span className="vfx-seq-thumb"><VfxSpriteFrame asset={asset} frame={0} /></span>
                <span style={{ minWidth: 0 }}>
                  <span className="vfx-seq-name">{asset.id}</span>
                  <span className="vfx-seq-meta">
                    <span>{VFX_ELEMENT_LABELS[asset.element] || asset.element}</span>
                    <span>{VFX_CATEGORY_LABELS[asset.category] || asset.category}</span>
                  </span>
                </span>
              </button>
            ))}
          </div>
          <div className="vfx-single-grid">
            {((result?.singles && result.singles.length ? result.singles : manifest.singles) || []).map((asset) => (
              <div className="vfx-single" key={asset.id}>
                <img src={asset.url} alt={asset.id} />
                <div className="vfx-single-label">
                  <I.Image size={12} />
                  <span>{VFX_ROLE_LABELS[asset.role] || asset.role}</span>
                </div>
              </div>
            ))}
          </div>
        </section>
      </div>
    </div>
  );
}

function EffectsWorkspacePage({ session = null, onUpdate = null, onPromoteSession = null, onBack, onResizePreview, authUser = null, authChecked = true, onRequireLogin }) {
  const saved = session?.workflowState || {};
  const sessionId = session?.id || null;
  const [styles, setStyles] = useState(VFX_FALLBACK_STYLES);
  const [models, setModels] = useState(VFX_FALLBACK_MODELS);
  const [imageModelId, setImageModelId] = useState(saved.imageModelId || "gpt-image-2");
  const [autoSplitEffects, setAutoSplitEffects] = useState(saved.autoSplitEffects ?? true);
  const [input, setInput] = useState(saved.input || "");
  const [styleId, setStyleId] = useState(saved.styleId || "realistic");
  const [customStyle, setCustomStyle] = useState(saved.customStyle || "");
  const [busy, setBusy] = useState(!!(session?.running && saved.activeJob?.id));
  const [result, setResult] = useState(null);
  const [selectedEffectId, setSelectedEffectId] = useState(saved.selectedEffectId || "");
  const [previewMode, setPreviewMode] = useState(saved.previewMode || "single");
  const [messages, setMessages] = useState(saved.messages || defaultWorkflowMessages("effects"));
  const [frame, setFrame] = useState(saved.frame || 0);
  const [fps, setFps] = useState(saved.fps || 8);
  const [playing, setPlaying] = useState(saved.playing ?? true);
  const [previewOpen, setPreviewOpen] = useState(saved.previewOpen ?? true);
  const [status, setStatus] = useState(saved.status || "等待输入");
  const [vfxProgress, setVfxProgress] = useState(saved.progress || null);
  const [plannedUsage, setPlannedUsage] = useState(saved.plannedUsage || null);
  const [vfxUsageSummary, setVfxUsageSummary] = useState(saved.usageSummary || null);
  const [workflowJob, setWorkflowJob] = useState(saved.activeJob || null);
  const [usageRefreshKey, setUsageRefreshKey] = useState(0);
  const vfxUsageSummaryRef = useRef(saved.usageSummary || null);
  const vfxAbortRef = useRef(null);
  const vfxJobRef = useRef(saved.activeJob || null);

  useEffect(() => {
    let alive = true;
    if (window.MCAPI?.vfx?.styles) {
      window.MCAPI.vfx.styles()
        .then((r) => {
          if (alive && r?.styles?.length) {
            setStyles(r.styles);
            setStyleId((current) => r.styles.some((s) => s.id === current) ? current : r.styles[0].id);
          }
        })
        .catch((err) => console.warn("VFX styles fallback:", err));
    }
    if (window.MCAPI?.vfx?.models) {
      window.MCAPI.vfx.models()
        .then((r) => {
          if (alive && r?.models?.length) {
            setModels(r.models);
            setImageModelId((current) => r.models.some((m) => m.id === current) ? current : (r.default || r.models[0].id));
          }
        })
        .catch((err) => console.warn("VFX models fallback:", err));
    }
    return () => { alive = false; };
  }, []);

  const activeStyle = useMemo(() => {
    return styles.find((s) => s.id === styleId) || styles[0] || VFX_FALLBACK_STYLES[0];
  }, [styles, styleId]);
  const activeModel = useMemo(() => {
    return models.find((m) => m.id === imageModelId) || models[0] || VFX_FALLBACK_MODELS[0];
  }, [models, imageModelId]);
  const resultItems = useMemo(() => {
    if (!result) return [];
    return Array.isArray(result.effects) && result.effects.length ? result.effects : [result];
  }, [result]);
  const selectedEffect = useMemo(() => {
    return resultItems.find((item) => item.id === selectedEffectId) || resultItems[0] || null;
  }, [resultItems, selectedEffectId]);
  const selected = selectedEffect?.sequence || null;
  const composition = result?.composition || null;
  const comboAvailable = resultItems.length > 1 && !!composition?.components?.length;
  const maxFrame = previewMode === "combo" && comboAvailable
    ? Math.max(0, (composition.total_frames || 1) - 1)
    : Math.max(0, (selected?.frames || 1) - 1);
  const customStyleRequired = activeStyle?.id === "custom";
  const customStyleReady = !customStyleRequired || !!customStyle.trim();
  const makePlannedUsage = (count, model = activeModel) => {
    const calls = Math.max(1, Number(count || 1));
    const cost = calls * Number(model?.estimated_cost_usd || 0);
    const imageTokens = imageEquivTokensFromUsd(cost);
    return {
      currency: "USD",
      image_calls: calls,
      image_cost_usd: cost,
      image_equiv_tokens: imageTokens,
      total_tokens: imageTokens,
      total_cost_usd: cost,
      cost_usd: cost,
      est_cost_usd: cost,
      estimated: true,
    };
  };
  const rememberVfxUsage = (summary) => {
    if (!summary) return;
    const next = mergeUsageSummary(vfxUsageSummaryRef.current, summary);
    vfxUsageSummaryRef.current = next;
    setVfxUsageSummary(next);
    setPlannedUsage(null);
    setUsageRefreshKey((n) => n + 1);
  };

  useEffect(() => {
    vfxJobRef.current = workflowJob;
  }, [workflowJob]);

  useEffect(() => {
    if (!sessionId || !session?.workflowAssetStored) return;
    let alive = true;
    loadWorkflowAsset(sessionId)
      .then((record) => {
        if (alive && record?.result) setResult(record.result);
      })
      .catch((err) => console.warn("VFX asset restore failed:", err));
    return () => { alive = false; };
  }, [sessionId]);

  useEffect(() => {
    if (!sessionId || !result) return;
    saveWorkflowAsset(sessionId, "effects", result)
      .then(() => onUpdate && onUpdate({ workflowAssetStored: true, updatedAt: Date.now() }))
      .catch((err) => console.warn("VFX asset save failed:", err));
  }, [sessionId, result]);

  useEffect(() => {
    if (!sessionId || !onUpdate) return;
    const firstUser = messages.find((m) => m.role === "user")?.text || session?.prompt || "";
    onUpdate({
      prompt: firstUser,
      title: workflowTitleFromPrompt("effects", firstUser),
      model: activeModel?.id || "vfx",
      running: busy,
      updatedAt: Date.now(),
      workflowState: compactWorkflowState({
        input,
        styleId,
        customStyle,
        imageModelId,
        autoSplitEffects,
        messages,
        selectedEffectId,
        previewMode,
        fps,
        playing,
        previewOpen,
        status,
        progress: vfxProgress,
        plannedUsage,
        usageSummary: vfxUsageSummary,
        activeJob: workflowJob,
      }),
    });
  }, [sessionId, input, styleId, customStyle, imageModelId, autoSplitEffects, messages, selectedEffectId, previewMode, fps, playing, previewOpen, status, vfxProgress, plannedUsage, vfxUsageSummary, workflowJob, busy, activeModel?.id]);

  useEffect(() => { setFrame(0); }, [selected?.id, previewMode]);

  useEffect(() => {
    if (previewMode === "combo" && comboAvailable && composition?.fps) {
      setFps(Number(composition.fps));
      return;
    }
    const plannedFps = selectedEffect?.task?.timing?.playback_fps;
    if (plannedFps) setFps(Number(plannedFps));
  }, [previewMode, comboAvailable, composition?.fps, selectedEffect?.id]);

  useEffect(() => {
    if (!playing || !selected) return;
    const t = window.setInterval(() => {
      setFrame((v) => (v + 1) % (maxFrame + 1 || 1));
    }, 1000 / fps);
    return () => window.clearInterval(t);
  }, [playing, fps, selected?.id, maxFrame]);

  const resultSummary = (out, style = activeStyle) => {
    const seqId = out?.sequence?.id || "pending";
    const count = Array.isArray(out?.effects) && out.effects.length ? out.effects.length : 1;
    if (count > 1) {
      return `已把提示词拆成 ${count} 个不同类型的独立特效顺序生成，后续特效参考前一张风格，当前预览 ${seqId}。`;
    }
    if (out?.auto_split === false) {
      return `已关闭自动拆解，按「${style.name}」视觉风格生成单张 4x4 PNG 序列帧，当前产物为 ${seqId}。`;
    }
    const mode = out?.mode === "openai_image" ? "\u771f\u5b9e\u751f\u6210" : "\u751f\u6210";
    return `\u5df2\u6309\u300c${style.name}\u300d\u89c6\u89c9\u98ce\u683c${mode}\u9884\u89c8\uff0c\u5f53\u524d\u4ea7\u7269\u4e3a ${seqId}\u3002`;
  };

  const applyVfxResult = (out, styleForRun = activeStyle, modelForRun = activeModel) => {
    rememberVfxUsage(out.usage_summary || out.usage);
    setResult(out);
    setSelectedEffectId((Array.isArray(out.effects) && out.effects[0]?.id) || out.id || "");
    setPreviewMode(Array.isArray(out.effects) && out.effects.length > 1 ? "combo" : "single");
    setFrame(0);
    setMessages((prev) => [...prev, { role: "assistant", text: resultSummary(out, styleForRun) }]);
    setStatus("生成完成");
    setVfxProgress({
      status: "done",
      progress: 100,
      phase: "生成完成",
      detail: `${(Array.isArray(out.effects) && out.effects.length) || 1} 个 PNG 已可预览和下载`,
      model: out.model || modelForRun,
      total: (Array.isArray(out.effects) && out.effects.length) || 1,
      completed: (Array.isArray(out.effects) && out.effects.length) || 1,
    });
    setWorkflowJob(null);
  };

  const pollVfxJob = async (jobId, { signal, plannedCount = 1, modelForRun = activeModel } = {}) => {
    let tick = 0;
    while (true) {
      if (signal?.aborted) throw makeAbortError();
      const job = await window.MCAPI.vfx.job(jobId, { signal });
      if (job.status === "done") return job.result;
      if (job.status === "cancelled") throw makeAbortError("cancelled");
      if (job.status === "error") throw new Error(job.error || "VFX job failed");
      tick += 1;
      setVfxProgress((prev) => ({
        ...(prev || {}),
        status: "running",
        progress: Math.min(94, Math.max(prev?.progress || 18, 18 + tick * 3)),
        phase: autoSplitEffects ? `后台生成中 ${Math.min(plannedCount, Math.max(1, Math.ceil((tick + 1) / 4)))}/${plannedCount}` : "后台生成单张序列帧",
        detail: `${modelForRun.name || modelForRun.id} · 切换会话或刷新后可继续恢复`,
        model: modelForRun,
        total: plannedCount,
      }));
      await waitWithSignal(1500, signal);
    }
  };

  useEffect(() => {
    if (!sessionId || !workflowJob?.id) return;
    if (["done", "error", "cancelled"].includes(workflowJob.status)) return;
    let alive = true;
    const controller = new AbortController();
    vfxAbortRef.current = controller;
    setBusy(true);
    setStatus("生成中");
    pollVfxJob(workflowJob.id, {
      signal: controller.signal,
      plannedCount: workflowJob.plannedCount || workflowJob.payload?.estimatedCount || 1,
      modelForRun: activeModel,
    })
      .then((out) => {
        if (!alive || !out) return;
        applyVfxResult(out, activeStyle, activeModel);
      })
      .catch((err) => {
        if (!alive || err?.name === "AbortError") return;
        setMessages((prev) => [...prev, { role: "assistant", text: `真实生成失败：${err?.message || err}` }]);
        setStatus("生成失败");
        setVfxProgress((prev) => ({ ...(prev || {}), status: "error", phase: "生成失败", detail: err?.message || String(err || "") }));
      })
      .finally(() => {
        if (!alive) return;
        if (vfxAbortRef.current === controller) vfxAbortRef.current = null;
        setBusy(false);
      });
    return () => {
      alive = false;
      controller.abort();
    };
  }, [sessionId]);

  const stopGenerate = () => {
    if (!busy) return;
    const controller = vfxAbortRef.current;
    if (controller && !controller.signal.aborted) controller.abort();
    const jobId = vfxJobRef.current?.id || workflowJob?.id;
    if (jobId && window.MCAPI?.vfx?.cancelJob) {
      window.MCAPI.vfx.cancelJob(jobId).catch((err) => console.warn("VFX job cancel failed:", err));
    }
    setWorkflowJob(null);
    setStatus("正在停止");
    setVfxProgress((prev) => prev ? {
      ...prev,
      status: "stopping",
      phase: "正在停止生成",
      detail: "正在中断当前请求",
    } : prev);
  };
  const runGenerate = async (refine = false, quickText = "") => {
    const text = String(quickText || input || "").trim();
    if (!text || busy) return;
    const styleForRun = activeStyle;
    const modelForRun = activeModel;
    const customStyleText = styleForRun.id === "custom" ? customStyle.trim() : "";
    if (styleForRun.id === "custom" && !customStyleText) {
      setStatus("请输入自定义风格");
      setMessages((prev) => [...prev, { role: "assistant", text: "选择自定义风格时，需要先输入风格描述；系统会自动转换成英文视觉 prompt。" }]);
      return;
    }
    const controller = new AbortController();
    vfxAbortRef.current = controller;
    const requestSignal = controller.signal;
    const nextMessages = [...messages, { role: "user", text }];
    if (onPromoteSession) {
      onPromoteSession({
        prompt: text,
        title: workflowTitleFromPrompt("effects", text),
        model: modelForRun.id,
      });
    }
    setMessages(nextMessages);
    setInput("");
    setBusy(true);
    setStatus(refine ? "\u4fee\u6539\u4e2d" : "\u751f\u6210\u4e2d");
    setVfxProgress({
      status: "planning",
      progress: 6,
      phase: "规划特效组件",
      detail: "分析提示词、特效类型、方向和爆点",
      model: modelForRun,
      total: 1,
      completed: 0,
    });
    let progressTimer = null;
    try {
      if (!window.MCAPI?.vfx) throw new Error("VFX API \u672a\u52a0\u8f7d");
      let planned = null;
      if (window.MCAPI.vfx.plan) {
        try {
          planned = await window.MCAPI.vfx.plan({
            prompt: text,
            style_id: styleForRun.id,
            custom_style: customStyleText,
            image_model: modelForRun.id,
            auto_split: autoSplitEffects,
            history: nextMessages,
            previous: result,
            signal: requestSignal,
          });
        } catch (planErr) {
          if (requestSignal.aborted || planErr?.name === "AbortError") throw planErr;
          console.warn("VFX plan failed:", planErr);
        }
      }
      const plannedCount = autoSplitEffects
        ? Math.max(1, Number(planned?.components?.length || planned?.estimated_usage?.image_calls || 1))
        : 1;
      const estimate = planned?.estimated_usage || makePlannedUsage(plannedCount, modelForRun);
      setPlannedUsage(estimate);
      setVfxProgress({
        status: "running",
        progress: 14,
        phase: autoSplitEffects ? `按顺序生成 ${plannedCount} 个组件` : "生成单张特效 PNG",
        detail: `${modelForRun.name || modelForRun.id} · 预计消耗 ${fmtUsd(usageCostUsd(estimate))}`,
        model: modelForRun,
        total: plannedCount,
        completed: 0,
      });
      const payload = { prompt: text, style_id: styleForRun.id, custom_style: customStyleText, image_model: modelForRun.id, auto_split: autoSplitEffects, history: nextMessages, previous: result, signal: requestSignal };
      let out = null;
      if (window.MCAPI.vfx.startJob && window.MCAPI.vfx.job) {
        const job = await window.MCAPI.vfx.startJob({ ...payload, refine });
        const nextJob = {
          id: job.job_id,
          status: job.status || "running",
          refine: !!refine,
          plannedCount,
          startedAt: Date.now(),
          payload: { ...payload, signal: undefined, previous: result ? { id: result.id } : null },
        };
        setWorkflowJob(nextJob);
        out = await pollVfxJob(job.job_id, { signal: requestSignal, plannedCount, modelForRun });
      } else {
        const startAt = Date.now();
        progressTimer = window.setInterval(() => {
          setVfxProgress((prev) => {
            if (!prev || prev.status === "done" || prev.status === "error") return prev;
            const elapsed = Math.floor((Date.now() - startAt) / 1000);
            const nextProgress = Math.min(92, Math.max(prev.progress || 0, 18 + elapsed * 3));
            const approxDone = Math.min(plannedCount - 1, Math.floor((nextProgress / 92) * plannedCount));
            return {
              ...prev,
              progress: nextProgress,
              completed: approxDone,
              phase: autoSplitEffects ? `真实调用 API 生成中 ${approxDone + 1}/${plannedCount}` : "真实调用 API 生成单张序列帧",
            };
          });
        }, 1000);
        out = refine && window.MCAPI.vfx.refine
          ? await window.MCAPI.vfx.refine(payload)
          : await window.MCAPI.vfx.generate(payload);
      }
      if (!out?.sequence?.url) throw new Error("\u771f\u5b9e\u751f\u6210\u6ca1\u6709\u8fd4\u56de\u53ef\u9884\u89c8\u56fe\u7247");
      if (progressTimer) {
        window.clearInterval(progressTimer);
        progressTimer = null;
      }
      applyVfxResult(out, styleForRun, modelForRun);
    } catch (err) {
      if (progressTimer) {
        window.clearInterval(progressTimer);
        progressTimer = null;
      }
      const aborted = requestSignal.aborted || err?.name === "AbortError";
      if (aborted) {
        setMessages((prev) => [...prev, { role: "assistant", text: "已停止生成。" }]);
        setStatus("已停止");
        setVfxProgress(null);
        setPlannedUsage(null);
        setWorkflowJob(null);
        return;
      }
      console.warn("VFX generate failed:", err);
      const detail = err?.message || String(err || "\u672a\u77e5\u9519\u8bef");
      setMessages((prev) => [...prev, { role: "assistant", text: `\u771f\u5b9e\u751f\u6210\u5931\u8d25\uff1a${detail}` }]);
      setStatus("\u751f\u6210\u5931\u8d25");
      setVfxProgress((prev) => ({
        ...(prev || {}),
        status: "error",
        progress: prev?.progress || 0,
        phase: "生成失败",
        detail,
      }));
    } finally {
      if (progressTimer) window.clearInterval(progressTimer);
      if (vfxAbortRef.current === controller) vfxAbortRef.current = null;
      setBusy(false);
    }
  };
  const downloadJson = () => {
    if (!result) return;
    const payload = JSON.stringify({ result, style: activeStyle, custom_style: customStyle, image_model: activeModel, auto_split: autoSplitEffects }, null, 2);
    const blob = new Blob([payload], { type: "application/json" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `${result.id || selected?.id || "vfx"}_recipe.json`;
    a.click();
    URL.revokeObjectURL(url);
  };

  const suggestions = result?.suggested_next || [
    "火球向前飞出,命中时爆炸",
    "冰霜弧线横扫,碎冰向外飞散",
    "从天而降的雷击,落点有电弧",
    "暗影能量团爆开,残留烟雾",
  ];

  return (
    <>
      <header className="topbar vfx-topbar">
        <button className="icon-btn" onClick={onBack} title="返回工作流">
          <I.X size={16} />
        </button>
        <div className="topbar-title">
          特效工作流
          <span className="sep">/</span>
          <span className="small">AI VFX</span>
        </div>
        <div className="spacer" />
        <span className="vfx-status-pill">{busy ? "生成中" : status}</span>
        <button className="ghost-btn" onClick={() => setPreviewOpen((v) => !v)}>
          <I.Eye size={14} /> {previewOpen ? "隐藏预览" : "显示预览"}
        </button>
        <a className="ghost-btn" href="vfx/player.html" target="_blank" rel="noreferrer">
          <I.Play size={14} /> 播放器
        </a>
        <a
          className={"primary-btn" + (selected ? "" : " disabled")}
          href={selected?.url || "#"}
          download={selected?.filename || "vfx.png"}
          aria-disabled={!selected}
          onClick={(e) => { if (!selected) e.preventDefault(); }}
        >
          <I.Download size={14} /> 下载 PNG
        </a>
      </header>

      <div className={"workspace vfx-native-workspace" + (previewOpen ? "" : " no-preview")}>
        <section className="convo">
          <div className="convo-stream">
            <div className="convo-inner">
              {messages.map((m, idx) => (
                m.role === "user" ? (
                  <div className="turn user" key={idx}>
                    <div className="bubble-user">{m.text}</div>
                    <div className="bubble-meta-row">
                      <span className="meta-chip"><I.Wand size={12} /> {activeStyle.name}</span>
                    </div>
                  </div>
                ) : (
                  <div className="turn assistant" key={idx}>
                    <AssistantHead busy={busy && idx === messages.length - 1} />
                    <div className="assistant-body">{m.text}</div>
                  </div>
                )
              ))}

              {busy && vfxProgress && (
                <div className="turn assistant workflow-progress-turn">
                  <AssistantHead busy />
                  <div className="assistant-body" style={{ width: "100%" }}>
                    <ImageWorkflowProgressBubble
                      progress={vfxProgress}
                      usage={plannedUsage}
                      onStop={stopGenerate}
                    />
                  </div>
                </div>
              )}

              <div className="turn assistant">
                <AssistantHead />
                <div className="assistant-body" style={{ width: "100%" }}>
                  <div className="info-card vfx-style-panel">
                    <div className="info-card-head">
                      <I.Wand className="ico" size={14} /> 特效风格
                    </div>
                    <div className="vfx-style-grid-native">
                      {styles.map((style) => {
                        const swatch = style.swatch || ["#111", "#888", "#fff"];
                        const tags = (style.tags || String(style.tone || "").split(/[,，]/)).slice(0, 3);
                        return (
                          <button
                            key={style.id}
                            type="button"
                            className={"vfx-style-option" + (style.id === activeStyle.id ? " active" : "")}
                            onClick={() => setStyleId(style.id)}
                          >
                            <span className="vfx-style-swatch" style={{ "--c1": swatch[0], "--c2": swatch[1], "--c3": swatch[2] }} />
                            <span className="vfx-style-copy">
                              <span className="vfx-style-name">{style.name}</span>
                              <span className="vfx-style-desc">{style.description}</span>
                              <span className="vfx-style-tags">
                                {tags.map((tag) => <span key={tag}>{tag}</span>)}
                              </span>
                            </span>
                          </button>
                        );
                      })}
                    </div>
                    {activeStyle.id === "custom" && (
                      <div className="vfx-custom-style-box">
                        <label>自定义风格</label>
                        <textarea
                          rows={2}
                          value={customStyle}
                          onChange={(e) => setCustomStyle(e.target.value)}
                          placeholder="例如：暗黑哥特、低饱和金属质感、赛博朋克霓虹、水墨国风"
                        />
                        <span>系统会自动转换成英文视觉 prompt，只影响材质、色彩、线条和渲染质感。</span>
                      </div>
                    )}
                  </div>

                  {result && selected && (
                    <div className="export-card vfx-result-card">
                      <div className="info-card-head">
                        <I.Download className="ico" size={14} /> 生成产物
                      </div>
                      <div className="file-grid">
                        {resultItems.map((item, index) => {
                          const seq = item.sequence;
                          if (!seq) return null;
                          return (
                            <a
                              className="file-row"
                              href={seq.url}
                              download={seq.filename}
                              onClick={() => {
                                setSelectedEffectId(item.id);
                                setPreviewMode("single");
                              }}
                              key={item.id || seq.id}
                            >
                              <span className="file-icon">PNG</span>
                              <span className="file-meta">
                                <span className="file-name">{seq.filename}</span>
                                <span className="file-size">#{index + 1} · {seq.cols || 4}x{seq.rows || 4} / {seq.frames || 16} 帧</span>
                              </span>
                              <span className="dl"><I.Download size={14} /></span>
                            </a>
                          );
                        })}
                        <button className="file-row" onClick={downloadJson}>
                          <span className="file-icon">JSON</span>
                          <span className="file-meta">
                            <span className="file-name">{result.id}_recipe.json</span>
                            <span className="file-size">prompt + style</span>
                          </span>
                          <span className="dl"><I.Download size={14} /></span>
                        </button>
                      </div>
                    </div>
                  )}
                </div>
              </div>
            </div>
          </div>

          <div className="convo-composer">
            <div className="convo-composer-inner">
              <div className="preset-row vfx-prompt-presets">
                {suggestions.slice(0, 4).map((s) => (
                  <button key={s} className="preset-chip" onClick={() => runGenerate(!!result, s)} disabled={busy}>
                    {s}
                  </button>
                ))}
              </div>
              <div className="composer vfx-composer-native">
                <textarea
                  rows={2}
                  value={input}
                  disabled={busy}
                  onChange={(e) => setInput(e.target.value)}
                  placeholder={result ? "继续输入修改要求,例如: 拖尾更长,节奏更快" : "描述要生成的具体特效,例如: 火球向前飞出,命中时爆炸"}
                  onKeyDown={(e) => {
                    if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
                      e.preventDefault();
                      runGenerate(!!result);
                    }
                  }}
                />
                <div className="composer-bar">
                  <div className="style-group vfx-current-style">
                    <span className="style-label">特效风格：</span>
                    <div className="style-pills">
                      <button className="style-pill active" type="button">{activeStyle.name}</button>
                    </div>
                  </div>
                  <ImageWorkflowModelMenu
                    models={models}
                    activeModel={activeModel}
                    onModelChange={setImageModelId}
                    busy={busy}
                  />
                  <label className="workflow-mini-toggle" title={autoSplitEffects ? "开启后自动拆成多张 PNG" : "关闭后只生成一张 PNG"}>
                    <input
                      type="checkbox"
                      checked={autoSplitEffects}
                      onChange={() => setAutoSplitEffects((v) => !v)}
                      disabled={busy}
                    />
                    <span>自动拆解</span>
                  </label>
                  <button
                    className={"send-btn" + (busy ? " stop" : "")}
                    onClick={busy ? stopGenerate : () => runGenerate(!!result)}
                    disabled={!busy && (!input.trim() || !customStyleReady)}
                    title={busy ? "停止生成" : result ? "发送修改" : "生成特效"}
                    aria-label={busy ? "停止生成" : result ? "发送修改" : "生成特效"}
                  >
                    {busy ? <span className="send-stop-square" aria-hidden="true" /> : <I.Send size={16} />}
                  </button>
                </div>
              </div>
              <ImageWorkflowQuotaLine
                refreshKey={usageRefreshKey}
                authUser={authUser}
                authChecked={authChecked}
                usage={vfxUsageSummary || plannedUsage}
              />
            </div>
          </div>
        </section>

        {previewOpen && (onResizePreview ? <Resizer onResize={onResizePreview} /> : <div className="resizer-v" />)}
        {previewOpen && (
          <section className="preview vfx-preview-pane">
            <div className="preview-tabs">
              <button
                className={"preview-tab" + (previewMode === "single" ? " active" : "")}
                type="button"
                onClick={() => {
                  setPreviewMode("single");
                  setFrame(0);
                }}
              >
                单 PNG
              </button>
              <button
                className={"preview-tab" + (previewMode === "combo" ? " active" : "")}
                type="button"
                disabled={!comboAvailable}
                onClick={() => {
                  setPreviewMode("combo");
                  setFrame(0);
                }}
              >
                组合播放
              </button>
            </div>
            <div className="preview-stage vfx-preview-stage">
              {selected ? (
                <div className="vfx-preview-center">
                  {resultItems.length > 1 && (
                    <div className="vfx-png-picker" aria-label="选择预览 PNG">
                      {resultItems.map((item, index) => {
                        const seq = item.sequence;
                        if (!seq) return null;
                        const isActive = seq.id === selected.id;
                        return (
                          <button
                            key={item.id || seq.id}
                            type="button"
                            className={"vfx-png-option" + (isActive ? " active" : "")}
                            onClick={() => {
                              setSelectedEffectId(item.id);
                              setPreviewMode("single");
                              setFrame(0);
                            }}
                          >
                            <span>PNG {index + 1}</span>
                            <small>{item.task?.component_label || item.task?.component_type || item.task?.effect_type || seq.category || "vfx"}</small>
                          </button>
                        );
                      })}
                    </div>
                  )}
                  <div className="vfx-frame vfx-main-frame">
                    {previewMode === "combo" && comboAvailable ? (
                      <VfxCompositeFrame items={resultItems} composition={composition} frame={frame} />
                    ) : (
                      <VfxSpriteFrame asset={selected} frame={frame} />
                    )}
                  </div>
                  <div className="vfx-controls">
                    <button className="icon-btn" title={playing ? "暂停" : "播放"} onClick={() => setPlaying((v) => !v)}>
                      {playing ? <I.Pause size={14} /> : <I.Play size={14} />}
                    </button>
                    <input
                      min="0"
                      max={maxFrame}
                      value={frame}
                      type="range"
                      onChange={(e) => setFrame(Number(e.target.value))}
                    />
                    <span>{frame + 1}/{maxFrame + 1}</span>
                    <input
                      min="2"
                      max="16"
                      value={fps}
                      type="range"
                      onChange={(e) => setFps(Number(e.target.value))}
                    />
                    <span>{fps} fps</span>
                  </div>
                </div>
              ) : (
                <div className="preview-empty vfx-empty-state">
                  <I.Effects size={34} />
                  <div>等待生成</div>
                  <span>输入具体特效后这里才会出现预览。</span>
                </div>
              )}
            </div>
            <div className="vfx-preview-detail">
              {selected ? (
                <>
                  {resultItems.length > 1 && (
                    <div className="file-grid" style={{ marginBottom: 12 }}>
                      {resultItems.map((item, index) => {
                        const seq = item.sequence;
                        if (!seq) return null;
                        return (
                          <button
                            className="file-row"
                            onClick={() => {
                              setSelectedEffectId(item.id);
                              setPreviewMode("single");
                            }}
                            key={item.id || seq.id}
                            type="button"
                          >
                            <span className="file-icon">FX{index + 1}</span>
                            <span className="file-meta">
                              <span className="file-name">{seq.id}</span>
                              <span className="file-size">{item.task?.component_label || item.task?.prompt || seq.filename}</span>
                            </span>
                            <span className="dl"><I.Eye size={14} /></span>
                          </button>
                        );
                      })}
                    </div>
                  )}
                  <div className="vfx-detail-kicker">{VFX_ELEMENT_LABELS[selected.element] || selected.element || "VFX"}</div>
                  <h2 className="vfx-detail-title">{selected.id}</h2>
                  <div className="vfx-result-note">
                    {resultItems.length > 1
                      ? `已把提示词拆成 ${resultItems.length} 个不同类型的独立特效顺序生成，后续特效参考前一张风格，当前预览 ${selected.id}。`
                      : resultSummary(result, result?.style || activeStyle)}
                  </div>
                  <img className="vfx-sheet" src={selected.url} alt={selected.id} />
                  <div className="vfx-download-row">
                    <a className="primary-btn" href={selected.url} download={selected.filename}>
                      <I.Download size={14} /> 下载序列 PNG
                    </a>
                    <button className="ghost-btn" onClick={downloadJson}>
                      <I.Code size={14} /> 下载生成参数
                    </button>
                  </div>
                  <div className="vfx-stats">
                    <VfxStat label="文件" value={selected.filename} />
                    <VfxStat label="规格" value={`${selected.cols || 3}x${selected.rows || 3} / ${selected.frames || 9} 帧`} />
                    <VfxStat label="背景" value={selected.background_removed ? "已扣除" : "未扣除"} />
                    <VfxStat label="推荐速度" value={`${selectedEffect?.task?.timing?.playback_fps || fps} fps`} />
                    <VfxStat label="组合时长" value={composition ? `${composition.total_frames || 1} 帧 / ${composition.fps || fps} fps` : "单段"} />
                    <VfxStat label="风格" value={activeStyle.name} />
                    <VfxStat label="模型" value={selected.model || activeModel.name || activeModel.id} />
                    <VfxStat label="消耗" value={fmtUsd(usageCostUsd(vfxUsageSummary || plannedUsage))} />
                    <VfxStat label="任务" value={selectedEffect?.task?.prompt || selected.id} />
                    <VfxStat label="路径" value={selected.url} />
                  </div>
                </>
              ) : (
                <div className="vfx-preview-placeholder">
                  没有默认占位资产。生成完成后才显示下载和序列信息。
                </div>
              )}
            </div>
          </section>
        )}
      </div>
    </>
  );
}

function VfxSpriteFrame({ asset, frame }) {
  const cols = asset.cols || 3;
  const rows = asset.rows || Math.ceil((asset.frames || 9) / cols);
  const col = frame % cols;
  const row = Math.floor(frame / cols);
  const x = cols <= 1 ? 0 : (col / (cols - 1)) * 100;
  const y = rows <= 1 ? 0 : (row / (rows - 1)) * 100;
  return (
    <div
      style={{
        width: "100%",
        height: "100%",
        backgroundImage: `url(${asset.url})`,
        backgroundPosition: `${x}% ${y}%`,
        backgroundRepeat: "no-repeat",
        backgroundSize: `${cols * 100}% ${rows * 100}%`,
      }}
    />
  );
}

function VfxCompositeFrame({ items, composition, frame }) {
  const byEffectId = new Map((items || []).map((item) => [item.id, item]));
  const bySequenceId = new Map((items || []).map((item) => [item.sequence?.id, item]));
  const layers = (composition?.components || [])
    .map((component) => {
      const item = byEffectId.get(component.effect_id) || bySequenceId.get(component.sequence_id);
      return { component, asset: item?.sequence };
    })
    .filter(({ asset }) => !!asset)
    .sort((a, b) => (a.component.layer || 0) - (b.component.layer || 0));

  const activeLayers = layers.map(({ component, asset }) => {
    const start = Number(component.start_frame || 0);
    const duration = Math.max(1, Number(component.duration_frames || asset.frames || 16));
    if (frame < start || frame >= start + duration) return null;
    const sourceFrames = Math.max(1, Number(asset.frames || component.source_frames || 16));
    const localFrame = duration <= 1
      ? 0
      : Math.min(sourceFrames - 1, Math.floor(((frame - start) / (duration - 1)) * sourceFrames));
    return (
      <div
        key={`${component.effect_id || component.sequence_id}-${component.layer}-${start}`}
        className="vfx-composite-layer"
        style={{
          opacity: component.opacity ?? 1,
          zIndex: component.layer || 0,
        }}
        title={component.component_label || component.component_type || "VFX"}
      >
        <VfxSpriteFrame asset={asset} frame={localFrame} />
      </div>
    );
  }).filter(Boolean);

  return (
    <div className="vfx-composite-frame">
      {activeLayers.length ? activeLayers : (
        <div className="vfx-composite-empty">Waiting for timed layer</div>
      )}
    </div>
  );
}

function VfxProgress({ progress, usage }) {
  if (!progress && !usage) return null;
  const pct = Math.max(0, Math.min(100, Number(progress?.progress || 0)));
  const tone = progress?.status === "error" ? " error" : progress?.status === "done" ? " done" : "";
  const calls = Number(usage?.image_calls || progress?.total || 0);
  const cost = usageCostUsd(usage);
  const tokens = Number(usage?.image_equiv_tokens || usage?.total_tokens || imageEquivTokensFromUsd(cost));
  return (
    <div className={"vfx-progress-card" + tone}>
      <div className="vfx-progress-head">
        <span>{progress?.phase || "等待生成"}</span>
        <b>{Math.round(pct)}%</b>
      </div>
      <div className="vfx-progress-bar">
        <span style={{ width: `${pct}%` }} />
      </div>
      <div className="vfx-progress-meta">
        <span>{progress?.detail || "选择模型后输入提示词开始生成"}</span>
        <span>{calls ? `${calls} PNG` : "0 PNG"} · {fmtUsd(cost)} · {fmtTokens(tokens)} tokens</span>
      </div>
    </div>
  );
}

function VfxStat({ label, value }) {
  return (
    <div className="vfx-stat">
      <div className="vfx-stat-label">{label}</div>
      <div className="vfx-stat-value" title={value}>{value}</div>
    </div>
  );
}

function ImageWorkflowModelMenu({
  models = [],
  activeModel = null,
  onModelChange,
  busy = false,
}) {
  const activeModelLabel = activeModel?.name || activeModel?.id || "选择模型";
  const activeModelWidth = Array.from(activeModelLabel).reduce((sum, ch) => {
    return sum + (/[\u0100-\uffff]/.test(ch) ? 2 : 1);
  }, 0);
  const modelSelectWidth = `calc(${Math.max(8, Math.min(18, activeModelWidth))}ch + 44px)`;
  return (
    <label className="workflow-model-menu" style={{ "--model-select-width": modelSelectWidth }}>
      <span><I.Cpu className="ico" size={13} /> 模型</span>
      <select
        value={activeModel?.id || ""}
        onChange={(e) => onModelChange?.(e.target.value)}
        disabled={busy || !models.length}
        title={activeModel ? `${activeModelLabel} · ${activeModel.api_model || activeModel.id}` : "请选择模型"}
      >
        {!models.length && <option value="">加载模型</option>}
        {models.map((model) => (
          <option key={model.id} value={model.id}>
            {model.name || model.id}
          </option>
        ))}
      </select>
    </label>
  );
}

function ImageWorkflowProgressBubble({ progress = null, usage = null, onStop = null }) {
  if (!progress) return null;
  const pct = Math.max(0, Math.min(100, Number(progress?.progress || 0)));
  const tone = progress?.status === "error" ? " error" : progress?.status === "done" ? " done" : "";
  const calls = Number(usage?.image_calls || progress?.total || 0);
  const cost = usageCostUsd(usage);
  const tokens = Number(usage?.image_equiv_tokens || usage?.total_tokens || imageEquivTokensFromUsd(cost));
  return (
    <div className={"workflow-progress-card" + tone}>
      <div className="workflow-progress-head">
        <div>
          <strong>{progress.phase || "生成中"}</strong>
          <span>{progress.detail || "正在调用生成服务"}</span>
        </div>
        {onStop && (
          <button className="workflow-progress-stop" type="button" onClick={onStop}>
            <span className="send-stop-square" aria-hidden="true" /> 停止
          </button>
        )}
      </div>
      <div className="workflow-progress-bar">
        <span style={{ width: `${pct}%` }} />
      </div>
      <div className="workflow-progress-meta">
        <span>{Math.round(pct)}%</span>
        <span>{calls ? `${calls} PNG` : "等待返回"} · {fmtUsd(cost)} · {fmtTokens(tokens)} tokens</span>
      </div>
    </div>
  );
}

function ImageWorkflowQuotaLine({
  refreshKey = 0,
  authUser = null,
  authChecked = true,
  usage = null,
}) {
  return (
    <div className="workflow-composer-quota">
      <QuotaWindow
        refreshKey={refreshKey}
        sessionCostUsd={usageCostUsd(usage)}
        enabled={!!authChecked && !!authUser}
      />
    </div>
  );
}

const UI_WORKFLOW_STYLES = [
  {
    id: "low-poly",
    name: "低多边形",
    description: "切面造型、块面明暗、轮廓清楚。",
    tags: ["低多边形", "切面", "块面"],
    swatch: ["#366c8d", "#f2b84b", "#7bbf6a"],
    prompt: "Low-poly game UI art asset pack on a pure white background. Generate polished game UI art assets only, using faceted geometry, flat-shaded planes, crisp silhouettes, chunky bevels, clean readable shapes, and stylized low-poly material lighting. Include the UI asset types requested by the user such as logo, icons, buttons, bars, counters, frames, panels, tabs, badges, and ornaments. No full screen mockup, no wireframes, no plain placeholder rectangles.",
    instruction: "Use a low-poly art direction for every isolated asset: faceted surfaces, angular silhouettes, clear block shading, readable icon shapes, and enough white margin for clean extraction.",
  },
  {
    id: "cartoon",
    name: "卡通",
    description: "圆润夸张、高饱和、粗描边。",
    tags: ["卡通", "圆润", "描边"],
    swatch: ["#ff7a45", "#ffd25a", "#51c4ff"],
    prompt: "Cartoon game UI art asset pack on a pure white background. Generate polished game UI art assets only, using rounded shapes, expressive proportions, saturated colors, thick clean outlines, soft highlights, and playful readable forms. Include the UI asset types requested by the user such as logo, icons, buttons, bars, counters, frames, panels, tabs, badges, and ornaments. No full screen mockup, no wireframes, no plain placeholder rectangles.",
    instruction: "Use a cartoon art direction for every isolated asset: rounded forms, bold outlines, saturated palette, soft highlights, expressive icon silhouettes, and clean spacing for extraction.",
  },
  {
    id: "realistic",
    name: "写实",
    description: "真实材质、细节光影、精致质感。",
    tags: ["写实", "材质", "光影"],
    swatch: ["#1f2933", "#b9a17a", "#e6e0d3"],
    prompt: "Realistic game UI art asset pack on a pure white background. Generate polished game UI art assets only, using believable materials, detailed metal, leather, glass, stone, fabric, polished shadows, subtle edge wear, accurate specular highlights, and production-quality realism while keeping icons readable. Include the UI asset types requested by the user such as logo, icons, buttons, bars, counters, frames, panels, tabs, badges, and ornaments. No full screen mockup, no wireframes, no plain placeholder rectangles.",
    instruction: "Use a realistic art direction for every isolated asset: believable material rendering, nuanced shadows, specular highlights, texture detail, and clean extraction margins.",
  },
  {
    id: "soulslike",
    name: "魂系",
    description: "暗黑残破、哥特金属、压迫感。",
    tags: ["魂系", "暗黑", "哥特"],
    swatch: ["#151515", "#6f1d1b", "#b68a4a"],
    prompt: "Soulslike dark fantasy game UI art asset pack on a pure white background. Generate polished game UI art assets only, using grim gothic ornament, tarnished metal, cracked stone, dark leather, ember glow, aged gold, sharp silhouettes, solemn ritual details, and oppressive premium fantasy styling. Include the UI asset types requested by the user such as logo, icons, buttons, bars, counters, frames, panels, tabs, badges, and ornaments. No full screen mockup, no wireframes, no plain placeholder rectangles.",
    instruction: "Use a soulslike dark fantasy art direction for every isolated asset: aged metal, gothic ornament, cracked surfaces, ember accents, sharp readable silhouettes, and clean extraction margins.",
  },
  {
    id: "minimal",
    name: "简约",
    description: "少量色彩、干净轮廓、轻量信息。",
    tags: ["简约", "干净", "轻量"],
    swatch: ["#f7f7f4", "#242424", "#79a7ff"],
    prompt: "Minimal game UI art asset pack on a pure white background. Generate polished game UI art assets only, using simple silhouettes, restrained colors, clean geometric structure, subtle shadows, elegant spacing, very clear icon readability, and refined modern game interface styling. Include the UI asset types requested by the user such as logo, icons, buttons, bars, counters, frames, panels, tabs, badges, and ornaments. No full screen mockup, no wireframes, no plain placeholder rectangles.",
    instruction: "Use a minimal art direction for every isolated asset: clean shapes, restrained palette, high readability, subtle dimensionality, and generous white margin for extraction.",
  },
  {
    id: "sci-fi",
    name: "科幻",
    description: "硬表面、发光线条、HUD 科技感。",
    tags: ["科幻", "HUD", "发光"],
    swatch: ["#071527", "#24e6ff", "#6c63ff"],
    prompt: "Sci-fi game UI art asset pack on a pure white background. Generate polished game UI art assets only, using hard-surface panels, holographic glass, neon glow strips, technical cut lines, futuristic HUD shapes, luminous buttons, readable iconography, and sleek advanced material styling. Include the UI asset types requested by the user such as logo, icons, buttons, bars, counters, frames, panels, tabs, badges, and ornaments. No full screen mockup, no wireframes, no flat placeholder boxes.",
    instruction: "Use a sci-fi art direction for every isolated asset: hard-surface forms, luminous accents, HUD details, glass or metal materials, readable icon silhouettes, and clean extraction margins.",
  },
  {
    id: "ornate",
    name: "华丽",
    description: "复杂装饰、宝石金属、仪式感。",
    tags: ["华丽", "宝石", "装饰"],
    swatch: ["#5a1f7a", "#d6a63a", "#ef5aa6"],
    prompt: "Ornate premium game UI art asset pack on a pure white background. Generate polished game UI art assets only, using luxurious decoration, gem inlays, gold trim, intricate filigree, layered bevels, rich color accents, ceremonial frames, and high-end fantasy or royal styling while keeping all icons readable. Include the UI asset types requested by the user such as logo, icons, buttons, bars, counters, frames, panels, tabs, badges, and ornaments. No full screen mockup, no wireframes, no plain placeholder rectangles.",
    instruction: "Use an ornate premium art direction for every isolated asset: rich decoration, gem and gold details, layered borders, crisp readable shapes, and enough white margin for clean extraction.",
  },
  {
    id: "custom",
    name: "自定义美术",
    description: "用附加要求指定自己的美术语言。",
    tags: ["自定义", "风格", "资产"],
    swatch: ["#202020", "#9a9a9a", "#f7f7f7"],
    prompt: "Game UI art asset pack on a pure white background. Generate polished game UI art assets only: logo, icons, buttons, status bars, counters, frames, panels, tabs and decorative ornaments. Each asset must be isolated, centered, and suitable for transparent PNG extraction.",
    instruction: "Prioritize distinct art assets over screen layout: logo, icon set, button set, counters, bars, frames, panels, tabs and ornaments.",
  },
];

const UI_WORKFLOW_QUICK = [
  "暗黑哥特卡牌 RPG UI，血红黑金，卡牌、符文、按钮和状态条",
  "海盗航海冒险 UI，木质船舷、罗盘、藏宝图、金币和主按钮",
  "沙漠神庙策略 UI，砂岩金色、圣甲虫、兵种卡、资源计数器",
  "二次元魔法学院 UI，徽章、法术图标、学院按钮、能量条",
];

function UiWorkflowPage({ session = null, onUpdate = null, onPromoteSession = null, onBack, onResizePreview, authUser = null, authChecked = true }) {
  const saved = session?.workflowState || {};
  const sessionId = session?.id || null;
  const [input, setInput] = useState(saved.input || "");
  const [styleId, setStyleId] = useState(saved.styleId || "low-poly");
  const [customInstruction, setCustomInstruction] = useState(saved.customInstruction || "");
  const [busy, setBusy] = useState(!!(session?.running && saved.activeJob?.id));
  const [models, setModels] = useState(VFX_FALLBACK_MODELS);
  const [imageModelId, setImageModelId] = useState(saved.imageModelId || "gpt-image-2");
  const [uiProgress, setUiProgress] = useState(saved.progress || null);
  const [plannedUsage, setPlannedUsage] = useState(saved.plannedUsage || null);
  const [uiUsageSummary, setUiUsageSummary] = useState(saved.usageSummary || null);
  const [usageRefreshKey, setUsageRefreshKey] = useState(0);
  const [status, setStatus] = useState(saved.status || "等待输入");
  const [result, setResult] = useState(null);
  const [previewMode, setPreviewMode] = useState(saved.previewMode || "transparent");
  const [previewOpen, setPreviewOpen] = useState(saved.previewOpen ?? true);
  const [messages, setMessages] = useState(saved.messages || defaultWorkflowMessages("ui"));
  const [workflowJob, setWorkflowJob] = useState(saved.activeJob || null);

  const uiUsageSummaryRef = useRef(saved.usageSummary || null);
  const uiAbortRef = useRef(null);
  const uiJobRef = useRef(saved.activeJob || null);

  useEffect(() => {
    let alive = true;
    if (window.MCAPI?.vfx?.models) {
      window.MCAPI.vfx.models()
        .then((r) => {
          if (alive && r?.models?.length) {
            const imageModels = r.models.filter((m) => (m.provider || "openai") === "openai");
            const nextModels = imageModels.length ? imageModels : r.models;
            setModels(nextModels);
            setImageModelId((current) => nextModels.some((m) => m.id === current) ? current : (r.default || nextModels[0].id));
          }
        })
        .catch((err) => console.warn("UI image models fallback:", err));
    }
    if (window.MCAPI?.ui?.config) {
      window.MCAPI.ui.config()
        .then((r) => {
          if (!alive) return;
          if (r?.imageModel) {
            setImageModelId((current) => current || r.imageModel);
          }
          setStatus(r?.hasApiKey ? `${r.imageModel || "gpt-image-2"} 已就绪` : "UI 服务未配置 API key");
        })
        .catch(() => {
          if (alive) setStatus("UI 服务未连接");
        });
    }
    return () => { alive = false; };
  }, []);

  const activeStyle = useMemo(() => {
    return UI_WORKFLOW_STYLES.find((s) => s.id === styleId) || UI_WORKFLOW_STYLES[0];
  }, [styleId]);
  const activeModel = useMemo(() => {
    return models.find((m) => m.id === imageModelId) || models[0] || VFX_FALLBACK_MODELS[0];
  }, [models, imageModelId]);
  const components = result?.package?.components || [];
  const expectedCount = result?.expectedComponentCount || result?.plan?.componentCount || 0;
  const primaryPreview = previewMode === "grid"
    ? result?.componentGrid?.imageDataUrl
    : result?.transparent?.imageDataUrl;
  const primaryDownloadName = previewMode === "grid"
    ? "ui-components-white-grid.png"
    : "ui-components-transparent-grid.png";
  const makePlannedUsage = (count, model = activeModel) => {
    const calls = Math.max(1, Number(count || 1));
    const cost = calls * Number(model?.estimated_cost_usd || 0);
    const imageTokens = imageEquivTokensFromUsd(cost);
    return {
      currency: "USD",
      image_calls: calls,
      image_cost_usd: cost,
      image_equiv_tokens: imageTokens,
      total_tokens: imageTokens,
      total_cost_usd: cost,
      cost_usd: cost,
      est_cost_usd: cost,
      estimated: true,
    };
  };
  const rememberUiUsage = (summary) => {
    if (!summary) return;
    const next = mergeUsageSummary(uiUsageSummaryRef.current, summary);
    uiUsageSummaryRef.current = next;
    setUiUsageSummary(next);
    setPlannedUsage(null);
    setUsageRefreshKey((n) => n + 1);
  };

  useEffect(() => {
    uiJobRef.current = workflowJob;
  }, [workflowJob]);

  useEffect(() => {
    if (!sessionId || !session?.workflowAssetStored) return;
    let alive = true;
    loadWorkflowAsset(sessionId)
      .then((record) => {
        if (alive && record?.result) setResult(record.result);
      })
      .catch((err) => console.warn("UI asset restore failed:", err));
    return () => { alive = false; };
  }, [sessionId]);

  useEffect(() => {
    if (!sessionId || !result) return;
    saveWorkflowAsset(sessionId, "ui", result)
      .then(() => onUpdate && onUpdate({ workflowAssetStored: true, updatedAt: Date.now() }))
      .catch((err) => console.warn("UI asset save failed:", err));
  }, [sessionId, result]);

  useEffect(() => {
    if (!sessionId || !onUpdate) return;
    const firstUser = messages.find((m) => m.role === "user")?.text || session?.prompt || "";
    onUpdate({
      prompt: firstUser,
      title: workflowTitleFromPrompt("ui", firstUser),
      model: activeModel?.id || "ui-kit",
      running: busy,
      updatedAt: Date.now(),
      workflowState: compactWorkflowState({
        input,
        styleId,
        customInstruction,
        imageModelId,
        messages,
        previewMode,
        previewOpen,
        status,
        progress: uiProgress,
        plannedUsage,
        usageSummary: uiUsageSummary,
        activeJob: workflowJob,
      }),
    });
  }, [sessionId, input, styleId, customInstruction, imageModelId, messages, previewMode, previewOpen, status, uiProgress, plannedUsage, uiUsageSummary, workflowJob, busy, activeModel?.id]);

  const buildPayload = (text) => {
    const userPrompt = String(text || "").trim();
    const prompt = `${activeStyle.prompt}\n\nUser request: ${userPrompt}`;
    const instruction = [
      activeStyle.instruction,
      customInstruction.trim(),
      "Use a standard equal-size grid. Put exactly one complete asset in each cell. Do not place multiple tiny assets inside one cell. Leave enough white margin for clean background removal.",
    ].filter(Boolean).join("\n");
    return {
      prompt,
      instruction,
      gridSize: "1536x1024",
      quality: "medium",
      threshold: 246,
      image_model: activeModel.id,
      imageModel: activeModel.id,
    };
  };

  const resultSummary = (out) => {
    const grid = out?.plan?.grid || out?.grid || {};
    const count = out?.package?.count || 0;
    const used = out?.transparent?.usedCount || count;
    const planned = out?.expectedComponentCount || out?.plan?.componentCount || count;
    return `组件包已生成：规划 ${planned} 个，实际切出 ${count} 个 PNG；标准网格 ${grid.cols || "?"} × ${grid.rows || "?"}，透明网格使用 ${used} 个素材。`;
  };

  const applyUiResult = (out, modelForRun = activeModel) => {
    const imageCalls = Math.max(1, Number(out.extractionAttempts?.length || 1));
    const estimatedUsage = makePlannedUsage(imageCalls, modelForRun);
    const usageForRun = out.usage_summary || out.usage || estimatedUsage;
    if (out.usage_summary || out.usage) {
      rememberUiUsage(usageForRun);
    } else if (window.MCAPI?.billing?.recordImage) {
      window.MCAPI.billing.recordImage({
        kind: "ui_component_image",
        image_calls: imageCalls,
        image_cost_usd: usageCostUsd(estimatedUsage),
        image_model: modelForRun.id || modelForRun.api_model || "",
        provider: modelForRun.provider || "openai",
        session_id: sessionId,
      }).then((r) => {
        const billedUsage = r?.usage_summary || r?.usage;
        if (billedUsage) {
          rememberUiUsage(billedUsage);
          setResult((prev) => prev ? { ...prev, usage: billedUsage, usage_summary: billedUsage, transaction: r.transaction } : prev);
        }
      }).catch((err) => {
        console.warn("UI image billing failed:", err);
        rememberUiUsage(estimatedUsage);
      });
    } else {
      rememberUiUsage(estimatedUsage);
    }
    setResult(out);
    setPreviewMode("transparent");
    setMessages((prev) => [...prev, { role: "assistant", text: resultSummary(out) }]);
    setUiProgress({
      status: "done",
      progress: 100,
      phase: "生成完成",
      detail: `${imageCalls} PNG API call · ${fmtUsd(usageCostUsd(usageForRun))}`,
      model: modelForRun,
      total: imageCalls,
      completed: imageCalls,
    });
    setStatus("生成完成");
    setWorkflowJob(null);
  };

  const pollUiJob = async (jobId, { signal, modelForRun = activeModel } = {}) => {
    let tick = 0;
    while (true) {
      if (signal?.aborted) throw makeAbortError();
      const job = await window.MCAPI.ui.directComponentJob(jobId, { signal });
      if (job.status === "done") return job.result;
      if (job.status === "cancelled") throw makeAbortError("cancelled");
      if (job.status === "error") throw new Error(job.error || "UI component job failed");
      tick += 1;
      setUiProgress((prev) => ({
        ...(prev || {}),
        status: "running",
        progress: Math.min(94, Math.max(prev?.progress || 18, 18 + tick * 4)),
        phase: "后台生成 UI 组件",
        detail: `${modelForRun.name || modelForRun.id} · 切换会话或刷新后可继续恢复`,
        model: modelForRun,
        total: 1,
      }));
      await waitWithSignal(1500, signal);
    }
  };

  useEffect(() => {
    if (!sessionId || !workflowJob?.id) return;
    if (["done", "error", "cancelled"].includes(workflowJob.status)) return;
    let alive = true;
    const controller = new AbortController();
    uiAbortRef.current = controller;
    setBusy(true);
    setStatus("生成中");
    pollUiJob(workflowJob.id, { signal: controller.signal, modelForRun: activeModel })
      .then((out) => {
        if (!alive || !out) return;
        applyUiResult(out, activeModel);
      })
      .catch((err) => {
        if (!alive || err?.name === "AbortError") return;
        setMessages((prev) => [...prev, { role: "assistant", text: `UI 组件包生成失败：${err?.message || err}` }]);
        setStatus("生成失败");
        setUiProgress((prev) => ({ ...(prev || {}), status: "error", phase: "生成失败", detail: err?.message || String(err || "") }));
      })
      .finally(() => {
        if (!alive) return;
        if (uiAbortRef.current === controller) uiAbortRef.current = null;
        setBusy(false);
      });
    return () => {
      alive = false;
      controller.abort();
    };
  }, [sessionId]);

  const stopGenerate = () => {
    if (!busy) return;
    const controller = uiAbortRef.current;
    if (controller && !controller.signal.aborted) controller.abort();
    const jobId = uiJobRef.current?.id || workflowJob?.id;
    if (jobId && window.MCAPI?.ui?.cancelDirectComponentJob) {
      window.MCAPI.ui.cancelDirectComponentJob(jobId).catch((err) => console.warn("UI job cancel failed:", err));
    }
    setWorkflowJob(null);
    setStatus("正在停止");
    setUiProgress((prev) => prev ? {
      ...prev,
      status: "stopping",
      phase: "正在停止生成",
      detail: "正在中断当前请求",
    } : prev);
  };

  const runGenerate = async (quickText = "") => {
    const text = String(quickText || input || "").trim();
    if (!text || busy) return;
    const controller = new AbortController();
    uiAbortRef.current = controller;
    const requestSignal = controller.signal;
    const nextMessages = [...messages, { role: "user", text }];
    const modelForRun = activeModel;
    if (onPromoteSession) {
      onPromoteSession({
        prompt: text,
        title: workflowTitleFromPrompt("ui", text),
        model: modelForRun.id,
      });
    }
    setMessages(nextMessages);
    setInput("");
    setBusy(true);
    const estimate = makePlannedUsage(1, modelForRun);
    setPlannedUsage(estimate);
    setUiProgress({
      status: "running",
      progress: 12,
      phase: "生成 UI 组件包",
      detail: `${modelForRun.name || modelForRun.id} · 预计消耗 ${fmtUsd(usageCostUsd(estimate))}`,
      model: modelForRun,
      total: 1,
      completed: 0,
    });
    let progressTimer = null;
    setStatus("生成中");
    try {
      if (!window.MCAPI?.ui?.runDirectComponentPipeline && !window.MCAPI?.ui?.startDirectComponentJob) {
        throw new Error("UI API 未加载，请确认 127.0.0.1:4317 的组件管线服务已启动。");
      }
      let out = null;
      const payload = buildPayload(text);
      if (window.MCAPI.ui.startDirectComponentJob && window.MCAPI.ui.directComponentJob) {
        const job = await window.MCAPI.ui.startDirectComponentJob({ ...payload, signal: requestSignal });
        const nextJob = {
          id: job.job_id,
          status: job.status || "running",
          startedAt: Date.now(),
          payload: { ...payload, signal: undefined },
        };
        setWorkflowJob(nextJob);
        out = await pollUiJob(job.job_id, { signal: requestSignal, modelForRun });
      } else {
        const startAt = Date.now();
        progressTimer = window.setInterval(() => {
          setUiProgress((prev) => {
            if (!prev || prev.status === "done" || prev.status === "error") return prev;
            const elapsed = Math.floor((Date.now() - startAt) / 1000);
            return {
              ...prev,
              progress: Math.min(92, Math.max(prev.progress || 0, 18 + elapsed * 4)),
              phase: "真实调用 API 生成 UI 组件",
            };
          });
        }, 1000);
        out = await window.MCAPI.ui.runDirectComponentPipeline({ ...payload, signal: requestSignal });
      }
      if (!out?.transparent?.imageDataUrl || !out?.package?.zipDataUrl) {
        throw new Error("组件管线没有返回透明网格或 ZIP。");
      }
      if (progressTimer) {
        window.clearInterval(progressTimer);
        progressTimer = null;
      }
      applyUiResult(out, modelForRun);
    } catch (err) {
      const aborted = requestSignal.aborted || err?.name === "AbortError";
      if (aborted) {
        setMessages((prev) => [...prev, { role: "assistant", text: "已停止生成。" }]);
        setStatus("已停止");
        setUiProgress(null);
        setPlannedUsage(null);
        setWorkflowJob(null);
        return;
      }
      const detail = err?.message || String(err || "未知错误");
      setMessages((prev) => [...prev, { role: "assistant", text: `UI 组件包生成失败：${detail}` }]);
      setStatus("生成失败");
    } finally {
      if (progressTimer) window.clearInterval(progressTimer);
      if (uiAbortRef.current === controller) uiAbortRef.current = null;
      setBusy(false);
    }
  };

  const downloadJson = () => {
    if (!result) return;
    const payload = JSON.stringify({
      style: activeStyle,
      customInstruction,
      plan: result.plan,
      grid: result.grid,
      expectedComponentCount: result.expectedComponentCount,
      package: {
        count: result.package?.count || 0,
        assetCount: result.package?.assetCount || 0,
        components: (result.package?.components || []).map((c) => ({
          name: c.name,
          width: c.width,
          height: c.height,
          cell: c.cell,
          plannedName: c.plannedName,
          plannedRole: c.plannedRole,
        })),
      },
    }, null, 2);
    const blob = new Blob([payload], { type: "application/json" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "ui-component-package.json";
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <>
      <header className="topbar vfx-topbar ui-topbar">
        <button className="icon-btn" onClick={onBack} title="返回工作流">
          <I.X size={16} />
        </button>
        <div className="topbar-title">
          UI 工作流
          <span className="sep">/</span>
          <span className="small">Game UI Kit</span>
        </div>
        <div className="spacer" />
        <span className="vfx-status-pill">{busy ? "生成中" : status}</span>
        <button className="ghost-btn" onClick={() => setPreviewOpen((v) => !v)}>
          <I.Eye size={14} /> {previewOpen ? "隐藏预览" : "显示预览"}
        </button>
        <a
          className={"primary-btn" + (result?.package?.zipDataUrl ? "" : " disabled")}
          href={result?.package?.zipDataUrl || "#"}
          download="ui-component-package.zip"
          aria-disabled={!result?.package?.zipDataUrl}
          onClick={(e) => { if (!result?.package?.zipDataUrl) e.preventDefault(); }}
        >
          <I.Download size={14} /> 下载 ZIP
        </a>
      </header>

      <div className={"workspace vfx-native-workspace ui-native-workspace" + (previewOpen ? "" : " no-preview")}>
        <section className="convo">
          <div className="convo-stream">
            <div className="convo-inner">
              {messages.map((m, idx) => (
                m.role === "user" ? (
                  <div className="turn user" key={idx}>
                    <div className="bubble-user">{m.text}</div>
                    <div className="bubble-meta-row">
                      <span className="meta-chip"><I.Image size={12} /> {activeStyle.name}</span>
                    </div>
                  </div>
                ) : (
                  <div className="turn assistant" key={idx}>
                    <AssistantHead busy={busy && idx === messages.length - 1} />
                    <div className="assistant-body">{m.text}</div>
                  </div>
                )
              ))}

              {busy && uiProgress && (
                <div className="turn assistant workflow-progress-turn">
                  <AssistantHead busy />
                  <div className="assistant-body" style={{ width: "100%" }}>
                    <ImageWorkflowProgressBubble
                      progress={uiProgress}
                      usage={plannedUsage}
                      onStop={stopGenerate}
                    />
                  </div>
                </div>
              )}

              <div className="turn assistant">
                <AssistantHead />
                <div className="assistant-body" style={{ width: "100%" }}>
                  <div className="info-card vfx-style-panel">
                    <div className="info-card-head">
                      <I.Wand className="ico" size={14} /> UI 风格
                    </div>
                    <div className="vfx-style-grid-native">
                      {UI_WORKFLOW_STYLES.map((style) => {
                        const swatch = style.swatch || ["#111", "#888", "#fff"];
                        return (
                          <button
                            key={style.id}
                            type="button"
                            className={"vfx-style-option" + (style.id === activeStyle.id ? " active" : "")}
                            onClick={() => setStyleId(style.id)}
                          >
                            <span className="vfx-style-swatch" style={{ "--c1": swatch[0], "--c2": swatch[1], "--c3": swatch[2] }} />
                            <span className="vfx-style-copy">
                              <span className="vfx-style-name">{style.name}</span>
                              <span className="vfx-style-desc">{style.description}</span>
                              <span className="vfx-style-tags">
                                {(style.tags || []).slice(0, 3).map((tag) => <span key={tag}>{tag}</span>)}
                              </span>
                            </span>
                          </button>
                        );
                      })}
                    </div>
                    <div className="vfx-custom-style-box ui-custom-box">
                      <label>附加拆分要求</label>
                      <textarea
                        rows={2}
                        value={customInstruction}
                        onChange={(e) => setCustomInstruction(e.target.value)}
                        placeholder="例：多给按钮、不要文字、图标偏 Q 版、资源计数器要金币/钻石/体力"
                      />
                      <span>这段会附加到 GPT 的组件规划和网格生成提示词里。</span>
                    </div>
                  </div>

                  {result && (
                    <div className="export-card vfx-result-card">
                      <div className="info-card-head">
                        <I.Download className="ico" size={14} /> 组件包产物
                      </div>
                      <div className="file-grid">
                        <a className="file-row" href={result.package?.zipDataUrl || "#"} download="ui-component-package.zip">
                          <span className="file-icon">ZIP</span>
                          <span className="file-meta">
                            <span className="file-name">ui-component-package.zip</span>
                            <span className="file-size">{result.package?.count || 0} PNG components</span>
                          </span>
                          <span className="dl"><I.Download size={14} /></span>
                        </a>
                        <a className="file-row" href={result.transparent?.imageDataUrl || "#"} download="ui-components-transparent-grid.png">
                          <span className="file-icon">PNG</span>
                          <span className="file-meta">
                            <span className="file-name">transparent-grid.png</span>
                            <span className="file-size">{result.transparent?.width || 0}×{result.transparent?.height || 0}</span>
                          </span>
                          <span className="dl"><I.Download size={14} /></span>
                        </a>
                        <button className="file-row" type="button" onClick={downloadJson}>
                          <span className="file-icon">JSON</span>
                          <span className="file-meta">
                            <span className="file-name">component-plan.json</span>
                            <span className="file-size">plan + component metadata</span>
                          </span>
                          <span className="dl"><I.Download size={14} /></span>
                        </button>
                      </div>
                    </div>
                  )}
                </div>
              </div>
            </div>
          </div>

          <div className="convo-composer">
            <div className="convo-composer-inner">
              <div className="preset-row vfx-prompt-presets">
                {UI_WORKFLOW_QUICK.map((s) => (
                  <button key={s} className="preset-chip" onClick={() => runGenerate(s)} disabled={busy}>
                    {s}
                  </button>
                ))}
              </div>
              <div className="composer vfx-composer-native">
                <textarea
                  rows={2}
                  value={input}
                  disabled={busy}
                  onChange={(e) => setInput(e.target.value)}
                  placeholder="描述要生成的游戏 UI 美术组件包，例如：冰雪魔法塔防手游 UI，logo、技能图标、建造按钮、资源计数器、血条"
                  onKeyDown={(e) => {
                    if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
                      e.preventDefault();
                      runGenerate();
                    }
                  }}
                />
                <div className="composer-bar">
                  <div className="style-group vfx-current-style">
                    <span className="style-label">UI 风格：</span>
                    <div className="style-pills">
                      <button className="style-pill active" type="button">{activeStyle.name}</button>
                    </div>
                  </div>
                  <ImageWorkflowModelMenu
                    models={models}
                    activeModel={activeModel}
                    onModelChange={setImageModelId}
                    busy={busy}
                  />
                  <button
                    className={"send-btn" + (busy ? " stop" : "")}
                    onClick={busy ? stopGenerate : () => runGenerate()}
                    disabled={!busy && !input.trim()}
                    title={busy ? "停止生成" : "生成 UI 组件包"}
                    aria-label={busy ? "停止生成" : "生成 UI 组件包"}
                  >
                    {busy ? <span className="send-stop-square" aria-hidden="true" /> : <I.Send size={16} />}
                  </button>
                </div>
              </div>
              <ImageWorkflowQuotaLine
                refreshKey={usageRefreshKey}
                authUser={authUser}
                authChecked={authChecked}
                usage={uiUsageSummary || plannedUsage}
              />
            </div>
          </div>
        </section>

        {previewOpen && (onResizePreview ? <Resizer onResize={onResizePreview} /> : <div className="resizer-v" />)}
        {previewOpen && (
          <section className="preview vfx-preview-pane ui-preview-pane">
            <div className="preview-tabs">
              <button className={"preview-tab" + (previewMode === "transparent" ? " active" : "")} type="button" onClick={() => setPreviewMode("transparent")}>
                透明网格
              </button>
              <button className={"preview-tab" + (previewMode === "grid" ? " active" : "")} type="button" onClick={() => setPreviewMode("grid")}>
                白底原图
              </button>
              <button className={"preview-tab" + (previewMode === "components" ? " active" : "")} type="button" onClick={() => setPreviewMode("components")}>
                组件
              </button>
              <button className={"preview-tab" + (previewMode === "plan" ? " active" : "")} type="button" onClick={() => setPreviewMode("plan")}>
                规划
              </button>
            </div>
            <div className="preview-stage vfx-preview-stage ui-preview-stage">
              {result ? (
                previewMode === "components" ? (
                  <div className="ui-component-grid">
                    {components.map((component, index) => (
                      <a
                        className="ui-component-card"
                        key={component.name || index}
                        href={component.imageDataUrl}
                        download={component.name || `component-${index + 1}.png`}
                      >
                        <span className="ui-component-thumb">
                          <img src={component.imageDataUrl} alt={component.plannedName || component.name} />
                        </span>
                        <span className="ui-component-name">{component.plannedName || component.name}</span>
                        <span className="ui-component-meta">{component.width}×{component.height}</span>
                      </a>
                    ))}
                  </div>
                ) : previewMode === "plan" ? (
                  <pre className="ui-plan-code">{JSON.stringify(result.plan || {}, null, 2)}</pre>
                ) : (
                  <div className="ui-preview-image-frame">
                    <img className="ui-preview-image" src={primaryPreview} alt={previewMode === "grid" ? "white grid" : "transparent grid"} />
                  </div>
                )
              ) : (
                <div className="preview-empty vfx-empty-state">
                  <I.Image size={34} />
                  <div>等待生成</div>
                  <span>输入主题后会显示透明组件网格、白底原图、单个组件列表和 GPT 规划。</span>
                </div>
              )}
            </div>
            <div className="vfx-preview-detail ui-preview-detail">
              {result ? (
                <>
                  <div className="vfx-result-note">
                    {resultSummary(result)}
                  </div>
                  <div className="vfx-download-row ui-download-row">
                    <a className="primary-btn" href={result.package?.zipDataUrl || "#"} download="ui-component-package.zip">
                      <I.Download size={14} /> 下载组件 ZIP
                    </a>
                    {primaryPreview && (
                      <a className="ghost-btn" href={primaryPreview} download={primaryDownloadName}>
                        <I.Download size={14} /> 下载当前 PNG
                      </a>
                    )}
                    <button className="ghost-btn" onClick={downloadJson}>
                      <I.Code size={14} /> 下载 JSON
                    </button>
                  </div>
                  <div className="vfx-stats">
                    <VfxStat label="模型" value={result.model || "gpt-image-2"} />
                    <VfxStat label="规划" value={`${result.plan?.componentCount || expectedCount || 0} 个组件`} />
                    <VfxStat label="网格" value={`${result.plan?.grid?.cols || "?"} × ${result.plan?.grid?.rows || "?"}`} />
                    <VfxStat label="切出" value={`${result.package?.count || 0} PNG`} />
                    <VfxStat label="检测" value={`${result.transparent?.detectedCount || 0} / 使用 ${result.transparent?.usedCount || 0}`} />
                    <VfxStat label="尺寸" value={result.gridSize || "1536x1024"} />
                    <VfxStat label="风格" value={activeStyle.name} />
                    <VfxStat label="API" value={window.MCAPI?.ui?.base || "未配置"} />
                  </div>
                </>
              ) : (
                <div className="vfx-preview-placeholder">
                  当前工作流直接产出组件图和组件 ZIP。
                </div>
              )}
            </div>
          </section>
        )}
      </div>
    </>
  );
}

// ---------- Effects "Coming Soon" page ----------
function EffectsSoonPage({ onBack }) {
  const [email, setEmail] = useState("");
  const [joined, setJoined] = useState(false);
  const features = [
    { icon: I.Effects, title: "粒子与火焰", body: "为模型附加轨迹粒子、火焰、闪电等实时特效,导出为引擎兼容的 VFX。" },
    { icon: I.Sparkle, title: "光效与光环", body: "霓虹描边、自发光通道、动态光环 — 适配 Bedrock 与 Java 渲染。" },
    { icon: I.Footprints, title: "动作触发", body: "把特效绑到动画事件:落地烟尘、攻击挥砍、跑步残影。" },
    { icon: I.Texture, title: "Shader 图层", body: "可替换的法线 / 发光 / 滚动 UV 着色层,无需手写 GLSL。" },
  ];
  return (
    <div className="soon-page">
      <div className="soon-inner">
        <div className="soon-eyebrow"><span className="dot" />Coming Q3 2025</div>
        <h1 className="soon-title">特效工作流</h1>
        <p className="soon-sub">为已生成的资产附加粒子、光效、Shader 图层 — 一句话给模型加上"灵魂"。</p>

        <div className="soon-grid">
          {features.map((f) => {
            const Ico = f.icon;
            return (
              <div key={f.title} className="soon-card">
                <Ico className="ico" size={20} />
                <h4>{f.title}</h4>
                <p>{f.body}</p>
              </div>
            );
          })}
        </div>

        {!joined ? (
          <form className="soon-waitlist" onSubmit={(e) => { e.preventDefault(); if (email) setJoined(true); }}>
            <input
              type="email"
              placeholder="留下邮箱,上线时第一时间通知"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
            />
            <button type="submit" className="primary-btn">加入候补</button>
          </form>
        ) : (
          <div className="soon-waitlist" style={{ justifyContent: "center", color: "var(--good)" }}>
            ✓ 已加入候补 — 上线时会发邮件给 {email}
          </div>
        )}

        <div className="soon-back">
          <button className="ghost-btn" onClick={onBack}>← 返回完整资产工作流</button>
        </div>
      </div>
    </div>
  );
}

// ---------- Library / Community Pages ----------
// MY_ASSETS / COMMUNITY_FULL replaced by /api/library/list and
// /api/community/list — see the page components below.

function LibraryPage({ onOpen, onNew, onBackToWorkflow }) {
  // Pull live sessions from /api/library/list (server-side SESSIONS map
  // + persisted on-disk records, merged in the backend so My Assets
  // survives restarts).
  const [items, setItems] = useState([]);
  const [loaded, setLoaded] = useState(false);
  const [searching, setSearching] = useState(false);
  const [searchQ, setSearchQ] = useState("");
  const [searchHits, setSearchHits] = useState(null);
  // Per-card publish state. Map id → "pending" | "done" | "error".
  // Stays in memory only — fresh on every visit to the page.
  const [shareState, setShareState] = useState({});

  useEffect(() => {
    (async () => {
      try {
        const r = await window.MCAPI.library.list();
        setItems(r.sessions || []);
      } catch {} finally { setLoaded(true); }
    })();
  }, []);

  const onShare = async (e, a) => {
    e.stopPropagation();  // don't open the asset; this is the share badge
    if (shareState[a.id] === "pending" || shareState[a.id] === "done") return;
    setShareState((s) => ({ ...s, [a.id]: "pending" }));
    try {
      await window.MCAPI.library.publish(a.id, { name: a.name });
      setShareState((s) => ({ ...s, [a.id]: "done" }));
    } catch (err) {
      setShareState((s) => ({ ...s, [a.id]: "error" }));
      alert("分享失败: " + (err && err.message || ""));
    }
  };

  const onSearch = async () => {
    const q = (searchQ || "").trim();
    if (!q) { setSearchHits(null); return; }
    try {
      const r = await window.MCAPI.search(q, "library");
      setSearchHits(r.library || []);
    } catch (e) {
      setSearchHits([]);
      alert("搜索失败: " + (e && e.message || ""));
    }
  };

  const visible = searchHits != null
    ? items.filter((it) => searchHits.some((h) => h.id === it.id))
    : items;

  return (
    <>
      <header className="topbar">
        <button className="ghost-btn" onClick={onBackToWorkflow}><I.ChevronLeft size={14} /> 返回</button>
        <div className="topbar-title" style={{ marginLeft: 4 }}>
          我的资产 <span className="sep">·</span>
          <span style={{ color: "var(--ink-3)" }}>{items.length} 个项目</span>
        </div>
        <div className="spacer" />
        {searching ? (
          <>
            <input value={searchQ} onChange={(e) => setSearchQ(e.target.value)}
                   placeholder="按名称 / 提示词搜索" autoFocus
                   onKeyDown={(e) => e.key === "Enter" && onSearch()}
                   style={{ ..._authInputStyle(), padding: "6px 12px", fontSize: 13 }} />
            <button className="ghost-btn" onClick={onSearch}><I.Search size={14} /> 搜</button>
            <button className="ghost-btn" onClick={() => {
              setSearching(false); setSearchQ(""); setSearchHits(null);
            }}><I.X size={14} /></button>
          </>
        ) : (
          <button className="ghost-btn" onClick={() => setSearching(true)}>
            <I.Search size={14} /> 搜索
          </button>
        )}
        <button className="primary-btn" onClick={onNew}><I.Plus size={14} /> 新建生成</button>
      </header>
      <div className="lib-page">
        {loaded && items.length === 0 && (
          <div className="empty-state" style={{ textAlign: "center", padding: 60, color: "var(--ink-4)" }}>
            还没有资产——主页发个提示词，跑完后就出现在这里。
          </div>
        )}
        <div className="lib-grid">
          {visible.map(a => {
            const ss = shareState[a.id];
            const shareLabel = ss === "done" ? "已分享"
              : ss === "pending" ? "分享中…"
              : ss === "error" ? "重试分享"
              : "分享到社区";
            return (
            <div key={a.id} className="lib-card" onClick={() =>
              onOpen({ id: a.id, prompt: a.prompt || a.name, name: a.name, model: a.model || "trex" })
            }>
              <div className="lib-thumb">
                <button
                  type="button"
                  className={"lib-share-badge" + (ss === "done" ? " published" : "")}
                  onClick={(e) => onShare(e, a)}
                  disabled={ss === "pending" || ss === "done"}
                  title={shareLabel}
                  aria-label={shareLabel}
                >
                  {ss === "done"
                    ? <I.Check size={14} />
                    : ss === "pending"
                      ? <I.Loader size={14} />
                      : <I.Upload size={14} />}
                </button>
                <img src={a.thumb_url} alt={a.name}
                     style={{ width: "100%", height: "100%", objectFit: "cover",
                              background: "var(--bg-soft)" }}
                     onError={(e) => { e.target.style.display = "none"; }} />
              </div>
              <div className="lib-meta">
                <div className="lib-name">{a.name}</div>
                <div className="lib-tag">{a.format} · {a.cubes} cubes</div>
                <div className="lib-row">
                  <span className="lib-anim">{(a.animations || []).join(", ") || "—"}</span>
                  <span className="lib-date">{a.has_texture ? "有贴图" : "无贴图"}</span>
                </div>
              </div>
            </div>
            );
          })}
        </div>
      </div>
    </>
  );
}

function CommunityPage({ onOpen, onBackToWorkflow }) {
  const [sort, setSort] = useState("trending");
  const sorts = [
    { id: "trending", label: "热门" },
    { id: "new", label: "最新" },
    { id: "downloads", label: "下载最多" },
  ];
  // Live community list from /api/community/list?sort=…
  const [items, setItems] = useState([]);
  const [loaded, setLoaded] = useState(false);
  const [searching, setSearching] = useState(false);
  const [searchQ, setSearchQ] = useState("");
  const [searchHits, setSearchHits] = useState(null);

  useEffect(() => {
    setLoaded(false);
    (async () => {
      try {
        const r = await window.MCAPI.community.list(sort);
        setItems(r.items || []);
      } catch {} finally { setLoaded(true); }
    })();
  }, [sort]);

  const onSearch = async () => {
    const q = (searchQ || "").trim();
    if (!q) { setSearchHits(null); return; }
    try {
      const r = await window.MCAPI.search(q, "community");
      setSearchHits(r.community || []);
    } catch (e) { setSearchHits([]); }
  };

  const onPublishMine = async () => {
    // Walk the localStorage sessions to let the user pick one to publish.
    let saved = {};
    try { saved = JSON.parse(localStorage.getItem("MCSKIN_SESSIONS_v1") || "{}"); } catch {}
    const eligible = Object.values(saved).filter((s) => s && s.ctx && s.ctx.bbmodel);
    if (eligible.length === 0) {
      alert("还没有可发布的会话——先生成一个完整资产。");
      return;
    }
    const lines = eligible.map((s, i) => `${i + 1}. ${s.spec?.name || s.id} (${s.prompt?.slice(0, 30) || ""}...)`);
    const which = prompt(
      "选择要发布的会话:\n" + lines.join("\n") + "\n\n输入序号 (1-" + eligible.length + ")",
      "1",
    );
    const idx = parseInt(which, 10) - 1;
    if (isNaN(idx) || idx < 0 || idx >= eligible.length) return;
    const target = eligible[idx];
    try {
      await window.MCAPI.community.publish({
        ctx: target.ctx,
        name: target.spec?.name,
        author: target.author || "you",
        anim: (target.ctx.bbmodel.animations || []).map(a => a.name).join(", "),
      });
      alert("已发布到社区。");
      // refresh
      const r = await window.MCAPI.community.list(sort);
      setItems(r.items || []);
    } catch (e) {
      alert("发布失败: " + (e && e.message || ""));
    }
  };

  const visible = searchHits != null
    ? items.filter((c) => searchHits.some((h) => h.id === c.id))
    : items;

  return (
    <>
      <header className="topbar">
        <button className="ghost-btn" onClick={onBackToWorkflow}><I.ChevronLeft size={14} /> 返回</button>
        <div className="topbar-title" style={{ marginLeft: 4 }}>
          社区资产 <span className="sep">·</span>
          <span style={{ color: "var(--ink-3)" }}>由用户分享</span>
        </div>
        <div className="spacer" />
        {searching ? (
          <>
            <input value={searchQ} onChange={(e) => setSearchQ(e.target.value)}
                   placeholder="按名称 / 作者搜索" autoFocus
                   onKeyDown={(e) => e.key === "Enter" && onSearch()}
                   style={{ ..._authInputStyle(), padding: "6px 12px", fontSize: 13 }} />
            <button className="ghost-btn" onClick={onSearch}><I.Search size={14} /> 搜</button>
            <button className="ghost-btn" onClick={() => {
              setSearching(false); setSearchQ(""); setSearchHits(null);
            }}><I.X size={14} /></button>
          </>
        ) : (
          <button className="ghost-btn" onClick={() => setSearching(true)}>
            <I.Search size={14} /> 搜索
          </button>
        )}
        <button className="ghost-btn" onClick={onPublishMine}>
          <I.Download size={14} /> 发布我的
        </button>
      </header>
      <div className="lib-page">
        <div className="lib-filters">
          {sorts.map(s => (
            <button key={s.id} className={"lib-filter" + (sort === s.id ? " active" : "")} onClick={() => setSort(s.id)}>
              {s.label}
            </button>
          ))}
          <div style={{ flex: 1 }} />
          <span style={{ fontSize: 12, color: "var(--ink-4)", fontFamily: "var(--font-mono)" }}>
            {items.length} 个作品
          </span>
        </div>
        {loaded && items.length === 0 && (
          <div className="empty-state" style={{ textAlign: "center", padding: 60, color: "var(--ink-4)" }}>
            还没有社区作品——你是第一个！
          </div>
        )}
        <div className="lib-grid">
          {visible.map(c => (
            <div key={c.id} className="lib-card" onClick={() => onOpen({ ...c, prompt: c.name })}>
              <div className="lib-thumb" style={{ background: "var(--bg-soft)" }}>
                {c.thumb_b64 ? (
                  <img src={`data:image/png;base64,${c.thumb_b64}`} alt={c.name}
                       style={{ width: "100%", height: "100%", objectFit: "cover" }} />
                ) : (
                  <div style={{ display: "grid", placeItems: "center", height: "100%",
                                color: "var(--ink-5)", fontSize: 11 }}>
                    {c.tag || c.model || ""}
                  </div>
                )}
              </div>
              <div className="lib-meta">
                <div className="lib-name">{c.name}</div>
                <div className="lib-tag">{c.author} · {c.anim || c.tag}</div>
                <div className="lib-row">
                  <span className="lib-anim">♥ {(c.likes || 0).toLocaleString()}</span>
                  <span className="lib-date">↓ {c.downloads || 0}</span>
                </div>
              </div>
            </div>
          ))}
        </div>
      </div>
    </>
  );
}

// ---------- Root ----------
// ---------------- Resizer ----------------
/** Thin draggable column divider. `onResize(dx)` is called on every
 *  mousemove with the px delta from the previous frame; the parent
 *  decides whether to add/subtract from its panel width. Captures the
 *  pointer so dragging off the divider doesn't lose the gesture. */
function Resizer({ onResize }) {
  const stateRef = useRef(null);
  const [dragging, setDragging] = useState(false);

  const onMouseDown = (e) => {
    e.preventDefault();
    stateRef.current = { lastX: e.clientX };
    setDragging(true);
    document.body.style.cursor = "col-resize";
    document.body.style.userSelect = "none";
    const onMove = (mv) => {
      if (!stateRef.current) return;
      const dx = mv.clientX - stateRef.current.lastX;
      stateRef.current.lastX = mv.clientX;
      if (dx) onResize(dx);
    };
    const onUp = () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
      stateRef.current = null;
      setDragging(false);
      document.body.style.cursor = "";
      document.body.style.userSelect = "";
    };
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
  };

  return (
    <div
      className={"resizer-v" + (dragging ? " dragging" : "")}
      onMouseDown={onMouseDown}
      title="拖动调整宽度"
    />
  );
}


// ---------------- localStorage persistence ----------------
// Hydrate / save the App's session state so a browser refresh doesn't wipe
// what the user just generated. We persist three slices: `sessions` (full
// data — prompts + ctx + stagesDone + anims), `recent` (sidebar order),
// `activeId` (which session is currently focused). Same key family across
// refreshes; if QuotaExceededError trips the saver, ctx blobs get pruned
// progressively until the rest fits.
const LS_SESSIONS = "MCSKIN_SESSIONS_v1";
const LS_RECENT = "MCSKIN_RECENT_v1";
const LS_ACTIVE = "MCSKIN_ACTIVE_v1";
const LS_VIEW = "MCSKIN_VIEW_v1";
const LS_SB_W = "MCSKIN_SBW_v1";
const LS_PV_W = "MCSKIN_PVW_v1";
const WF_ASSET_DB = "MCSKIN_WORKFLOW_ASSETS_v1";
const WF_ASSET_STORE = "assets";

function isImageWorkflow(workflow) {
  return workflow === "effects" || workflow === "ui";
}

function defaultWorkflowMessages(workflow) {
  return workflow === "ui"
    ? [{ role: "assistant", text: "描述游戏题材和 UI 风格后，会直接生成白底等格组件图，再扣白底并打包每个独立 PNG。" }]
    : [{ role: "assistant", text: "先描述具体特效动作和节奏；右侧风格只控制线条、色彩和渲染质感。" }];
}

function workflowDraftTitle(workflow) {
  return workflow === "ui" ? "新 UI 会话" : "新特效会话";
}

function workflowTitleFromPrompt(workflow, prompt) {
  const text = String(prompt || "").trim();
  if (text) return text.slice(0, 18);
  return workflowDraftTitle(workflow);
}

function makeImageWorkflowSession(workflow) {
  const id = `${workflow}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
  return {
    id,
    workflow,
    draft: true,
    prompt: "",
    title: workflowDraftTitle(workflow),
    model: workflow === "ui" ? "ui-kit" : "vfx",
    running: false,
    createdAt: Date.now(),
    updatedAt: Date.now(),
    workflowState: {
      messages: defaultWorkflowMessages(workflow),
      status: "等待输入",
      previewOpen: true,
    },
  };
}

function compactWorkflowState(state) {
  if (!state || typeof state !== "object") return state;
  const next = { ...state };
  delete next.result;
  return next;
}

function makeAbortError(message = "aborted") {
  try { return new DOMException(message, "AbortError"); }
  catch {
    const err = new Error(message);
    err.name = "AbortError";
    return err;
  }
}

function waitWithSignal(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(makeAbortError());
      return;
    }
    const timer = window.setTimeout(resolve, ms);
    if (signal) {
      signal.addEventListener("abort", () => {
        window.clearTimeout(timer);
        reject(makeAbortError());
      }, { once: true });
    }
  });
}

function openWorkflowDb() {
  return new Promise((resolve, reject) => {
    if (!("indexedDB" in window)) {
      reject(new Error("IndexedDB unavailable"));
      return;
    }
    const req = indexedDB.open(WF_ASSET_DB, 1);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(WF_ASSET_STORE)) db.createObjectStore(WF_ASSET_STORE, { keyPath: "id" });
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error || new Error("IndexedDB open failed"));
  });
}

async function saveWorkflowAsset(sessionId, workflow, result) {
  if (!sessionId || !result) return false;
  const db = await openWorkflowDb();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(WF_ASSET_STORE, "readwrite");
    tx.objectStore(WF_ASSET_STORE).put({ id: sessionId, workflow, result, updatedAt: Date.now() });
    tx.oncomplete = () => { db.close(); resolve(true); };
    tx.onerror = () => { db.close(); reject(tx.error || new Error("IndexedDB save failed")); };
  });
}

async function loadWorkflowAsset(sessionId) {
  if (!sessionId) return null;
  const db = await openWorkflowDb();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(WF_ASSET_STORE, "readonly");
    const req = tx.objectStore(WF_ASSET_STORE).get(sessionId);
    req.onsuccess = () => resolve(req.result || null);
    req.onerror = () => reject(req.error || new Error("IndexedDB load failed"));
    tx.oncomplete = () => db.close();
  });
}

async function deleteWorkflowAsset(sessionId) {
  if (!sessionId || !("indexedDB" in window)) return;
  try {
    const db = await openWorkflowDb();
    await new Promise((resolve, reject) => {
      const tx = db.transaction(WF_ASSET_STORE, "readwrite");
      tx.objectStore(WF_ASSET_STORE).delete(sessionId);
      tx.oncomplete = () => { db.close(); resolve(true); };
      tx.onerror = () => { db.close(); reject(tx.error || new Error("IndexedDB delete failed")); };
    });
  } catch (err) {
    console.warn("MCSKIN: workflow asset delete failed", err);
  }
}

function lsLoad(key, fallback) {
  try {
    const v = localStorage.getItem(key);
    if (v == null) return fallback;
    return JSON.parse(v);
  } catch { return fallback; }
}

function lsSaveSafely(key, value) {
  try {
    localStorage.setItem(key, JSON.stringify(value));
    return true;
  } catch (e) {
    // Quota or serialization error — try once with ctx pruned out of every
    // session so at least the metadata survives.
    if (key === LS_SESSIONS && value && typeof value === "object") {
      try {
        const lite = {};
        for (const id of Object.keys(value)) {
          const s = value[id] || {};
          lite[id] = { ...s, ctx: null, imageData: null, workflowState: compactWorkflowState(s.workflowState) };
        }
        localStorage.setItem(key, JSON.stringify(lite));
        console.warn("MCSKIN: pruned ctx/imageData from sessions to fit localStorage quota");
        return true;
      } catch {}
    }
    console.warn("MCSKIN: localStorage save failed for", key, e);
    return false;
  }
}


// ---------- Auth pages (login + register share the layout) ----------

function AuthPage({ mode = "login", onAuthed, onBack }) {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");

  const submit = async (e) => {
    e?.preventDefault();
    if (!username || !password) {
      setErr("用户名和密码不能为空"); return;
    }
    setBusy(true); setErr("");
    try {
      const fn = mode === "register" ? window.MCAPI.auth.register : window.MCAPI.auth.login;
      const u = await fn(username, password);
      if (typeof onAuthed === "function") onAuthed(u);
    } catch (e) {
      setErr(e && e.message || "登录失败");
    } finally {
      setBusy(false);
    }
  };

  return (
    <div className="auth-page" style={{
      minHeight: "100vh", display: "grid", placeItems: "center",
      padding: 24, background: "var(--bg-soft)",
    }}>
      <form className="auth-card" onSubmit={submit} style={{
        width: "min(420px, 92vw)", padding: 32, borderRadius: 16,
        background: "var(--bg-elev)", border: "1px solid var(--line)",
        boxShadow: "var(--shadow-md)",
        display: "flex", flexDirection: "column", gap: 14,
      }}>
        <div style={{ fontSize: 22, fontWeight: 600 }}>
          {mode === "register" ? "注册账号" : "登录"}
        </div>
        <div style={{ fontSize: 13, color: "var(--ink-3)" }}>
          {mode === "register"
            ? "任意用户名 + 密码即可注册——目前是单租户本地账号。"
            : "登录后才能使用 Library / Billing / 发布社区。"}
        </div>
        <input
          autoFocus type="text" placeholder="用户名" value={username}
          onChange={(e) => setUsername(e.target.value)}
          style={_authInputStyle()}
        />
        <input
          type="password" placeholder="密码" value={password}
          onChange={(e) => setPassword(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && submit()}
          style={_authInputStyle()}
        />
        {err && (
          <div style={{ color: "var(--danger)", fontSize: 12 }}>{err}</div>
        )}
        <button type="submit" className="primary-btn" disabled={busy}
                style={{ padding: "10px 16px", borderRadius: 999 }}>
          {busy ? "请稍候…" : (mode === "register" ? "注册并登录" : "登录")}
        </button>
        <div style={{ fontSize: 12, color: "var(--ink-3)", textAlign: "center" }}>
          {mode === "register" ? (
            <>已有账号? <a href="#/login">去登录</a></>
          ) : (
            <>没有账号? <a href="#/register">立即注册</a></>
          )}
        </div>
        {onBack && (
          <button type="button" className="ghost-btn" onClick={onBack}
                  style={{ marginTop: 4 }}>返回首页</button>
        )}
      </form>
    </div>
  );
}

function _authInputStyle() {
  return {
    padding: "10px 14px",
    fontSize: 14,
    border: "1px solid var(--line)",
    borderRadius: 8,
    background: "var(--bg)",
    color: "var(--ink)",
    outline: "none",
    fontFamily: "var(--font-sans)",
  };
}


// ---------- Billing page ----------

function BillingPage({ onBack }) {
  const [usage, setUsage] = useState(null);
  const [txns, setTxns] = useState([]);
  const [busy, setBusy] = useState(false);
  const [toast, setToast] = useState("");

  useEffect(() => {
    (async () => {
      try {
        const u = await window.MCAPI.billing.usage();
        setUsage(u);
      } catch {}
      try {
        const t = await window.MCAPI.billing.transactions();
        setTxns(t.transactions || []);
      } catch {}
    })();
  }, []);

  const quota = usage?.quota || {};
  const lifetime = usage?.lifetime || {};
  const usedUsd = quota.used_usd ?? lifetime.total_cost_usd ?? lifetime.est_cost_usd ?? 0;
  const quotaUsd = quota.quota_usd ?? 0;
  const remainingUsd = quota.remaining_usd ?? Math.max(0, quotaUsd - usedUsd);
  const totalTokens = lifetime.total_tokens || 0;

  const onTopup = async () => {
    setBusy(true); setToast("");
    try {
      const r = await window.MCAPI.billing.topup(20);
      setToast(r.message || "暂未开通");
    } catch (e) {
      setToast(e && e.message || "失败");
    } finally {
      setBusy(false);
      setTimeout(() => setToast(""), 4000);
    }
  };

  return (
    <>
      <header className="topbar">
        <button className="ghost-btn" onClick={onBack}>
          <I.ChevronLeft size={14} /> 返回
        </button>
        <div className="topbar-title" style={{ marginLeft: 4 }}>
          账单 <span className="sep">·</span>
          <span style={{ color: "var(--ink-3)" }}>真实 token 与美元额度</span>
        </div>
      </header>
      <div className="lib-page" style={{ maxWidth: 900, margin: "0 auto" }}>
        <div className="info-card" style={{
          padding: 20, borderRadius: 14, background: "var(--bg-elev)",
          border: "1px solid var(--line)", marginBottom: 16,
        }}>
          <div style={{ fontSize: 11, color: "var(--ink-3)", textTransform: "uppercase",
                         letterSpacing: 0.5, marginBottom: 6 }}>
            美元额度
          </div>
          <div style={{ fontSize: 32, fontWeight: 600, color: "var(--ink)" }}>
            ${Number(usedUsd).toFixed(4)}
          </div>
          <div style={{ fontSize: 13, color: "var(--ink-3)", marginTop: 4 }}>
            剩余 ${Number(remainingUsd).toFixed(4)} / 总额度 ${Number(quotaUsd).toFixed(2)} · {Number(totalTokens).toLocaleString()} tokens
          </div>
          <div style={{ display: "flex", gap: 8, marginTop: 14 }}>
            <button className="primary-btn" onClick={onTopup} disabled={busy}>
              <I.Plus size={14} /> 充值（暂未开通）
            </button>
            <button className="ghost-btn" onClick={() => window.location.hash = "#/upgrade"}>
              <I.Rocket size={14} /> 升级套餐
            </button>
          </div>
          {toast && (
            <div style={{
              marginTop: 12, padding: "8px 12px",
              background: "var(--warn)", borderRadius: 8, color: "white",
              fontSize: 13,
            }}>{toast}</div>
          )}
        </div>

        <div style={{ fontSize: 12, color: "var(--ink-3)", textTransform: "uppercase",
                       letterSpacing: 0.5, marginBottom: 8 }}>
          交易记录
        </div>
        {txns.length === 0 && (
          <div className="empty-state" style={{ padding: 40, textAlign: "center", color: "var(--ink-4)" }}>
            还没有交易——跑一次完整管线后会在这里看到一行。
          </div>
        )}
        {txns.map((t, i) => (
          <div key={i} className="lib-card" style={{ display: "flex", padding: 14,
                gap: 16, alignItems: "center", marginBottom: 8 }}>
            <div style={{ flex: 1 }}>
              <div style={{ fontSize: 14, fontWeight: 500 }}>
                {t.kind} <span style={{ color: "var(--ink-4)", fontFamily: "var(--font-mono)", fontSize: 11 }}>
                  · {Number(t.total_tokens || 0).toLocaleString()} tokens · LLM {t.llm_calls || 0} · Image {t.image_calls || 0} · Meshy {t.meshy_calls || 0}
                </span>
              </div>
              <div style={{ fontSize: 11, color: "var(--ink-4)", fontFamily: "var(--font-mono)" }}>
                {new Date((t.at || 0) * 1000).toLocaleString()}
              </div>
            </div>
            <div style={{ fontSize: 16, fontWeight: 600, fontFamily: "var(--font-mono)" }}>
              ${Number(t.total_cost_usd ?? t.est_cost_usd ?? 0).toFixed(4)}
            </div>
          </div>
        ))}
      </div>
    </>
  );
}


// ---------- Upgrade page ----------

function UpgradePage({ onBack }) {
  const [plansResp, setPlansResp] = useState(null);
  const [me, setMe] = useState(null);
  const [toast, setToast] = useState("");
  const [busy, setBusy] = useState(false);

  // Pick up Stripe redirect status (success / canceled) from the URL hash.
  const hashStatus = (() => {
    const h = window.location.hash || "";
    const m = h.match(/status=(success|canceled)/);
    return m ? m[1] : null;
  })();

  const refresh = async () => {
    try {
      const [p, m] = await Promise.all([
        window.MCAPI.upgrade.plans(),
        window.MCAPI.auth.me(),
      ]);
      setPlansResp(p || null);
      setMe(m && m.authenticated ? m : null);
    } catch {}
  };

  useEffect(() => {
    refresh();
    // After a Stripe Checkout success the webhook usually arrives within
    // ~1s, but the user lands here immediately. Poll /auth/me a few times
    // so the page reflects the new "pro" state without a manual refresh.
    if (hashStatus === "success") {
      setToast("订阅成功！正在同步账户状态…");
      const timer = setInterval(refresh, 1500);
      const stop = setTimeout(() => {
        clearInterval(timer);
        setToast("订阅已激活 — 欢迎升级 Pro 🎉");
        setTimeout(() => setToast(""), 4000);
      }, 8000);
      return () => { clearInterval(timer); clearTimeout(stop); };
    }
    if (hashStatus === "canceled") {
      setToast("已取消支付。Free 仍可以继续创作。");
      setTimeout(() => setToast(""), 4000);
    }
  }, [hashStatus]);

  const plans = (plansResp && plansResp.plans) || [];
  const isPro = (me?.plan || "").toLowerCase() === "pro";

  const onPick = async (plan) => {
    if (plan.id === "free") {
      setToast("Free 是默认套餐，无需操作。");
      setTimeout(() => setToast(""), 2500);
      return;
    }
    if (!me) {
      // Not logged in — bounce to login first, then come back.
      window.location.hash = "#/login";
      return;
    }
    if (isPro) {
      // Already Pro — open the Customer Portal instead of starting a new sub.
      setBusy(true);
      try {
        const r = await window.MCAPI.stripe.portal();
        if (r?.url) window.location.href = r.url;
      } catch (e) {
        setToast("无法打开订阅管理：" + (e?.message || e));
        setBusy(false);
      }
      return;
    }
    setBusy(true);
    try {
      const r = await window.MCAPI.stripe.checkout();
      if (r?.url) {
        window.location.href = r.url;
      } else {
        throw new Error("Stripe checkout 未返回 URL");
      }
    } catch (e) {
      setBusy(false);
      const msg = (e?.data?.detail) || e?.message || String(e);
      // Common cause: STRIPE_PRICE_ID_PRO env not set in this deploy.
      setToast("升级失败：" + msg);
    }
  };

  return (
    <>
      <header className="topbar">
        <button className="ghost-btn" onClick={onBack}>
          <I.ChevronLeft size={14} /> 返回
        </button>
        <div className="topbar-title" style={{ marginLeft: 4 }}>
          {isPro ? "管理 Pro 订阅" : "升级 Pro"}
        </div>
      </header>
      <div className="lib-page" style={{ maxWidth: 1100, margin: "0 auto", paddingTop: 32 }}>
        {hashStatus === "success" && (
          <div style={{
            padding: 14, marginBottom: 20, borderRadius: 10,
            background: "#dcfce7", color: "#166534",
            border: "1px solid #86efac", textAlign: "center", fontWeight: 500,
          }}>
            ✅ 订阅成功，已为你解锁 Pro 全部功能。
          </div>
        )}
        {hashStatus === "canceled" && (
          <div style={{
            padding: 14, marginBottom: 20, borderRadius: 10,
            background: "var(--bg-elev)", color: "var(--ink-2)",
            border: "1px solid var(--line)", textAlign: "center",
          }}>
            订单已取消。准备好了随时回来升级 Pro。
          </div>
        )}
        <div style={{
          display: "grid",
          gridTemplateColumns: `repeat(${Math.min(plans.length || 1, 2)}, 1fr)`,
          gap: 20,
          maxWidth: 800,
          margin: "0 auto",
        }}>
          {plans.map((p) => {
            const isCurrent = (p.id === "pro" && isPro) || (p.id === "free" && !isPro);
            return (
              <div key={p.id} className="lib-card" style={{
                padding: 28, display: "flex", flexDirection: "column", gap: 14,
                border: p.popular ? "2px solid var(--ink)" : "1px solid var(--line)",
                position: "relative",
                opacity: 1,
              }}>
                {p.popular && (
                  <div style={{
                    position: "absolute", top: -10, right: 16,
                    background: "var(--ink)", color: "white",
                    padding: "2px 10px", borderRadius: 999,
                    fontSize: 10, fontWeight: 600, letterSpacing: 0.5,
                  }}>POPULAR</div>
                )}
                <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
                  <div style={{ fontSize: 22, fontWeight: 600 }}>{p.name}</div>
                  {isCurrent && (
                    <span style={{
                      fontSize: 11, padding: "2px 8px", borderRadius: 999,
                      background: "var(--bg-elev)", color: "var(--ink-3)",
                    }}>当前</span>
                  )}
                </div>
                <div>
                  <span style={{ fontSize: 40, fontWeight: 700 }}>${p.price_usd}</span>
                  {p.billing_period && (
                    <span style={{ fontSize: 13, color: "var(--ink-3)" }}>
                      {" "}/ {p.billing_period === "month" ? "月" : p.billing_period}
                    </span>
                  )}
                </div>
                <ul style={{ paddingLeft: 18, margin: 0, fontSize: 13,
                              color: "var(--ink-2)", lineHeight: 1.7 }}>
                  {(p.features || []).map((h, i) => <li key={i}>{h}</li>)}
                </ul>
                <button
                  className={p.popular && !isPro ? "primary-btn" : "ghost-btn"}
                  onClick={() => onPick(p)}
                  disabled={busy || (p.id === "free")}
                  style={{ marginTop: "auto", padding: "10px 16px" }}
                >
                  {p.id === "free" && "当前套餐"}
                  {p.id === "pro" && !isPro && (busy ? "跳转中…" : "升级 Pro")}
                  {p.id === "pro" && isPro && (busy ? "打开中…" : "管理订阅")}
                </button>
              </div>
            );
          })}
        </div>
        {toast && (
          <div style={{
            marginTop: 18, padding: 12, textAlign: "center",
            background: "var(--bg-elev)", border: "1px solid var(--line)",
            borderRadius: 8, color: "var(--ink-2)",
          }}>{toast}</div>
        )}
        <div style={{
          marginTop: 28, padding: 18, fontSize: 12, color: "var(--ink-3)",
          textAlign: "center", borderTop: "1px solid var(--line)",
        }}>
          安全支付由 <strong>Stripe</strong> 提供 · 随时可在订阅管理页取消，下月生效 · 月费不退已使用部分
        </div>
      </div>
    </>
  );
}


// ---------- Docs page ----------

function DocsPage({ slug, onBack }) {
  const [toc, setToc] = useState([]);
  const [body, setBody] = useState("");
  const [activeSlug, setActiveSlug] = useState(slug || "getting-started");

  useEffect(() => {
    (async () => {
      try {
        const list = await window.MCAPI.docs.list();
        setToc(list.items || []);
        const initialSlug = slug || (list.items && list.items[0] && list.items[0].slug) || "getting-started";
        setActiveSlug(initialSlug);
      } catch {}
    })();
  }, []);

  useEffect(() => {
    if (!activeSlug) return;
    (async () => {
      try {
        const r = await window.MCAPI.docs.get(activeSlug);
        setBody(r.html || "");
      } catch (e) {
        setBody(`<p style="color:var(--danger)">加载失败: ${e && e.message || ""}</p>`);
      }
    })();
  }, [activeSlug]);

  const pickSlug = (s) => {
    setActiveSlug(s);
    window.location.hash = "#/docs/" + s;
  };

  return (
    <>
      <header className="topbar">
        <button className="ghost-btn" onClick={onBack}>
          <I.ChevronLeft size={14} /> 返回
        </button>
        <div className="topbar-title" style={{ marginLeft: 4 }}>文档</div>
      </header>
      <div style={{ display: "grid", gridTemplateColumns: "200px 1fr",
                     gap: 24, maxWidth: 1100, margin: "0 auto",
                     padding: "24px 32px 80px" }}>
        <div className="docs-toc" style={{
          fontSize: 13, color: "var(--ink-2)",
          display: "flex", flexDirection: "column", gap: 4,
        }}>
          {toc.map((it) => (
            <button key={it.slug}
                    onClick={() => pickSlug(it.slug)}
                    className={"nav-item" + (it.slug === activeSlug ? " active" : "")}
                    style={{
                      textAlign: "left",
                      padding: "8px 12px",
                      background: it.slug === activeSlug ? "var(--bg-elev)" : "transparent",
                      border: it.slug === activeSlug ? "1px solid var(--line)" : "1px solid transparent",
                      borderRadius: 8, cursor: "pointer", color: "var(--ink-2)",
                    }}>
              {it.title}
            </button>
          ))}
        </div>
        <article className="docs-body"
          style={{
            background: "var(--bg-elev)",
            border: "1px solid var(--line)",
            borderRadius: 14, padding: 32,
            color: "var(--ink-2)", lineHeight: 1.7,
            fontSize: 14,
          }}
          dangerouslySetInnerHTML={{ __html: body || "<p>加载中…</p>" }}
        />
      </div>
    </>
  );
}


function App() {
  const [view, setView] = useState(() => lsLoad(LS_VIEW, "home")); // 'home' | 'workspace' | 'settings'
  const [workflow, setWorkflow] = useState("full"); // 'full' | 'animation' | 'texture' | 'ui' | 'effects'
  // sessions: { [id]: sessionData }. Persists ctx + stagesDone + anims so
  // re-picking a recent session in the sidebar restores its state instead
  // of re-running the whole pipeline. activeId tracks which one is shown.
  const [sessions, setSessions] = useState(() => lsLoad(LS_SESSIONS, {}));
  const [recent, setRecent] = useState(() => lsLoad(LS_RECENT, []));
  const [activeId, setActiveId] = useState(() => lsLoad(LS_ACTIVE, null));
  // Resizable column widths — user-tweaked + persisted across refreshes.
  const [sbW, setSbW] = useState(() => Math.max(180, Math.min(420, lsLoad(LS_SB_W, 248))));
  const [pvW, setPvW] = useState(() => Math.max(280, Math.min(900, lsLoad(LS_PV_W, 480))));
  const session = activeId ? sessions[activeId] : null;
  const runningSessionIds = Object.keys(sessions).filter((id) => isSessionRunning(sessions[id]));
  const recentForSidebar = useMemo(() => recent.map((item) => ({
    ...item,
    live: !!(sessions[item.id] && isSessionRunning(sessions[item.id])),
  })), [recent, sessions]);
  const workspaceIds = Array.from(new Set([
    ...(view === "workspace" && activeId ? [activeId] : []),
    ...runningSessionIds,
  ])).filter((id) => sessions[id]);
  const anySessionRunning = runningSessionIds.length > 0;
  const activeIdRef = useRef(activeId);
  const sessionsRef = useRef(sessions);

  // Save on change.
  useEffect(() => { activeIdRef.current = activeId; }, [activeId]);
  useEffect(() => { sessionsRef.current = sessions; }, [sessions]);
  useEffect(() => { lsSaveSafely(LS_SESSIONS, sessions); }, [sessions]);
  useEffect(() => { lsSaveSafely(LS_RECENT, recent); }, [recent]);
  useEffect(() => { lsSaveSafely(LS_ACTIVE, activeId); }, [activeId]);
  useEffect(() => { lsSaveSafely(LS_VIEW, view); }, [view]);
  useEffect(() => { lsSaveSafely(LS_SB_W, sbW); }, [sbW]);
  useEffect(() => { lsSaveSafely(LS_PV_W, pvW); }, [pvW]);

  const resizeSidebar = (dx) =>
    setSbW((w) => Math.max(180, Math.min(420, w + dx)));
  const resizePreview = (dx) =>
    // Preview is on the right — drag handle right (dx > 0) shrinks it.
    setPvW((w) => Math.max(280, Math.min(900, w - dx)));

  const clearWorkflowHashForWorkspace = () => {
    if (window.location.hash.startsWith("#/workflow/")) {
      window.history.replaceState(null, "", window.location.pathname + window.location.search);
    }
  };

  const promoteWorkflowSession = (id, patch = {}) => {
    if (!id) return;
    setSessions((p) => {
      const s = p[id];
      if (!s) return p;
      return {
        ...p,
        [id]: {
          ...s,
          ...patch,
          draft: false,
          updatedAt: Date.now(),
        },
      };
    });
    setRecent((p) => {
      const existing = p.findIndex((r) => r.id === id);
      const current = sessions[id] || {};
      const entry = {
        id,
        title: patch.title || current.title || workflowDraftTitle(current.workflow),
        prompt: patch.prompt || current.prompt || "",
        live: false,
        model: patch.model || current.model || current.workflow || "workflow",
      };
      if (existing === -1) return [entry, ...p];
      const next = p.slice();
      next[existing] = { ...next[existing], ...entry };
      return next;
    });
  };

  // Listen for hash-driven nav. Recognized routes:
  //   #/settings[/<tab>]     #/login   #/register
  //   #/billing   #/upgrade
  //   #/docs[/<slug>]
  //   #/workflow/<full|animation|texture|ui|effects>
  const [docsSlug, setDocsSlug] = useState(null);
  useEffect(() => {
    const sync = () => {
      const h = window.location.hash;
      if (h.startsWith("#/settings")) setView("settings");
      else if (h === "#/login") setView("login");
      else if (h === "#/register") setView("register");
      else if (h === "#/billing") setView("billing");
      else if (h === "#/upgrade") setView("upgrade");
      else if (h.startsWith("#/docs")) {
        setView("docs");
        const slug = h.replace("#/docs/", "").replace("#/docs", "");
        setDocsSlug(slug || null);
      }
      else if (h.startsWith("#/workflow/")) {
        const wf = h.replace("#/workflow/", "");
        if (["full","animation","texture","ui","effects"].includes(wf)) {
          setWorkflow(wf);
          if (isImageWorkflow(wf)) {
            const currentActiveId = activeIdRef.current;
            const currentSessions = sessionsRef.current || {};
            const active = currentActiveId && currentSessions[currentActiveId]?.workflow === wf ? currentSessions[currentActiveId] : null;
            if (active) {
              setView("workspace");
            } else {
              const draft = makeImageWorkflowSession(wf);
              setSessions((p) => ({ ...p, [draft.id]: draft }));
              setActiveId(draft.id);
              setView("workspace");
            }
          } else {
            setView("home");
            setActiveId(null);
          }
        }
      }
    };
    sync();
    window.addEventListener("hashchange", sync);
    return () => window.removeEventListener("hashchange", sync);
  }, []);

  // Auth state — read /api/auth/me on mount; refresh on login/logout.
  const [authUser, setAuthUser] = useState(null);
  const [authChecked, setAuthChecked] = useState(false);
  const refreshAuth = async () => {
    try {
      const r = await window.MCAPI.auth.me();
      setAuthUser(r && r.authenticated ? r : null);
    } catch { setAuthUser(null); }
    setAuthChecked(true);
  };
  useEffect(() => { refreshAuth(); }, []);

  /** Mint or revive a session.
   *
   * `id` is optional: when supplied (e.g. clicking a seed `RECENT_SESSIONS`
   * entry whose id is `s-trex`) we adopt it so the seed becomes the live
   * session — a second click then finds it in `sessions` and switches
   * instead of forking a new conversation. The recent-list is deduped by
   * id at the same time, so no entry shows up twice.
   */
  const startSession = ({ id, prompt, imageData, imageName, style,
                          model = "trex", importedCtx, sourceKind, workflow: sessionWorkflow } = {}) => {
    if (!authChecked || !authUser) {
      window.location.hash = "#/login";
      return;
    }
    const guessName = (() => {
      if (/虎|tiger/i.test(prompt)) return "tiger";
      if (/龙|trex|霸王/i.test(prompt)) return "trex";
      if (/机甲|mech/i.test(prompt)) return "mech";
      if (/眼|watcher/i.test(prompt)) return "watcher";
      if (/枪|ak/i.test(prompt)) return "rifle";
      return "model";
    })();
    const guessModel = /机甲|mech|战车/i.test(prompt) ? "mech"
                     : /眼|watcher/i.test(prompt) ? "watcher"
                     : "trex";
    const finalId = id || ("s-" + Date.now().toString(36));
    const newSession = {
      id: finalId,
      prompt, imageData, imageName, style,
      model: model || guessModel,
      spec: { name: guessName, format: "java_block" },
      cubeCount: 38,
      workflow: "full",
      running: !importedCtx,
    };
    // If we're reviving from /api/upload, the ctx is already populated:
    // skip the generate phase and mark all stages done so Workspace
    // mounts straight into the post-build state (animation editor +
    // export buttons available immediately).
    if (importedCtx) {
      newSession.ctx = importedCtx;
      newSession.stagesDone = STAGE_PLAN.length;
      newSession.spec = importedCtx.model_spec || newSession.spec;
      newSession.sourceKind = sourceKind || "imported";
      newSession.workflow = sessionWorkflow === "texture" ? "texture" : "animation";
      newSession.uploadComplete = true;
      newSession.doneAt = Date.now();
      newSession.running = false;
      // Pre-count cubes so the spec card looks right.
      const els = (importedCtx.bbmodel && importedCtx.bbmodel.elements) || [];
      newSession.cubeCount = els.length;
    }
    setSessions((p) => ({ ...p, [finalId]: newSession }));
    // Recent-list update preserves order: if the id already exists (typical
    // when reviving a seed) replace it IN PLACE so the sidebar order stays
    // stable. Only truly new ids get prepended.
    setRecent((p) => {
      const idx = p.findIndex((r) => r.id === finalId);
      const entry = {
        id: finalId,
        title: prompt.slice(0, 18),
        prompt,
        live: false,
        model: newSession.model,
      };
      if (idx === -1) return [entry, ...p];
      const next = p.slice();
      next[idx] = { ...next[idx], ...entry };
      return next;
    });
    setActiveId(finalId);
    setView("workspace");
    clearWorkflowHashForWorkspace();
  };

  /** Sidebar / library-card click. If the id is already a live session
      we own (in `sessions`), switch to it; otherwise it's a seed entry
      whose id we ADOPT so a future click finds the session and switches. */
  const pickRecent = (s) => {
    if (sessions[s.id]) {
      const picked = sessions[s.id];
      setActiveId(s.id);
      setView("workspace");
      if (isImageWorkflow(picked.workflow)) {
        setWorkflow(picked.workflow);
        window.location.hash = `#/workflow/${picked.workflow}`;
      } else {
        setWorkflow(picked.workflow || "full");
        clearWorkflowHashForWorkspace();
      }
      return;
    }
    startSession({
      id: s.id,
      prompt: s.prompt || s.name || s.title,
      imageData: null,
      imageName: null,
      style: "pixel",
      model: s.model || "trex",
    });
  };

  /** Community-card click. Unlike sidebar items, community cards don't
      carry the heavy ctx_state in the list response — we fetch it via
      /api/community/{id}, hydrate a session pre-populated with that
      bbmodel + texture (importedCtx skips the generate pipeline), and
      switch to the workspace. The user lands on a fully-rendered model
      with animations playable, ready to refine. */
  const openCommunityItem = async (s) => {
    if (!s || !s.id) return;
    if (sessions[s.id]) {
      setActiveId(s.id);
      setView("workspace");
      return;
    }
    try {
      const full = await window.MCAPI.community.get(s.id);
      const ctx = full.ctx_state;
      if (!ctx || !ctx.bbmodel) {
        alert("无法加载该社区资源（缺少 bbmodel）。");
        return;
      }
      startSession({
        id: s.id,
        prompt: s.prompt || s.name || full.name || "(community)",
        imageData: null,
        imageName: null,
        style: (ctx.bbmodel?.meta?.texture_style) === "realistic" ? "pbr" : "pixel",
        model: full.model || s.model || "trex",
        importedCtx: ctx,
        sourceKind: "community",
      });
    } catch (e) {
      alert("加载社区资源失败:" + (e && e.message || e));
    }
  };

  /** Workspace pushes its long-lived state (ctx, stagesDone, anims) up so
      switching away + back doesn't lose progress / re-run the pipeline. */
  const updateSession = (id, patch) => {
    if (!id) return;
    const safePatch = patch?.workflowState
      ? { ...patch, workflowState: compactWorkflowState(patch.workflowState) }
      : patch;
    setSessions((p) => p[id] ? { ...p, [id]: { ...p[id], ...safePatch } } : p);
  };
  /** Sidebar trash button. Drops the session from both the sessions map
      and the recent list; if the deleted one is currently active, also
      navigate back to home. Backend session memory is fire-and-forget —
      MCAPI.deleteSession runs in the background, errors logged only. */
  const deleteSession = (id) => {
    setSessions((p) => {
      if (!p[id]) return p;
      const next = { ...p };
      delete next[id];
      return next;
    });
    setRecent((p) => p.filter((r) => r.id !== id));
    if (activeId === id) {
      setActiveId(null);
      setView("home");
    }
    deleteWorkflowAsset(id);
    if (window.MCAPI && typeof window.MCAPI.deleteSession === "function") {
      Promise.resolve(window.MCAPI.deleteSession(id))
        .catch((e) => console.warn("backend deleteSession failed:", e));
    }
  };

  return (
    <div className="app" style={{ "--sb-w": sbW + "px", "--pv-w": pvW + "px" }}>
      <Sidebar
        recent={recentForSidebar}
        activeId={session?.id}
        onSelect={(id) => {
          const s = recentForSidebar.find((r) => r.id === id);
          if (s) pickRecent(s);
        }}
        onDelete={deleteSession}
        onNew={() => { setActiveId(null); setView("home"); }}
        onSettings={() => { window.location.hash = "#/settings"; setView("settings"); }}
        workflow={workflow}
        onWorkflow={(wf) => {
          if (isImageWorkflow(wf)) {
            setWorkflow(wf);
          } else {
            setWorkflow(wf);
            setActiveId(null);
            setView("home");
          }
          window.location.hash = `#/workflow/${wf}`;
        }}
        view={view}
        authUser={authUser}
        authChecked={authChecked}
        brandBusy={anySessionRunning}
        onView={(v) => {
          setActiveId(null);
          setView(v);
          window.location.hash = v === "home" ? "" : `#/${v}`;
        }}
      />
      <Resizer onResize={resizeSidebar} />
      <main className="main">
        {view === "home" && workflow !== "effects" && workflow !== "ui" && (
          <>
            <header className="topbar">
              <div className="topbar-title">
                MeshForge Studio <span className="sep">·</span>
                <span style={{ color: "var(--ink-3)" }}>{WORKFLOW_META[workflow].topbar}</span>
              </div>
              <div className="spacer" />
              <button className="ghost-btn"
                      onClick={() => { window.location.hash = "#/docs"; setView("docs"); }}>
                <I.Book size={14} /> 文档
              </button>
              <button className="ghost-btn"
                      onClick={() => { setActiveId(null); setView("community"); }}>
                <I.Users size={14} /> 浏览社区
              </button>
              <button className="primary-btn"
                      onClick={() => { window.location.hash = "#/upgrade"; setView("upgrade"); }}>
                <I.Rocket size={14} /> 升级 Pro
              </button>
            </header>
            <Home
              workflow={workflow}
              onSubmit={startSession}
              onPickRecent={pickRecent}
              authUser={authUser}
              authChecked={authChecked}
            />
          </>
        )}
        {view === "home" && workflow === "effects" && (
          <EffectsWorkspacePage
            onResizePreview={resizePreview}
            authUser={authUser}
            authChecked={authChecked}
            onRequireLogin={() => { window.location.hash = "#/login"; }}
            onBack={() => {
              setWorkflow("full");
              window.location.hash = "#/workflow/full";
            }}
          />
        )}
        {view === "home" && workflow === "ui" && (
          <UiWorkflowPage
            onResizePreview={resizePreview}
            authUser={authUser}
            authChecked={authChecked}
            onBack={() => {
              setWorkflow("full");
              window.location.hash = "#/workflow/full";
            }}
          />
        )}
        {workspaceIds.map((id) => {
          const wsSession = sessions[id];
          const visible = view === "workspace" && id === activeId;
          const commonWorkflowProps = {
            session: wsSession,
            onUpdate: (patch) => updateSession(id, patch),
            onPromoteSession: (patch) => promoteWorkflowSession(id, patch),
            onResizePreview: resizePreview,
            authUser,
            authChecked,
            onBack: () => {
              setActiveId(null);
              setWorkflow("full");
              setView("home");
              window.location.hash = "#/workflow/full";
            },
          };
          return (
            <div key={id} className={"workspace-host" + (visible ? "" : " hidden")}>
              {wsSession.workflow === "effects" ? (
                <EffectsWorkspacePage
                  {...commonWorkflowProps}
                  onRequireLogin={() => { window.location.hash = "#/login"; }}
                />
              ) : wsSession.workflow === "ui" ? (
                <UiWorkflowPage {...commonWorkflowProps} />
              ) : (
                <Workspace
                  session={wsSession}
                  onUpdate={(patch) => updateSession(id, patch)}
                  onBack={() => { setActiveId(null); setView("home"); }}
                  onResizePreview={resizePreview}
                  authUser={authUser}
                  authChecked={authChecked}
                  onRequireLogin={() => { window.location.hash = "#/login"; }}
                />
              )}
            </div>
          );
        })}
        {view === "settings" && (
          <SettingsPage onBack={() => { window.location.hash = ""; setView("home"); }} />
        )}
        {view === "library" && (
          <LibraryPage
            onOpen={pickRecent}
            onNew={() => { setView("home"); }}
            onBackToWorkflow={() => {
              setWorkflow("full");
              setView("home");
              window.location.hash = "#/workflow/full";
            }}
          />
        )}
        {view === "community" && (
          <CommunityPage
            onOpen={openCommunityItem}
            onBackToWorkflow={() => {
              setWorkflow("full");
              setView("home");
              window.location.hash = "#/workflow/full";
            }}
          />
        )}
        {view === "billing" && (
          <BillingPage onBack={() => {
            setView("home"); window.location.hash = "";
          }} />
        )}
        {view === "upgrade" && (
          <UpgradePage onBack={() => {
            setView("home"); window.location.hash = "";
          }} />
        )}
        {view === "docs" && (
          <DocsPage slug={docsSlug} onBack={() => {
            setView("home"); window.location.hash = "";
          }} />
        )}
      </main>
    </div>
  );
}

// AuthPage is rendered SEPARATELY at the top level (no sidebar),
// so logged-out users see a clean login screen without the chrome.
function AppRoot() {
  const [view, setView] = useState(() => {
    const h = window.location.hash;
    if (h === "#/login") return "login";
    if (h === "#/register") return "register";
    return null;
  });
  useEffect(() => {
    const sync = () => {
      const h = window.location.hash;
      if (h === "#/login") setView("login");
      else if (h === "#/register") setView("register");
      else setView(null);
    };
    window.addEventListener("hashchange", sync);
    return () => window.removeEventListener("hashchange", sync);
  }, []);
  if (view === "login" || view === "register") {
    return <AuthPage
      mode={view}
      onAuthed={() => { window.location.hash = ""; setView(null); }}
      onBack={() => { window.location.hash = ""; setView(null); }}
    />;
  }
  return <App />;
}

ReactDOM.createRoot(document.getElementById("root")).render(<AppRoot />);
