/* =========================================================
   figures2.jsx — lecture figures v11–v20.
   Shares FIGN / FigFrame / FBox / FArrow / FT with figures.jsx
   through the classic-script global scope.
   ========================================================= */

/* ---------------- v11 · streaming synthesis ---------------- */
FIGN["v11-stream"] = function ({ idx }) {
  const L = useL();
  return (
    <FigFrame idx={idx} h={214} cap={L("整句合成让顾客等完整句的生成时间;流式合成按标点切块,第一块一出来就开始播,后面的块边播边生成——等待时间从八百毫秒掉到两百以内。代价是一个新的失败模式:如果生成追不上播放(RTF 太高、显卡被抢、某块特别长),播放指针会撞上生成指针,顾客听到话说到一半突然卡住。实用技巧是把第一块特意切得极短(比如只有「好的,」),首包延迟立刻减半,后面的块有充足时间追上。", "Whole-utterance synthesis makes the customer wait for the entire sentence to be generated. Streaming chunks on punctuation, plays the first chunk the moment it is ready, and generates the rest during playback — cutting the wait from eight hundred milliseconds to under two hundred. The price is a new failure mode: if generation cannot keep up (RTF too high, the GPU stolen, one chunk unusually long), the playback pointer meets the generation pointer and the sentence freezes mid-word. The practical trick is a deliberately tiny first chunk, which halves first-packet latency and gives later chunks time to catch up.")}>
      <FT x={110} y={20} cls="tk">{L("整句合成", "whole utterance")}</FT>
      <rect x={24} y={30} width={300} height={18} rx="4" fill="color-mix(in srgb, var(--accent) 70%, transparent)" stroke="var(--accent)" />
      <rect x={324} y={52} width={300} height={18} rx="4" fill="color-mix(in srgb, var(--primary) 70%, transparent)" stroke="var(--primary)" />
      <FT x={174} y={43} cls="tn">{L("生成整句", "generate all of it")}</FT>
      <FT x={474} y={65} cls="tn">{L("播放", "play")}</FT>
      <line x1={324} y1={24} x2={324} y2={76} stroke="#c0453f" strokeDasharray="3 3" />
      <FT x={330} y={88} anchor="start" cls="ta">{L("顾客等了 800 ms 才听到第一个字", "800 ms before the first sound")}</FT>

      <line x1={12} y1={100} x2={668} y2={100} stroke="var(--hairline-strong)" />

      <FT x={110} y={120} cls="tk">{L("流式 · 首块切短", "streaming · short first chunk")}</FT>
      {[[24, 34, 0], [58, 92, 34], [150, 92, 126], [242, 92, 218]].map(([x, w, px], i) => (
        <g key={i}>
          <rect x={x} y={130} width={w} height={16} rx="3" fill="color-mix(in srgb, var(--accent) 70%, transparent)" stroke="var(--accent)" />
          <rect x={58 + px} y={150} width={w * 2.2} height={16} rx="3" fill="color-mix(in srgb, var(--primary) 70%, transparent)" stroke="var(--primary)" />
        </g>
      ))}
      <line x1={58} y1={124} x2={58} y2={172} stroke="#2e9e6b" strokeDasharray="3 3" />
      <FT x={64} y={186} anchor="start" cls="tp">{L("180 ms 就开口了 —— 剩下的边播边生成", "speaking at 180 ms — the rest generates while it plays")}</FT>
      <FT x={560} y={140} cls="tn">{L("上:生成", "above: generation")}</FT>
      <FT x={560} y={158} cls="tn">{L("下:播放", "below: playback")}</FT>
      <FT x={560} y={186} cls="ta">{L("两者相撞 = 卡顿", "a collision = a stall")}</FT>
    </FigFrame>
  );
};

/* ---------------- v12 · the intent long tail ---------------- */
FIGN["v12-zipf"] = function ({ idx }) {
  const L = useL();
  const ZS = 1.4;
  const H = Array.from({ length: 26 }, (_, i) => Math.pow(i + 1, -ZS)).reduce((a, b) => a + b, 0);
  return (
    <FigFrame idx={idx} h={216} cap={L("顾客的说法服从 Zipf 式的长尾:头部五六个意图占了八成话量,剩下的几十种说法每种都只有零点几个百分点。规则匹配在头部无敌——五分钟写一条,延迟三毫秒、成本为零;但覆盖到八成之后,每多一个百分点都要新增大量规则,而且开始互相打架。大模型正好相反:长尾零样本就懂,新增一个意图只要加一行描述,代价是每轮几百毫秒和按 token 计费。多数门店的最优解是混合。", "Customer phrasing follows a Zipf-like long tail: five or six intents carry eighty percent of volume while dozens of others are fractions of a percent each. Rules are unbeatable at the head — five minutes each, three milliseconds, zero cost — but past eighty percent coverage every additional point costs a pile of rules that start contradicting one another. The LLM inverts this: the tail works zero-shot and a new intent costs one line of description, paid for in a few hundred milliseconds and per-token billing. Hybrid is the optimum for most shops.")}>
      {Array.from({ length: 26 }, (_, i) => {
        const s = Math.pow(i + 1, -ZS) / H;
        const h = (s / (1 / H)) * 118;
        return <rect key={i} x={34 + i * 23} y={144 - h} width={18} height={Math.max(1.5, h)} rx="2"
          fill={i < 6 ? "color-mix(in srgb, var(--primary) 80%, transparent)" : "var(--surface-2)"}
          stroke={i < 6 ? "var(--primary)" : "var(--hairline-strong)"} />;
      })}
      <line x1={30} y1={144} x2={654} y2={144} stroke="var(--hairline-strong)" />
      <line x1={176} y1={20} x2={176} y2={152} stroke="var(--accent)" strokeDasharray="4 3" />
      <FT x={104} y={164} cls="tp">{L("前 6 个意图 ≈ 78% 话量", "top 6 intents ≈ 78% of volume")}</FT>
      <FT x={420} y={164} cls="tn">{L("长尾:每种只有零点几个百分点", "the tail: fractions of a percent each")}</FT>
      <FBox x={34} y={178} w={276} h={28} label={L("规则:秒回、零成本、写不完", "rules: instant, free, endless")} tone="p" />
      <FBox x={330} y={178} w={324} h={28} label={L("大模型:零样本懂长尾,每轮几百毫秒", "LLM: zero-shot on the tail, a few hundred ms per turn")} tone="a" />
    </FigFrame>
  );
};

/* ---------------- v13 · RAG and where hallucination comes from ---------------- */
FIGN["v13-rag"] = function ({ idx }) {
  const L = useL();
  return (
    <FigFrame idx={idx} h={222} cap={L("大模型不知道你家 68 分钟肩颈理疗卖多少钱。检索增强的做法是把门店知识切块、向量化,每轮先检索再塞进上下文。关键不在检索成功的那条路径,而在失败的那条:模型在上下文里找不到价格,却被训练成一个乐于助人的助手,于是编一个听起来很合理的数字,顾客到店才发现对不上。加一句「查不到就明确说不知道并转人工」,幻觉率会直接塌到接近零,代价只是转人工率上升几个点。", "The model does not know what your 68-minute treatment costs. Retrieval augmentation chunks the shop's knowledge, embeds it, and fetches the relevant pieces into context each turn. What matters is not the path where retrieval succeeds but the one where it fails: the model finds no price in context, has been trained to be helpful, and invents a plausible number that the customer discovers at the door. One rule — when nothing is found, say so and hand off — collapses hallucination to near zero for a few points of extra handoff.")}>
      <FBox x={16} y={78} w={96} h={40} label={L("顾客提问", "the question")} tone="n" />
      <FArrow x1={112} y1={98} x2={142} y2={98} />
      <FBox x={144} y={78} w={104} h={40} label={L("向量检索", "vector search")} sub="top-k" tone="p" />
      <FBox x={144} y={22} w={104} h={40} label={L("知识库", "knowledge base")} sub={L("价目 · 排班 · 规则", "prices · rota · rules")} tone="n" />
      <FArrow x1={196} y1={62} x2={196} y2={76} />
      <FArrow x1={248} y1={88} x2={286} y2={62} c="#2e9e6b" />
      <FArrow x1={248} y1={108} x2={286} y2={140} c="#c0453f" />
      <FT x={268} y={52} cls="tn">{L("命中", "hit")}</FT>
      <FT x={266} y={152} cls="tn">{L("未命中", "miss")}</FT>

      <FBox x={288} y={40} w={160} h={40} label={L("带证据回答", "answer with evidence")} tone="ok" />
      <FArrow x1={448} y1={60} x2={486} y2={60} c="#2e9e6b" />
      <FBox x={488} y={42} w={172} h={36} label={L("顾客拿到正确价格", "the real price, verified")} tone="ok" />

      <FBox x={288} y={122} w={160} h={40} label={L("没有证据", "no evidence")} tone="warn" />
      <FArrow x1={448} y1={132} x2={486} y2={116} c="#c0453f" />
      <FArrow x1={448} y1={152} x2={486} y2={178} c="#2e9e6b" />
      <FBox x={488} y={98} w={172} h={36} label={L("编一个合理的数字", "invent a plausible number")} tone="bad" />
      <FBox x={488} y={160} w={172} h={36} label={L("说不知道 → 转人工", "say so → hand off")} tone="ok" />
      <FT x={574} y={146} cls="tn">{L("没有兜底 ↑ / 有兜底 ↓", "no fallback ↑ / fallback ↓")}</FT>
      <FT x={340} y={212} cls="tm">{L("幻觉不是模型坏,是它在没有证据时仍被要求回答", "hallucination is not a broken model; it is being required to answer without evidence")}</FT>
    </FigFrame>
  );
};

/* ---------------- v14 · idempotency ---------------- */
FIGN["v14-idem"] = function ({ idx }) {
  const L = useL();
  return (
    <FigFrame idx={idx} h={218} cap={L("最难缠的失败不是「失败了」,是「成功了但响应丢了」:服务端已经写进数据库,客户端没收到回执就重试,于是一个顾客生成两条预约,前台第二天面对同一时段同一位技师的两单。幂等键——由会话 ID 与意图派生的唯一键——让服务端认出这是同一次业务操作,直接返回上次的结果而不是再执行一次。占位还要配超时释放,否则顾客问完没确认就挂了,那个时段被白锁一小时。", "The nastiest failure is not a failure but a success with a lost response: the server already wrote the row, the client saw nothing and retried, and one customer became two bookings — leaving the front desk with two identical appointments. An idempotency key, derived from session and intent, lets the server recognise the same business operation and return the previous result instead of executing again. Holds also need expiry, or a customer who asks and hangs up without confirming locks that slot for an hour.")}>
      <FT x={170} y={20} cls="tk">{L("没有幂等键", "without an idempotency key")}</FT>
      <FBox x={20} y={34} w={86} h={34} label={L("客户端", "client")} tone="n" />
      <FArrow x1={106} y1={44} x2={206} y2={44} c="var(--primary)" />
      <FT x={156} y={38} cls="tn">{L("落单", "commit")}</FT>
      <FBox x={208} y={34} w={100} h={34} label={L("服务端 ✓写入", "server ✓ wrote")} tone="ok" />
      <FArrow x1={206} y1={62} x2={112} y2={62} c="#c0453f" dash />
      <FT x={158} y={76} cls="tn">{L("响应丢了", "response lost")}</FT>
      <FArrow x1={106} y1={90} x2={206} y2={90} c="#c0453f" />
      <FT x={156} y={84} cls="tn">{L("重试", "retry")}</FT>
      <FBox x={208} y={80} w={100} h={34} label={L("又写了一条", "wrote it again")} tone="bad" />
      <FBox x={20} y={128} w={288} h={34} label={L("同一顾客 · 两条预约", "one customer, two bookings")} tone="bad" />

      <line x1={336} y1={14} x2={336} y2={196} stroke="var(--hairline-strong)" strokeDasharray="4 3" />

      <FT x={504} y={20} cls="tk">{L("带幂等键", "with an idempotency key")}</FT>
      <FBox x={356} y={34} w={86} h={34} label={L("客户端", "client")} tone="n" />
      <FArrow x1={442} y1={44} x2={542} y2={44} c="var(--primary)" />
      <FT x={492} y={38} cls="tn">key=abc123</FT>
      <FBox x={544} y={34} w={110} h={34} label={L("服务端 ✓写入", "server ✓ wrote")} tone="ok" />
      <FArrow x1={542} y1={62} x2={448} y2={62} c="#c0453f" dash />
      <FArrow x1={442} y1={90} x2={542} y2={90} c="var(--primary)" />
      <FT x={492} y={84} cls="tn">{L("重试 · 同一个 key", "retry · same key")}</FT>
      <FBox x={544} y={80} w={110} h={34} label={L("见过 → 返回上次", "seen → return prior")} tone="ok" />
      <FBox x={356} y={128} w={298} h={34} label={L("同一顾客 · 一条预约 · 重试安全", "one customer, one booking")} tone="ok" />
      <FT x={340} y={188} cls="tm">{L("写操作的正确性不靠网络稳定,靠设计", "correctness of writes does not come from a stable network; it comes from design")}</FT>
      <FT x={340} y={206} cls="tn">{L("再配一条:占位 10–15 分钟未确认自动释放", "and one more rule: unconfirmed holds expire after 10–15 minutes")}</FT>
    </FigFrame>
  );
};

/* ---------------- v15 · guardrails ---------------- */
FIGN["v15-guard"] = function ({ idx }) {
  const L = useL();
  const layers = [
    { l: L("角色约束:你是本店前台", "role: you are this shop's front desk"), tone: "p" },
    { l: L("知识边界:价格与空档必须查工具", "grounding: prices and slots from tools"), tone: "p" },
    { l: L("禁语:治疗 / 疗效 / 根治", "banned: treat / cure / therapeutic"), tone: "warn" },
    { l: L("身体状况:不诊断,邀请到店评估或建议就医", "health: no diagnosis; invite assessment or a doctor"), tone: "warn" },
    { l: L("越界试探:中性拒绝 → 结束话题 → 留痕转人工", "out-of-bounds: neutral refusal → end → log and hand off"), tone: "bad" },
  ];
  return (
    <FigFrame idx={idx} h={220} cap={L("提示词不是写作文,它是这套系统的行为规范书,而且要当代码管——进版本库、有评审、每次改动跑一遍回归。对按摩养生门店来说,五层护栏里最硬的是最后两层:第一,养生服务不是医疗服务,不得作治疗、疗效、根治一类表述,遇到症状类提问应当邀请到店由技师评估或建议就医;第二,任何带有越界暗示的试探,都要用中性、不带评判的措辞明确拒绝、结束该话题并留痕转人工——这是正规门店与灰色场所的分界线。", "A prompt is not an essay; it is the system's code of conduct, and it should be managed like code — in version control, reviewed, with a regression run on every change. For a massage and wellness shop the hardest of the five layers are the last two. First, wellness service is not medical service: no claims of treating, curing or therapeutic effect, and a symptom question is answered by inviting an in-person assessment or suggesting a doctor. Second, any out-of-bounds probe is refused in neutral, non-judgemental wording, the topic ended, the exchange logged and handed to a human — the line between a legitimate shop and a grey one.")}>
      <FBox x={20} y={70} w={92} h={44} label={L("顾客的话", "what they said")} tone="n" />
      {layers.map((x, i) => {
        const cx = 138 + i * 98;
        return (
          <g key={i}>
            <rect x={cx} y={34} width={82} height={116} rx="8" fill={`color-mix(in srgb, ${FTONE[x.tone]} 12%, transparent)`} stroke={FTONE[x.tone]} strokeWidth="1.3" />
            <text x={cx + 41} y={24} textAnchor="middle" style={{ font: "600 10px var(--f-mono)", fill: FTONE[x.tone] }}>{i + 1}</text>
            {x.l.split(/[::]/).map((line, j) => (
              <text key={j} x={cx + 41} y={64 + j * 44} textAnchor="middle" style={{ font: `${j ? 500 : 600} ${j ? 9 : 9.5}px var(--f-body)`, fill: "var(--ink)" }}>
                {line.length > 12 ? line.slice(0, 12) + "…" : line}
              </text>
            ))}
            {i < layers.length - 1 ? <FArrow x1={cx + 82} y1={92} x2={cx + 96} y2={92} /> : null}
          </g>
        );
      })}
      <FArrow x1={112} y1={92} x2={136} y2={92} />
      <FBox x={628} y={70} w={36} h={44} label="✓" tone="ok" />
      <FT x={340} y={172} cls="tm">{L("每一层只挡一类问题,漏掉任何一层都有对应的试探能穿过去", "each layer blocks one class of problem; miss a layer and a matching probe walks through")}</FT>
      <FT x={340} y={194} cls="tn">{L("提示词注入(「忽略前面的规则」)要靠角色约束 + 知识边界一起顶住", "prompt injection — ignore your earlier rules — needs the role constraint and grounding together")}</FT>
      <FT x={340} y={212} cls="tn">{L("八条试探组成回归集,每次改提示词都要重跑", "eight probes form the regression set, re-run on every prompt change")}</FT>
    </FigFrame>
  );
};

/* ---------------- v16 · the latency budget ---------------- */
FIGN["v16-budget"] = function ({ idx }) {
  const L = useL();
  const st = [
    { l: L("网络", "network"), v: 45, tone: "m" },
    { l: L("尾点判定", "endpoint"), v: 600, tone: "warn" },
    { l: "ASR", v: 140, tone: "p" },
    { l: L("大模型首字", "LLM first token"), v: 430, tone: "a" },
    { l: L("TTS 首包", "TTS first packet"), v: 260, tone: "p" },
    { l: L("播放缓冲", "playout"), v: 90, tone: "m" },
  ];
  const total = st.reduce((s, x) => s + x.v, 0);
  let acc = 0;
  return (
    <FigFrame idx={idx} h={216} cap={L("人在电话里能忍受的沉默大约一秒。从顾客说完到机器出声,六段各自要花钱,而其中最长的一段几乎总是尾点静音判定——它比任何一个模型都贵。三个真正有效的手段都是「提前开始」而不是「跑得更快」:用 ASR 的中间结果预热大模型、大模型流式输出的第一句话立刻送去合成、TTS 边合成边播。合起来能把 1.5 秒左右的链路压到 0.75 秒;而顾客记住的是 P95,不是平均值。", "A person tolerates about one second of silence on a call. Between the caller's last syllable and the machine's first sound, six stages spend from that budget, and the longest is almost always tail-silence endpointing — more expensive than any model in the chain. The three moves that actually work are all start earlier rather than run faster: warm the LLM on partial ASR, synthesise the model's first streamed sentence immediately, and play TTS while it generates. Together they compress roughly 1.5 seconds to 0.75 — and what the customer remembers is the P95, not the mean.")}>
      {st.map((x, i) => {
        const x0 = 108 + (acc / total) * 470;
        const w = (x.v / total) * 470;
        acc += x.v;
        return (
          <g key={i}>
            <text x={102} y={30 + i * 21} textAnchor="end" style={{ font: "500 10px var(--f-body)", fill: "var(--muted)" }}>{x.l}</text>
            <rect x={x0} y={20 + i * 21} width={Math.max(2, w)} height={13} rx="3" fill={`color-mix(in srgb, ${FTONE[x.tone]} 82%, transparent)`} stroke={FTONE[x.tone]} />
            <text x={x0 + Math.max(2, w) + 5} y={30 + i * 21} style={{ font: "600 9px var(--f-mono)", fill: "var(--ink)" }}>{x.v}</text>
          </g>
        );
      })}
      <line x1={108 + (1000 / total) * 470} y1={14} x2={108 + (1000 / total) * 470} y2={150} stroke="#c0453f" strokeDasharray="4 3" />
      <FT x={108 + (1000 / total) * 470 + 4} y={160} anchor="start" cls="ta">{L("1 秒 · 人的忍耐线", "1 s · human patience")}</FT>
      <FT x={310} y={160} cls="tn">{`${L("裸链路", "bare")} ${total} ms`}</FT>
      <rect x={108} y={172} width={(750 / total) * 470} height={16} rx="4" fill="color-mix(in srgb, #2e9e6b 70%, transparent)" stroke="#2e9e6b" />
      <FT x={108 + (750 / total) * 470 + 6} y={184} anchor="start" cls="tp">{L("三个重叠优化之后 ≈ 750 ms", "after three overlaps ≈ 750 ms")}</FT>
      <FT x={340} y={208} cls="tn">{L("尾点不动,换多贵的模型都跨不过那条线", "leave endpointing alone and no model budget crosses that line")}</FT>
    </FigFrame>
  );
};

/* ---------------- v17 · echo cancellation ---------------- */
FIGN["v17-aec"] = function ({ idx }) {
  const L = useL();
  return (
    <FigFrame idx={idx} h={216} cap={L("要支持打断,播放和录音必须同时开着,于是麦克风会收到自己刚播出去的声音。没有回声消除,系统把自己的语音识别成顾客在说话,于是自己把自己打断,陷入循环。AEC 的做法是拿已知的播放信号作参考,从麦克风信号里自适应地减掉它;残余回声加上门店本身的背景噪声(前台旁的电视、另一位顾客),就是误打断的两个来源。所以打断检测不能只看「有没有声音」,还要看持续时长,以及更聪明的一层:识别出来的文字是不是有意义。", "Barge-in requires playback and capture to run at once, so the microphone picks up what the speaker just played. Without echo cancellation the system recognises its own voice as the customer, interrupts itself and loops. AEC uses the known playback signal as a reference and adaptively subtracts it from the microphone input; residual echo plus the shop's own ambience — the television by the desk, another customer — are the two sources of false interruption. So interrupt detection cannot merely ask whether there is sound: it must weigh duration, and more cleverly, whether the recognised words mean anything.")}>
      <FBox x={22} y={46} w={104} h={38} label={L("TTS 播放", "TTS playback")} tone="p" />
      <FArrow x1={126} y1={65} x2={196} y2={65} c="var(--primary)" />
      <FBox x={198} y={46} w={78} h={38} label={L("扬声器", "speaker")} tone="n" />
      <path d="M276,65 C320,44 340,96 386,72" fill="none" stroke="#c0453f" strokeWidth="1.4" strokeDasharray="4 3" />
      <FT x={332} y={40} cls="ta">{L("回声:自己的声音绕回来", "echo: its own voice comes back")}</FT>
      <FBox x={388} y={54} w={78} h={38} label={L("麦克风", "microphone")} tone="n" />
      <FArrow x1={466} y1={73} x2={506} y2={73} />
      <FBox x={508} y={54} w={92} h={38} label="AEC" sub={L("减掉参考信号", "subtract reference")} tone="a" />
      <FArrow x1={74} y1={84} x2={74} y2={116} c="var(--accent)" />
      <FArrow x1={74} y1={116} x2={546} y2={116} c="var(--accent)" dash />
      <FT x={300} y={130} cls="tn">{L("参考信号:我刚才播了什么", "reference: what I just played")}</FT>
      <FArrow x1={554} y1={92} x2={554} y2={116} c="var(--accent)" />

      <FBox x={22} y={152} w={196} h={34} label={L("残余回声 → 自己打断自己", "residual echo → self-interruption")} tone="bad" />
      <FBox x={232} y={152} w={196} h={34} label={L("背景噪声 → 无故闭嘴", "ambient noise → silences itself")} tone="warn" />
      <FBox x={442} y={152} w={216} h={34} label={L("持续时长 + 语义二次确认", "duration gate + semantic check")} tone="ok" />
      <FT x={340} y={206} cls="tn">{L("打断之后还要恢复状态:说到哪了,哪些槽位已经确认", "after an interrupt, recover state: where were we, which slots are already confirmed")}</FT>
    </FigFrame>
  );
};

/* ---------------- v18 · cascade vs end-to-end ---------------- */
FIGN["v18-cascade"] = function ({ idx }) {
  const L = useL();
  return (
    <FigFrame idx={idx} h={220} cap={L("级联架构里,大模型吐出来的是文本,于是你有一个可以动手的地方:敏感词过滤、价格校验、禁语拦截,全都发生在合成之前;出了纠纷,每一跳都有文字记录可查。端到端语音模型直接音频进、音频出,延迟能压到几百毫秒、语气也更自然,但审核点变得模糊,纠纷时只剩音频。门店的实用结论:凡是涉及承诺(价格、时间、优惠)的环节走级联;闲聊、安抚、引导这类不涉及承诺的环节,端到端的体验优势值得用起来。", "In the cascade the model emits text, which gives you somewhere to act: sensitive-term filtering, price validation and banned-phrase blocking all happen before synthesis, and a dispute has a written record at every hop. An end-to-end speech model takes audio in and emits audio out, reaching latency in the low hundreds of milliseconds with far better prosody — but the checkpoint blurs and a dispute leaves only audio. The practical rule for a shop: anything that makes a promise — price, time, discount — goes through the cascade, while small talk, reassurance and guidance are where the end-to-end experience advantage is worth taking.")}>
      <FT x={200} y={20} cls="tk">{L("级联:ASR → LLM → TTS", "cascade: ASR → LLM → TTS")}</FT>
      <FBox x={18} y={34} w={72} h={36} label={L("音频", "audio")} tone="n" />
      <FArrow x1={90} y1={52} x2={110} y2={52} />
      <FBox x={112} y={34} w={64} h={36} label="ASR" tone="p" />
      <FArrow x1={176} y1={52} x2={196} y2={52} />
      <FBox x={198} y={34} w={82} h={36} label={L("文本", "text")} tone="ok" />
      <FArrow x1={280} y1={52} x2={300} y2={52} />
      <FBox x={302} y={34} w={64} h={36} label="LLM" tone="a" />
      <FArrow x1={366} y1={52} x2={386} y2={52} />
      <FBox x={388} y={34} w={82} h={36} label={L("文本", "text")} tone="ok" />
      <FArrow x1={470} y1={52} x2={490} y2={52} />
      <FBox x={492} y={34} w={64} h={36} label="TTS" tone="p" />
      <FArrow x1={556} y1={52} x2={576} y2={52} />
      <FBox x={578} y={34} w={72} h={36} label={L("音频", "audio")} tone="n" />
      <FArrow x1={239} y1={70} x2={239} y2={92} c="#2e9e6b" />
      <FArrow x1={429} y1={70} x2={429} y2={92} c="#2e9e6b" />
      <FBox x={160} y={94} w={348} h={32} label={L("可审的地方:过滤 · 校验 · 拦截 · 留痕", "where you can audit: filter · validate · block · log")} tone="ok" />

      <line x1={12} y1={140} x2={668} y2={140} stroke="var(--hairline-strong)" />
      <FT x={200} y={160} cls="tk">{L("端到端语音模型", "end-to-end speech model")}</FT>
      <FBox x={18} y={170} w={72} h={36} label={L("音频", "audio")} tone="n" />
      <FArrow x1={90} y1={188} x2={132} y2={188} />
      <FBox x={134} y={170} w={200} h={36} label={L("一个模型:听 · 想 · 说", "one model: hear · think · speak")} tone="a" />
      <FArrow x1={334} y1={188} x2={376} y2={188} />
      <FBox x={378} y={170} w={72} h={36} label={L("音频", "audio")} tone="n" />
      <FT x={560} y={182} cls="tn">{L("延迟低、语气好", "low latency, better prosody")}</FT>
      <FT x={560} y={200} cls="ta">{L("但没有可审的文本层", "but no text layer to audit")}</FT>
    </FigFrame>
  );
};

/* ---------------- v19 · the platform map ---------------- */
FIGN["v19-matrix"] = function ({ idx }) {
  const L = useL();
  const groups = [
    { t: L("国内云", "Chinese clouds"), tone: "p", items: ["阿里云 / Alibaba", "腾讯云 / Tencent", "科大讯飞 / iFlytek", "火山引擎 / Volcano", "百度 / Baidu"], note: L("中文电话、方言、合规落地", "Mandarin telephony, dialects, compliance") },
    { t: L("国际厂商", "International"), tone: "a", items: ["Azure Speech", "Deepgram", "AssemblyAI", "OpenAI Realtime", "ElevenLabs"], note: L("多语种、表现力、实时接口", "multilingual, expressive, realtime APIs") },
    { t: L("开源自建", "Self-hosted OSS"), tone: "ok", items: ["FunASR / Paraformer", "SenseVoice", "Whisper", "CosyVoice", "GPT-SoVITS / Piper"], note: L("数据不出门、无分钟费、要人运维", "data stays in, no per-minute fee, needs an operator") },
  ];
  return (
    <FigFrame idx={idx} h={224} cap={L("供给侧大致三块。国内云厂商在中文电话八千赫场景、方言覆盖和合规落地上有结构性优势;国际厂商在多语种、音色表现力和实时接口上各有强项,但要额外考虑网络时延与数据出境;开源自建前期不花钱、数据不出门,代价是一张显卡、一个会运维的人和一条持续的升级路。选型的关键不是看谁功能多,而是把你自己的约束变成权重——并且一定要用自己门店的真实录音做盲测,因为每家在自家 demo 上都很好听。", "The supply side has three blocks. Chinese clouds hold structural advantages on 8 kHz Mandarin telephony, dialect coverage and domestic compliance. International vendors lead on multilingual coverage, expressive voices and realtime APIs, with network latency and cross-border transfer as extra considerations. Self-hosted open source costs nothing per minute and keeps data in the building, at the price of a GPU, an operator and a permanent upgrade treadmill. Selection is not about who has more features but about turning your own constraints into weights — and always blind-testing on your own recordings, because every vendor sounds excellent in their own demo.")}>
      {groups.map((g, i) => {
        const x = 18 + i * 222;
        return (
          <g key={i}>
            <FBox x={x} y={20} w={204} h={30} label={g.t} tone={g.tone} />
            {g.items.map((it, j) => (
              <g key={j}>
                <rect x={x} y={58 + j * 26} width={204} height={21} rx="4" fill="var(--surface-2)" stroke="var(--hairline-strong)" />
                <text x={x + 102} y={73 + j * 26} textAnchor="middle" style={{ font: "500 9.5px var(--f-mono)", fill: "var(--ink)" }}>{it}</text>
              </g>
            ))}
            <text x={x + 102} y={204} textAnchor="middle" style={{ font: "500 9.5px var(--f-body)", fill: "var(--muted)" }}>{g.note}</text>
          </g>
        );
      })}
      <FT x={340} y={220} cls="tn">{L("能力与价格变化很快 —— 以官网为准,并用自己的录音盲测", "capabilities and prices move fast — check the vendor's pages and blind-test on your own audio")}</FT>
    </FigFrame>
  );
};

/* ---------------- v20 · build vs buy ---------------- */
FIGN["v20-tco"] = function ({ idx }) {
  const L = useL();
  const X0 = 60, Y0 = 156, W = 560, H = 124;
  const unit = 0.055, build = 6800;
  const maxM = 220000;
  const px = (m) => X0 + (m / maxM) * W;
  const py = (c) => Y0 - (c / 14000) * H;
  const cross = build / unit;
  return (
    <FigFrame idx={idx} h={216} cap={L("云是一条过原点的直线(斜率就是每分钟单价),自建是一条水平线——固定的显卡折旧、电费,加上最大也最容易被低估的一笔:一个会部署模型、会调优、能在半夜服务挂了时爬起来的人。两条线的交叉点就是决策点,通常落在每月十万分钟上下:单店基本永远到不了,所以单店应该买;几十家店的连锁到某个规模后自建开始划算。硬约束是另一回事——如果数据必须留在店里,问题就不再是成本,而是最小可行配置是什么。", "The cloud is a straight line through the origin whose slope is the per-minute price. Self-hosting is a horizontal line: fixed GPU depreciation and power, plus the largest and most underestimated line of all — someone who can deploy and tune models and get up when the service dies at 2 a.m. Where the lines cross is the decision point, usually around a hundred thousand minutes a month: a single shop essentially never gets there, so a single shop buys, while a chain of dozens eventually finds self-hosting cheaper. A hard constraint changes the question — if data must stay in the building, it is no longer about cost but about the minimum viable build.")}>
      <line x1={X0} y1={Y0} x2={X0 + W} y2={Y0} stroke="var(--hairline-strong)" />
      <line x1={X0} y1={26} x2={X0} y2={Y0} stroke="var(--hairline-strong)" />
      <line x1={px(0)} y1={py(0)} x2={px(maxM)} y2={py(maxM * unit)} stroke="var(--accent)" strokeWidth="2" />
      <line x1={px(0)} y1={py(build)} x2={px(maxM)} y2={py(build)} stroke="var(--primary)" strokeWidth="2" />
      <circle cx={px(cross)} cy={py(build)} r="4" fill="#c0453f" />
      <line x1={px(cross)} y1={py(build)} x2={px(cross)} y2={Y0} stroke="#c0453f" strokeDasharray="3 3" />
      <FT x={px(cross)} y={Y0 + 16} cls="ta">{L("交叉点 ≈ 12 万分钟/月", "break-even ≈ 120k min/month")}</FT>
      <FT x={px(maxM) - 6} y={py(maxM * unit) - 8} anchor="end" cls="ta">{L("云:按分钟计费", "cloud: per minute")}</FT>
      <FT x={px(maxM) - 6} y={py(build) - 8} anchor="end" cls="tp">{L("自建:卡 + 电 + 人", "self-host: cards + power + people")}</FT>
      <FT x={X0 + 90} y={40} cls="tn">{L("单店在这一侧 → 买", "a single shop lives here → buy")}</FT>
      <FT x={X0 + W - 110} y={40} cls="tn">{L("大连锁在这一侧 → 可以建", "a large chain lives here → can build")}</FT>
      <FT x={26} y={92} anchor="middle" cls="tn">¥</FT>
      <FT x={340} y={196} cls="tm">{L("自建最贵的一笔从来不是显卡,是那个会运维的人", "the most expensive line in a build is never the card; it is the person who operates it")}</FT>
    </FigFrame>
  );
};
