'use client';

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
  Brain,
  Check,
  ChevronDown,
  LayoutTemplate,
  Loader2,
  Maximize2,
  Minimize2,
  Mic,
  Send,
  Square,
  X,
  ExternalLink,
} from 'lucide-react';
import { io, type Socket } from 'socket.io-client';
import { cn } from '@/lib/utils';
import { env } from '@/lib/env';
import { useAuthStore } from '@/stores/auth-store';
import {
  bytesToBase64,
  downsampleTo16k,
  floatToPcm16,
} from '@/components/realtime-coach/audio-utils';
import { Permission, type AssignmentProposal, type Signal } from '@pac/types';
import { useHasPermission } from '@/hooks/use-permission';
import { useAssistantStore } from '@/stores/assistant-store';
import { AssignmentConfirmSheet } from './assignment-confirm-sheet';
import { RearrangeSheet } from './rearrange-sheet';
import { SignalPrompt } from './signal-prompts';
import { ReasoningRow, toolMeta } from './step-trace';
import { isDiscardedDraft, stripToolEcho } from './chat-blocks';
import {
  useAssistantChat,
  type Artifact,
  type Block,
  type ChatMessage,
} from './use-assistant-chat';

/**
 * 这两个是**纯位置标记**：正文里已经有它们的可见产物（卡片 / 按钮），
 * ⛔ 不再单独出一行灰字 —— 否则同一件事画两遍，而且顺序还是反的
 * （侧信道事件比 fullStream 的 tool-call 先到）。
 */

/**
 * ⭐ **小图标按钮的唯一定义**（2026-08-15）。
 *
 * 测绘发现这一串在本文件里**逐字重复了四遍**（artifact 卡的放大/新窗口/关闭、头部的最大化/收起）——
 * 改一处漏三处，而这次改风格恰恰要动的就是它。
 * ⚠️ 语义是「次要动作」：静止时是灰的，只有悬停才显形。
 * ⛔ 确认单里那几个 `×` 按钮**不许并进来**：它们同尺寸，但 hover 是 rose（破坏性动作），
 *   并进来就等于把"删掉一个人"和"收起面板"画成同一个东西。
 */
const ICON_BTN =
  'inline-flex h-7 w-7 flex-none items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600';

const MODELS = [
  { value: 'deepseek', label: 'DeepSeek' },
  { value: 'qwen', label: '通义千问' },
];

// 语音输入(听写)暂时隐藏 —— 置 true 即恢复麦克风按钮(听写链路代码保留)。
const VOICE_INPUT_ENABLED = false;

/**
 * 空态开场建议 —— 调用方不传 `examples` 时的兜底(/assistant 整页就是这条路)。
 *
 * ⚠️ 按角色分,判据同占位符(PLAN_DISPATCH)。原来只有 EXAMPLES_STAFF 一份,
 *   主管打开整页助手看到的三条全是**客服**的活,分配和批次跟踪一条不提 ——
 *   而整页恰恰是他做批次活最可能待的地方(表格宽、能铺开看)。
 * ⛔ 主管那三条里点名的指标必须是工具真返回的,别许愿;口径与措辞见 assistant-widget
 *   里 leaderExamples 的注释(那份是"详情页开着某患者"时的版本)。
 */
const EXAMPLES_STAFF = [
  '今日推荐:挑几个该优先跟进的患者,并说明理由',
  '查一下患者孙柯的信息、就诊事实和画像',
  '现在有哪些潜在治疗、值得召回的患者?',
];

/**
 * 主管空态的例句 —— **一句一条动线，每条落到一个真工具**。
 *
 * ⭐ 这里是唯一一份：`assistant-widget` 从这儿引，⛔ 别再抄一遍
 *   （2026-08-15 之前两处各写了一份逐字相同的三条，而其中两条都是错的）。
 *
 * ═══ 2026-08-15 改掉的三处 ═══════════════════════════════════════
 * 🔴 **「窗口内」界面上四个月前就没有了。** `temperature.ts` 2026-08-11 把前两档
 *   显示名从「黄金期 / 窗口内」改成「三个月内 / 三个月到半年」，理由写得很硬：
 *   列头写着「黄金期」而里面装的是"三个月内"，他拿它当临床窗口用就会做错决定。
 *   ⇒ 例句是**摆在他眼前、还请他点**的一句话，用一个界面上不存在的词最糟：
 *     他点了，模型还得去猜那是哪一档。
 * 🔴 **「成功 / 不成功」是 `TRACKING_GUIDE` 专门在防的那个词。** 字段确实有
 *   （`outcomes.success`），但它含「约定下次回访」，⛔ 不是"谈成了几个"；
 *   而提示词那条更硬：本系统**不统计成功与否**。例句把这个词递给他、还请他点，
 *   模型只有两条路：如实说没有这个数（那这条例句就是个坑），或者去圆。
 * ⚠️ **「任务」是客服的词。** 他这一屏叫「我分的批次」；单条任务是客服执行页的事。
 *
 * ⛔ 例句里点名的每一样都必须是工具真返回的 —— 例句先许愿，会逼着模型去圆。
 * ⚠️ 顺序 = 他的实际动线：看人手 → 分 → 追 → 单批下钻 → **调已经分下去的**。
 *
 * ═══ 2026-08-20 加的最后一条(调整)═══════════════════════════════
 * ⭐ 它落在 `propose_rearrange`,是这一屏上唯一能**动已经分下去的单**的路 ——
 *   而门诊经理提这件事的原话就是"分下去之后我什么都做不了"。不摆出来他不会知道有。
 * ⚠️ 措辞是**不下断言**的：⛔ 不写「有人手上压太多了」——
 *   那是一句**事实主张**，而队伍恰好很平均时，模型出的单会跟这句话对不上，
 *   它只有两条路：如实说"其实挺平均"（那这条例句就是个坑），或者去圆。
 * 🔴 **也⛔ 不许带着"怎么调"的意思**（2026-08-20 实测栽过）。
 *   第一版写的是「帮我把在手的调一调，别都堆在一个人身上」—— 后半句被模型读成了
 *   **指令**：它当场把最多那位手上的 15 条铺给了其他人，主管看到的不再是"现在什么样"，
 *   而是"模型觉得该什么样"。而把患者从一位客服手里挪到另一位手里是**关系层面的决定**，
 *   只有主管有资格拍（同 2026-08-06 拿掉自动改派那次的判据）。
 *   ⇒ 例句只说**他要干这件事**，⛔ 不说要往哪个方向调 —— 方向由他下一句说。
 * ⚠️ 用「在手」这个词是因为团队那张表的表头就写着「当前在手」—— 他见过。
 *   ⛔ 别写「转手」：那只覆盖换人，盖不住改时限和移出（产品 2026-08-20 原话）。
 */
export const EXAMPLES_LEADER = [
  '现在谁手上最空，谁快扛不住了',
  '帮我给「种植 · 三个月到半年」这批患者出一份分配方案',
  '我分下去那几批现在怎么样了，有没有谁堆着没动',
  '最近一批分下去的，处理到什么程度了，退回的是什么原因',
  '把大家在手的摆出来，我要调几条',
];


/**
 * LLM 文本 → markdown 渲染(用户要求:不复用设计的结构卡,改 markdown 解析)。
 *
 * ═══ 2026-08-11 排版走查改的四件事(产品:「输出抓不住重点」)═══════════════
 * ① **段落要成块**:原来 `p` 是 `my-1.5`(6px),而行距 22px —— 段距只有行距的 1/4,
 *    段与段比行与行还紧,三段话在眼里是一坨。排版通则:段距 ≥ 1.5 倍行距。
 * ② **加粗不再身兼三职**:原来 `strong` 是 `semibold + slate-900`(字重与对比度同时跳),
 *    模型又拿它当标题使 —— 一屏 7 处黑块,等于没有重点。
 *    现在 strong 降成 `font-medium`,**标题交给 h3**(见下)。
 * ③ **标题就要比正文更重、更大、更黑**(2026-08-11 第二轮返工)。
 *    第一版把 h3 做成了 12px/slate-500 的"小节标签",结果**标题比正文还弱** ——
 *    句中那个 `font-medium` 的加粗反而比标题更抢眼,层级是反的,产品一眼就指出来了。
 *    ⇒ h3 = 14px semibold slate-900(比正文 13.5 大半号)。
 *    ⛔ 别再改回"小灰字标签":那是**卡片小节**的写法,不是**对话正文标题**的写法 ——
 *      这段文字里标题是扫视的落脚点,它必须是这一屏最重的东西。
 * ④ **数字等宽**:`tabular-nums` 让数字竖着对齐,扫视时自己跳出来。
 *    ⚠️ 曾经加过 `max-w-[34em]` 限行长(中文舒适区 25–35 字),**已撤**:
 *      助手窗口放大后文字在半路断行、右边空一大片,看起来像渲染坏了。
 *      窄窗本来就只有 ~28 字/行,行长问题只在放大时出现,而放大正是主管要"看得多"的时候。
 *      ⛔ 要限行长就得连用户气泡一起收进一个居中的对话栏(整块布局的事),
 *      别只勒住助手这一半。
 * ⛔ 别把这些值改回去而不改提示词:样式与「模型输出什么结构」是一对
 *    (见 assistant-prompts 的「怎么写」一节)。
 */
function Markdown({ text: raw }: { text: string }) {
  // ⛔ 模型照抄出来的工具记录不许摆给主管看（见 chat-blocks.stripToolEcho）
  const text = stripToolEcho(raw);
  /**
   * ⚠️ 容器上那三条 `[&_hN+*]:mt-1.5` 不是装饰,是**外边距折叠**的必然结果:
   *   标题的 `mb`(6px)与紧随其后那一段的 `mt`(14px)会**折叠成较大的那个** ——
   *   于是"标题↔正文"的缝跟"段↔段"一样宽,标题看着像浮在半空(产品指出来的那处)。
   * ⇒ 只把**紧跟标题**的那个块的上边距压到 6px;段与段之间仍是 14px。
   * ⛔ 别改 `p` 的 my 去凑:那会把所有段落的间距一起缩掉,段落又不成块了。
   */
  return (
    <div className="text-[13.5px] leading-relaxed text-slate-700 [font-variant-numeric:tabular-nums] [&_h1+*]:mt-1.5 [&_h2+*]:mt-1.5 [&_h3+*]:mt-1.5">
      <ReactMarkdown
        /**
         * 🔴 **`singleTilde: false` —— ⛔ 别删**（2026-08-15 实测抓到）。
         *
         * GFM 默认连**单个**波浪号也当删除线（`~x~`）。而模型讲区间时会写
         * 「每人 8~9 条，分完每人手上 48~49 条」—— 两个 `~` 正好配成一对，
         * 把中间那截划掉，屏幕上剩下「每人 **89** 条、手上 **4849** 条」。
         * ⚠️ 这是 `assignment-facts.range()` 那条纪律的**另一半**：那边把区间拆成
         *   `{最少, 最多}` 两个键，就是因为字符串 `'4~15'` 会被模型读成 415；
         *   数据这头堵住了，呈现这头照样能把两个数粘成一个 —— 而且**不报错**。
         * ⇒ 关掉单波浪号：`~` 原样显示，`~~删除线~~` 仍然可用（模型也用不到）。
         */
        remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
        components={{
          h1: ({ children }) => <h1 className="mb-1.5 mt-5 text-[15px] font-semibold text-slate-900 first:mt-0">{children}</h1>,
          h2: ({ children }) => <h2 className="mb-1.5 mt-5 text-[14.5px] font-semibold text-slate-900 first:mt-0">{children}</h2>,
          /**
           * ⭐ h3 是**模型实际会用的那一档**(提示词让它用 `###` 分块)。
           * 比正文大半号 + semibold + 最深的字色 —— 它是这段文字里**最重**的东西。
           * ⚠️ `mt-5` 是段落成块的另一半:标题上方的空白才是"新的一块开始了"的信号;
           *   `first:mt-0` 防止第一块顶部多出一截空白。
           * ⛔ 别做成小灰字标签(第一版就是那样,结果标题比正文还弱,句中加粗比标题更抢眼)。
           */
          /**
           * 🔴 **⛔ 没有 `first:mt-0`**（2026-08-13 删的，别加回来）。
           *
           * 它当年是对的 —— 那时一条助手消息 = **一个**文本块，`first` 就等于"整条消息的开头"。
           * 现在 `show_guidance` / `show_sheet` 会把模型的正文**切成好几个文本块**，
           * 而每个块都包在自己的 `<div>` 里 ⇒ 紧跟按钮之后的那个块，第一个元素往往正是标题，
           * `first:mt-0` 把 28px 抹成 0，只剩容器 `space-y-2.5` 的 10px ——
           * 「分完什么样」「要您定的」贴着上面的按钮，产品当场指出来。
           * ⇒ "整条消息的第一个"改由容器上那条 `[&>div:first-child>h3:first-child]:mt-0` 管
           *   （见 MessageView），它才认得出真正的第一个。
           */
          h3: ({ children }) => (
            <h3 className="mb-1.5 mt-7 text-[14px] font-semibold text-slate-900">{children}</h3>
          ),
          /**
           * ⛔ **刻意没有 h4**（2026-08-13 做过一版又撤了，⛔ 别再加）。
           *
           * 当时的由头是确认单第 1 段有「这一格还能怎么选」「本批人数还能怎么调」两组，
           * 想给它们一级副标题。做出来产品当场否了 —— **每组通常只有一条**，
           * 标题和它下面那条说的是同一件事，白占一行；正撞上写作规范自己那句
           * 「⛔ 别为一两句话套一个标题」。⇒ 提示词改成「别给这两组套小标题」，
           * 组名只作为**给模型的分组**存在，不进主管的视野。
           *
           * ⚠️ 后果：模型自发写 `####` 时会渲染成**和正文一样**（Tailwind preflight 把
           *   h1–h6 的字号字重都清成 inherit）。要真需要第二级，⛔ 别只加样式 ——
           *   先想清楚"什么内容配得上一个副标题"，否则又是这次这个循环。
           */
          p: ({ children }) => <p className="my-3.5">{children}</p>,
          ul: ({ children }) => <ul className="my-2.5 ml-4 list-disc space-y-1.5">{children}</ul>,
          ol: ({ children }) => <ol className="my-2.5 ml-4 list-decimal space-y-1.5">{children}</ol>,
          li: ({ children }) => <li className="leading-relaxed">{children}</li>,
          /// ⚠️ `font-medium` 不是 semibold:句中强调而已,⛔ 别再同时跳字重和颜色
          strong: ({ children }) => <strong className="font-medium text-slate-900">{children}</strong>,
          a: ({ children, href }) => (
            <a href={href} className="text-brand-700 underline" target="_blank" rel="noreferrer">
              {children}
            </a>
          ),
          hr: () => <hr className="my-3" />,
          blockquote: ({ children }) => (
            <blockquote className="my-2 border-l-2 border-brand-300 bg-brand-50/40 py-1 pl-3 text-slate-600">
              {children}
            </blockquote>
          ),
          code: ({ children, className }) =>
            className ? (
              <code className={className}>{children}</code>
            ) : (
              <code className="rounded bg-slate-100 px-1 py-0.5 font-mono text-[12px] text-slate-700">
                {children}
              </code>
            ),
          pre: ({ children }) => (
            <pre className="my-2 overflow-x-auto rounded-md border bg-slate-50 p-3 text-[12px]">
              {children}
            </pre>
          ),
          table: ({ children }) => (
            <div className="my-2 overflow-x-auto">
              <table className="w-full border-collapse text-[12.5px]">{children}</table>
            </div>
          ),
          thead: ({ children }) => <thead className="bg-slate-50">{children}</thead>,
          th: ({ children }) => (
            <th className="border px-2 py-1 text-left font-medium text-slate-600">{children}</th>
          ),
          td: ({ children }) => <td className="border px-2 py-1 text-slate-700">{children}</td>,
        }}
      >
        {text}
      </ReactMarkdown>
    </div>
  );
}

function BlockView({
  block,
  onSheetConfirmed,
  onSheetEdited,
  onRearrangeEdited,
  onRearrangeConfirmed,
  onSignalIntent,
  onSignalCompose,
  onSheetReplaced,
  placedKeys,
  signalOf,
  streaming,
  onLiveSignalsChange,
  busyIntent,
  orphanKeys,
}: {
  block: Block;
  onSheetConfirmed?: (
    requestId: string,
    assignmentId: string,
    summary: string,
    modelFacts?: string,
  ) => void;
  /** 助手改确认单后,界面回报的那一句(注入消息流) */
  onSheetEdited?: (summary: string, modelText?: string) => void;
  /** 助手改**调整单**后,界面回报的那一句 —— ⛔ 与上面那条各走各的,别合并 */
  onRearrangeEdited?: (summary: string, modelText?: string) => void;
  /** 调整确认之后的结果(注入消息流,模型据此往下说) */
  onRearrangeConfirmed?: (summary: string, modelFacts: string) => void;
  /** 「重新排一版」回来的新提案 —— 整块换掉那张卡 */
  onSheetReplaced?: (requestId: string, next: AssignmentProposal) => void;
  /** ⭐ 引导节点按钮 —— 与助手共用同一组 intent（见 chat-blocks.intentToSheetOps） */
  onSignalIntent?: (intent: string, args?: Record<string, unknown>) => void;
  /** 引导节点的兜底 —— 把一句开头填进底部输入框并聚焦（⛔ 不直接发送） */
  onSignalCompose?: (draft: string) => void;
  /** 已经被模型插进正文的引导节点 —— 卡片按它去重（见 show_guidance） */
  placedKeys?: string[];
  /** signalKey → 当前确认单上的那条引导节点；取不到 = 它已经不存在了 */
  signalOf?: (key: string) => Signal | undefined;
  /** 这一轮还在写 —— 传给确认单，卡内兜底要等说完再显示 */
  streaming?: boolean;
  /** 确认单上报「主语还在」的节点 key（见它的 liveSignals） */
  onLiveSignalsChange?: (requestId: string, keys: string[]) => void;
  /** 正在执行的 intent —— 按钮转圈 + 整排禁用 */
  busyIntent?: string | null;
  /** 这些引导节点是「无根」的（上面没有解释它的那句话）—— 要自带标题 */
  orphanKeys?: Set<string>;
}) {
  /**
   * ⭐ 思考**按到达顺序就地渲染**（2026-08-14 改，此前只在末尾的执行过程里）——
   *   位置本身就是信息：「想 → 调 → 写 → 再调」和「全调完再写」是两种行为，堆在末尾看不出区别。
   *
   * 🔴 **工具那一类 2026-08-19 起整个不到这儿了**（在 `MessageView` 的 `rest` 里就滤掉了，
   *   理由写在那儿）。⇒ 这里⛔ 不留 `block.kind === 'tool'` 分支：
   *   它永远进不来，而一段永远不执行的代码读起来像"这个功能还在"。
   *   要把它放回来，改 `rest` 的过滤那一处就够了。
   */
  if (block.kind === 'reasoning') return <ReasoningRow text={block.text} />;
  // 位置标记本身不渲染 —— MessageView 已经把卡片搬到它那儿了(理论上到不了这)
  if (block.kind === 'sheet_anchor') return null;
  // 同上:调整单的位置标记本身不渲染(MessageView 已把卡片搬过去了)
  if (block.kind === 'rearrange_anchor') return null;
  if (block.kind === 'rearrange_sheet') {
    return (
      <RearrangeSheet
        operationId={block.operationId}
        sheet={block.sheet}
        state={block.state}
        edits={block.edits}
        streaming={streaming}
        onEditApplied={onRearrangeEdited}
        onConfirmed={onRearrangeConfirmed}
        onCompose={onSignalCompose}
      />
    );
  }
  if (block.kind === 'artifact') return <ArtifactView artifact={block.artifact} />;
  if (block.kind === 'guidance') {
    // ⚠️ 取不到 = 这条已经不在当前确认单上了（调整过 / 已处置）→ 不渲染。
    //   ⛔ 别渲染一个点了没反应的按钮，那比不渲染更糟。
    const s = signalOf?.(block.signalKey);
    if (!s || !onSignalIntent) return null;
    return (
      <SignalPrompt
        signal={s}
        busyIntent={busyIntent}
        onIntent={onSignalIntent}
        onCompose={onSignalCompose}
        // 无根（模型没交错，被挪到正文之后）→ 自带标题，否则一排按钮看不出在说哪件事
        withTitle={orphanKeys?.has(block.signalKey)}
      />
    );
  }
  if (block.kind === 'assignment_sheet') {
    return (
      <AssignmentConfirmSheet
        requestId={block.requestId}
        sheet={block.sheet}
        state={block.state}
        assignmentId={block.assignmentId}
        edits={block.edits}
        // 执行结果回一句进对话:既给主管看,也补进模型上下文 ——
        // 否则它不知道到底成没成(可能有人没找到),下一句就会替系统撒谎
        onEditApplied={onSheetEdited}
        onIntent={onSignalIntent}
        placedKeys={placedKeys}
        onCompose={onSignalCompose}
        streaming={streaming}
        onLiveSignalsChange={onLiveSignalsChange}
        onSheetReplaced={(next) => onSheetReplaced?.(block.requestId, next)}
        onConfirmed={(id, summary, modelFacts) =>
          onSheetConfirmed?.(block.requestId, id, summary, modelFacts)
        }
      />
    );
  }
  /**
   * ⚠️ 兜底显式判 `text`，⛔ 别写成 `return <Markdown text={block.text} />` ——
   *   那一版能编译过，是因为上面还有一条 `block.kind === 'tool'` 分支替它把类型收窄了；
   *   工具分支删掉之后（见上），少了收窄这条尾巴就不成立了。
   * ⇒ 判据写在自己这一行，⛔ 不靠别处的分支顺手兜。
   */
  return block.kind === 'text' ? <Markdown text={block.text} /> : null;
}

// ── Artifact:模型产出的 HTML 在隔离沙箱 iframe 渲染(即用即焚)──────────────
// 安全:sandbox="allow-scripts" 不含 allow-same-origin → 脚本在 null origin,
//   碰不到父页 cookie/localStorage、无法带凭证调 PAC;CSP 只放行 Tailwind/Chart.js CDN
//   且 connect-src 'none' 堵死任何外传(患者数据不会被偷渡)。
const ARTIFACT_CSP = [
  "default-src 'none'",
  "script-src 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://cdn.jsdelivr.net",
  "style-src 'unsafe-inline' https://cdn.tailwindcss.com https://fonts.googleapis.com",
  'font-src https://fonts.gstatic.com',
  'img-src data:',
  "connect-src 'none'",
  "base-uri 'none'",
  "form-action 'none'",
].join('; ');

const ARTIFACT_HEAD = `<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="${ARTIFACT_CSP}">
<meta name="viewport" content="width=device-width,initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>body{margin:0;font-family:"PingFang SC","Noto Sans CJK SC",system-ui,sans-serif;background:#f8fafc;color:#0f172a}
/* 切断 Chart.js 响应式 ↔ 自适应 iframe 高度的正反馈环:画布层高写死上限,
   无论模型用 maintainAspectRatio:false 还是百分比容器,canvas 都不会随 body 增高而无限拉长。*/
canvas{max-height:360px!important}</style>`;

// 持久壳:iframe 只加载一次(Tailwind/Chart.js 不重载),内容经 postMessage 注入 #root。
// 流式中只 set innerHTML(Tailwind 的 MutationObserver 自动上样式);final 时重建 <script> 让 Chart.js 执行。
function artifactShell(): string {
  return `<!DOCTYPE html><html lang="zh"><head>${ARTIFACT_HEAD}</head><body class="p-4"><div id="root"></div>
<script>
(function(){
  var root=document.getElementById('root');
  function h(){parent.postMessage({__artifactHeight:document.documentElement.scrollHeight},'*');}
  new ResizeObserver(h).observe(document.body);
  window.addEventListener('message',function(e){
    var d=e.data||{}; if(!d.__artifact)return;
    root.innerHTML=d.html||'';
    if(d.final){root.querySelectorAll('script').forEach(function(o){var s=document.createElement('script');for(var i=0;i<o.attributes.length;i++){s.setAttribute(o.attributes[i].name,o.attributes[i].value);}s.textContent=o.textContent;o.parentNode.replaceChild(s,o);});}
    setTimeout(h, d.final?150:0);
  });
  parent.postMessage({__ready:true},'*');
})();
</script>
</body></html>`;
}

// openFull / 兜底:自包含整页(内容内联,脚本随页面加载执行)。
function buildStandaloneDoc(inner: string): string {
  return `<!DOCTYPE html><html lang="zh"><head>${ARTIFACT_HEAD}</head><body class="p-4">${inner}</body></html>`;
}

/// 单个 artifact 的沙箱 iframe 渲染器(持久壳 + postMessage 增量注入;可多实例:
/// 内联卡 + 居中放大窗同时挂,各自吃同一份流式 html → 放大窗也实时"长")。
/// autoHeight:内联卡随内容增高;放大窗 false(锁满容器,内部滚动)。
function ArtifactFrame({
  artifact,
  autoHeight,
  className,
}: {
  artifact: Artifact;
  autoHeight: boolean;
  className?: string;
}) {
  const ref = useRef<HTMLIFrameElement>(null);
  const [height, setHeight] = useState(120);
  const shell = useMemo(() => artifactShell(), []);
  const readyRef = useRef(false);
  const htmlRef = useRef(artifact.html);
  const finalRef = useRef(!artifact.streaming);
  const lastPostRef = useRef(0);

  const post = () => {
    const win = ref.current?.contentWindow;
    if (!win || !readyRef.current) return;
    win.postMessage({ __artifact: true, html: htmlRef.current, final: finalRef.current }, '*');
    lastPostRef.current = Date.now();
  };

  useEffect(() => {
    htmlRef.current = artifact.html;
    finalRef.current = !artifact.streaming;
    if (!readyRef.current) return;
    if (finalRef.current) {
      post();
      return;
    }
    const since = Date.now() - lastPostRef.current;
    if (since >= 500) {
      post();
      return;
    }
    const t = setTimeout(post, 500 - since);
    return () => clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [artifact.html, artifact.streaming]);

  useEffect(() => {
    const onMsg = (e: MessageEvent) => {
      if (e.source !== ref.current?.contentWindow) return;
      const d = e.data as { __ready?: boolean; __artifactHeight?: number };
      if (d?.__ready) {
        readyRef.current = true;
        post();
      }
      if (autoHeight && typeof d?.__artifactHeight === 'number') {
        setHeight(Math.min(Math.max(d.__artifactHeight, 60), 1600));
      }
    };
    window.addEventListener('message', onMsg);
    return () => window.removeEventListener('message', onMsg);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [autoHeight]);

  return (
    <iframe
      ref={ref}
      title={artifact.title || 'artifact'}
      sandbox="allow-scripts"
      srcDoc={shell}
      className={cn('w-full', className)}
      style={autoHeight ? { height, border: 0 } : { border: 0 }}
    />
  );
}

function ArtifactView({ artifact }: { artifact: Artifact }) {
  const [expanded, setExpanded] = useState(false);

  const openTab = () => {
    const w = window.open('', '_blank');
    w?.document.write(buildStandaloneDoc(artifact.html));
    w?.document.close();
  };

  return (
    // ⭐ 去掉外框与阴影(2026-08-15):它是会话流里的一块内容,不是浮在上面的卡片
    <div className="overflow-hidden rounded-lg bg-white">
      <div className="flex items-center gap-1.5 px-1 py-1.5">
        <LayoutTemplate className="h-3.5 w-3.5 text-brand-600" />
        <span className="text-[11.5px] font-medium text-slate-500">{artifact.title || '可视化卡片'}</span>
        <button
          type="button"
          onClick={() => setExpanded(true)}
          title="放大查看"
          className="ml-auto inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10.5px] text-slate-400 hover:bg-slate-100 hover:text-slate-600"
        >
          <Maximize2 className="h-3 w-3" />
        </button>
      </div>
      <ArtifactFrame artifact={artifact} autoHeight />

      {/* 居中放大窗:与内联卡同源同流(streaming 时这里同步在"长")。
          ⚠️ portal 到 body —— 助手小窗容器带 transform(滑出动画),CSS 规定 transform 祖先
          会把后代 fixed 圈进自己坐标系 → 不 portal 大窗就被困在小窗里(2026-06 实测)。 */}
      {expanded &&
        typeof document !== 'undefined' &&
        createPortal(
        <div className="fixed inset-0 z-[70] flex items-center justify-center p-4">
          <div className="absolute inset-0 bg-black/40" onClick={() => setExpanded(false)} />
          <div className="relative z-10 flex h-[min(86vh,920px)] w-[min(980px,94vw)] flex-col overflow-hidden rounded-xl border bg-white shadow-2xl">
            <div className="flex flex-none items-center gap-1.5 border-b bg-slate-50/60 px-3 py-2">
              <LayoutTemplate className="h-4 w-4 text-brand-600" />
              <span className="text-[12.5px] font-medium text-slate-600">{artifact.title || '可视化卡片'}</span>
              {artifact.streaming && (
                <span className="ml-1 inline-flex items-center gap-1 text-[10.5px] text-brand-600">
                  <Loader2 className="h-3 w-3 animate-spin" />
                  生成中
                </span>
              )}
              <button
                type="button"
                onClick={openTab}
                title="新窗口打开"
                className={cn('ml-auto', ICON_BTN)}
              >
                <ExternalLink className="h-3.5 w-3.5" />
              </button>
              <button
                type="button"
                onClick={() => setExpanded(false)}
                title="关闭"
                className={ICON_BTN}
              >
                <X className="h-4 w-4" />
              </button>
            </div>
            <ArtifactFrame artifact={artifact} autoHeight={false} className="min-h-0 flex-1" />
          </div>
        </div>,
        document.body,
      )}
    </div>
  );
}

/**
 * 状态文案的**最短停留**。
 *
 * 🔴 2026-08-04 走查:文案一闪而过,根本看不清它在做什么。
 *   `get_current_user`、`get_agents` 这类工具 100ms 就返回,
 *   于是「正在确认你的身份与权限」这句在屏幕上活不过两帧 —— 主管只看到一片闪烁,
 *   反而以为界面卡了。慢工具(propose_assignment)的文案倒是看得见,
 *   所以他会以为"只有那一句文案"。
 * ⚠️ 用「最新覆盖待显示」而不是排队:排队会让文案在活干完之后还继续播,
 *   5 个快工具能拖出 3 秒假忙碌 —— 那是另一种撒谎。上限就是一个停留周期。
 */
const HINT_MIN_MS = 700;

function useStickyLabel(target: string): string {
  const [shown, setShown] = useState(target);
  const freeAt = useRef(0);
  useEffect(() => {
    if (target === shown) return;
    const wait = freeAt.current - Date.now();
    if (wait <= 0) {
      setShown(target);
      freeAt.current = Date.now() + HINT_MIN_MS;
      return;
    }
    const t = setTimeout(() => {
      setShown(target);
      freeAt.current = Date.now() + HINT_MIN_MS;
    }, wait);
    return () => clearTimeout(t);
  }, [target, shown]);
  return shown;
}

// 生成中指示:覆盖"工具运行 / 工具已返回但下一段还在生成"的空档。
// 有工具在跑 → 显示"正在xxx…"(动作);否则默认"生成中…"。
function GeneratingHint({ label = '生成中' }: { label?: string }) {
  // ⭐ 组件在整个"调工具"阶段是常驻的(只有 label 变),所以粘滞状态放这里就够,
  //   ⛔ 不必提到 MessageView —— 那里有 user 分支的提前 return,加 hook 会条件调用。
  const shown = useStickyLabel(label);
  return (
    <div className="flex items-center gap-1.5 text-[12px] text-slate-400">
      <Loader2 className="h-3.5 w-3.5 animate-spin text-brand-500" />
      <span className="animate-in fade-in duration-200">{shown}…</span>
    </div>
  );
}

function MessageView({
  message,
  streaming,
  onSheetConfirmed,
  onSheetEdited,
  onRearrangeEdited,
  onRearrangeConfirmed,
  onSignalIntent,
  onSignalCompose,
  onSheetReplaced,
  liveSignalKeys,
  onLiveSignalsChange,
  busyIntent,
  showTrace,
}: {
  message: ChatMessage;
  streaming?: boolean;
  onSheetConfirmed?: (
    requestId: string,
    assignmentId: string,
    summary: string,
    modelFacts?: string,
  ) => void;
  /** 助手改确认单后,界面回报的那一句(注入消息流) */
  onSheetEdited?: (summary: string, modelText?: string) => void;
  /** 助手改**调整单**后,界面回报的那一句 */
  onRearrangeEdited?: (summary: string, modelText?: string) => void;
  /** 调整确认之后的结果 */
  onRearrangeConfirmed?: (summary: string, modelFacts: string) => void;
  /** 「重新排一版」回来的新提案 —— 整块换掉那张卡 */
  onSheetReplaced?: (requestId: string, next: AssignmentProposal) => void;
  /** ⭐ 引导节点按钮 —— 与助手共用同一组 intent（见 chat-blocks.intentToSheetOps） */
  onSignalIntent?: (intent: string, args?: Record<string, unknown>) => void;
  /** 引导节点的兜底 —— 填进输入框并聚焦 */
  onSignalCompose?: (draft: string) => void;
  /** requestId → 主语还在的节点 key（由确认单上报；缺省 = 都还在） */
  liveSignalKeys?: Record<string, string[]>;
  onLiveSignalsChange?: (requestId: string, keys: string[]) => void;
  /** 正在执行的 intent —— 按钮转圈 + 整排禁用 */
  busyIntent?: string | null;
  /** 展开思考与工具调用 —— 由底部工具栏那个开关控制 */
  showTrace: boolean;
}) {
  if (message.role === 'user') {
    return (
      <div className="flex justify-end gap-2.5">
        {/* ⚠️ 主管说的话用**品牌主色**(PANTONE 286 C),⛔ 别用中性深灰:
              助手那边是白底,这一侧是唯一的实色块 —— 它就是这一屏的主色出场位。 */}
        <div className="max-w-[80%] rounded-2xl rounded-tr-sm bg-brand-600 px-3.5 py-2 text-[13.5px] leading-relaxed text-white">
          {message.blocks.map((b) => (b.kind === 'text' ? b.text : '')).join('')}
        </div>
      </div>
    );
  }
  // 流式中:若最后一块不是正在增长的文字(即在调工具/生成 artifact 的空档),补一个 loading 指示。
  // 若最后一块是正在跑的工具 → 显示"正在xxx…"(动作);否则默认"生成中…"。
  const last = message.blocks[message.blocks.length - 1];
  const showHint = streaming && (!last || last.kind !== 'text');
  // ⚠️ 找**还在跑的**工具,⛔ 不能只看最后一块:模型并行调三个工具时,
  //   最后那块可能已经 done 而前两个还在跑 —— 只看最后一块就退回「生成中」,
  //   前面两句文案一次都不会出现。
  const runningTool = [...message.blocks]
    .reverse()
    .find((b): b is Extract<Block, { kind: 'tool' }> => b.kind === 'tool' && b.step.status === 'running');
  const hintLabel = runningTool ? toolMeta(runningTool.step.tool).running : '生成中';
  // ⛔ 助手消息**不带左侧头像**(2026-08-03 走查):助手窗只有 480px,
  //   每条都挂一个 28px 的圆头像 + 间距 = 白白吃掉一成宽度,而"这段是助手说的"
  //   靠气泡形态(用户消息右对齐深底、助手左对齐无底)已经分得清了。
  /**
   * ⭐ **确认单:钉在消息末尾,且要等这段话说完才出现**(2026-08-03 走查)。
   *
   * 为什么不能靠"让模型先说话"解决:工具调用**必然发生在文字之前**
   * (模型要先拿到结果才知道说什么),而卡片是工具执行时经侧信道推来的 ——
   * 到达顺序天然是"卡片 → 文字",改不了。所以两件事都在渲染层做:
   *   ① 排序:把 assignment_sheet 挪到末尾。⛔ 别整体 sort ——
   *      文字块之间的先后是流式拼出来的,打乱就成乱码。
   *   ② **流式期间先不渲染** —— 否则主管看到的是"卡片先蹦出来,文字再在它上面一行行长",
   *      比卡片在前更怪。说完了再出现,顺序才成立。
   */
  const sheets = message.blocks.filter((b) => b.kind === 'assignment_sheet');
  // ⚠️ 调整单单独一叠 —— 与确认单**各有各的锚点**,同一条消息里两张都可能在
  const rearranges = message.blocks.filter((b) => b.kind === 'rearrange_sheet');
  /**
   * ⭐ **收起思考与工具调用**时，就在这里把那两类块滤掉（2026-08-15）。
   *
   * ⚠️ 只此一处过滤：`firstText` / `isRooted` / `anchorAt` 全都基于 `rest` 的下标算，
   *   在这之后统一少几块，相对顺序不变，那些判断照常成立。
   *   ⛔ 别改成在 `BlockView` 里逐块 return null —— 那样块还占着下标，
   *   「锚点在不在第一段正文之前」会被已经不可见的块影响。
   * ⚠️ 底部那行小结（执行过程 · N 步）**不跟着收** —— 收起之后它是唯一还看得见的痕迹，
   *   一眼知道这轮跑过什么、也知道有东西被收起来了。
   */
  const rest = message.blocks.filter(
    (b) =>
      b.kind !== 'assignment_sheet' &&
      b.kind !== 'rearrange_sheet' &&
      /**
       * 🔴 **工具灰行整个不给主管看**（2026-08-19 产品定）——「出分配方案 `propose_assignment`」
       *   这一行里，前半是产品说法、后半是**函数名**，而主管的词汇表就是他在界面上见过的那些
       *   （同 ② 诚实层那条）。他要判断的是"这一版分得对不对"，⛔ 不是"它走了哪条代码路径"。
       * ⚠️ 可核查性**没有丢**，只是挪了地方：
       *   · 「正在出分配方案…」那句等待文案还在（`hintLabel`，说的是动作不是工具名）；
       *   · 每一步的形状落在 `agent_invocations.output.steps`（服务端留痕，可回查）；
       *   · 裸台（`/assistant-lab`）仍旧原样展示全部步骤 —— 排查去那儿。
       * ⇒ 开关（`showTrace`）现在只管**思考**那一类。
       */
      b.kind !== 'tool' &&
      (showTrace || b.kind !== 'reasoning'),
  );

  /**
   * ⭐ **引导节点的位置由模型决定** —— 它写完「要您定的」某一条，紧接着调
   *   `show_guidance`，界面就在流里那个位置插一个 `guidance` 块（见 chat-blocks）。
   *   ⇒ 这里不做任何匹配、不猜位置。曾经用 `text.indexOf(标题)` 猜过（已删）：
   *     模型换个措辞就匹配不上，而匹配不上就渲染不出来。
   *
   * 这里只做两件事：
   *   ① `placedKeys` —— 已经插进正文的那几条，卡片上不再重复显示;
   *   ② `signalOf`  —— 按 key 从**当前**这张确认单上取那条节点（调整后自动失效）。
   * 🔴 **漏调不丢东西**：模型没插的那几条仍旧显示在卡片上（今天的行为原样保留）。
   *   ⇒ 卡片本身就是兜底面，⛔ 不需要另做一套"孤儿补渲染"。
   */
  const live = sheets.find((b) => b.state === 'active') ?? sheets[sheets.length - 1];
  const placedKeys = rest.filter((b) => b.kind === 'guidance').map((b) => b.signalKey);
  /**
   * signalKey → 当前这张确认单上那条节点；取不到 = 别渲染。
   *
   * 🔴 两道关：① 这张单上根本没这条（调整换过版）；
   *   ② **主语已经没了** —— 待分配被处置光之后，卡片会上报"还活着"的 key，
   *      不在里面的就不再渲染（2026-08-13 实测：点完「各自归专属客服」，
   *      待分配清零、卡片头也变了，而正文里那四个按钮还在，再点是空操作）。
   * ⚠️ **fail-open**:卡片还没上报（`undefined`）时按"都还在"渲染 ——
   *   宁可多显示一次，⛔ 也不能把「还有人没发出去」静默藏掉。
   */
  const signalOf = (key: string) => {
    if (!live) return undefined;
    const alive = liveSignalKeys?.[live.requestId];
    if (alive && !alive.includes(key)) return undefined;
    return (live.sheet.signals ?? []).find((s) => s.key === key);
  };

  /**
   * 🔴 **选项上面必须有话** —— 引导块落在第一个文本块之前时它是无根的：
   *   主管看到四个按钮，而"这是在说哪件事"还没写出来。
   *
   * ⚠️ 这不是又去猜位置，是一条**退化保护**：模型会把这一轮的 tool-call
   *   全部提到文本之前（`[工具×4][文本]`），而正常形态是交错的
   *   （`[文本][工具][文本][工具]…`）。⇒ 无根的一律挪到正文之后、卡片之前。
   * ⛔ 别把有根的也一起挪 —— 那就把模型定的位置又推翻了。
   *
   * 🔴 **2026-08-15 修正归因**：这条原文写的是「实测 **qwen** 会…，deepseek 则正常交错」，
   *   把它当成了模型之间的差别。**不对** —— 当天在 **deepseek** 上抓到过同一个形状
   *   （golden 的 `guidance-opens-in-place`，失败形状 `[工具×3][文本]`），
   *   是补了工具描述里「可动手的事**一件一件**开放」才稳住的。
   *   ⇒ 这是**提示词缺口**的表现，⛔ 不是某个模型的特性；那次观察从没把两者分开。
   * ⚠️ 所以这条保护**与模型无关，一直要留着**：即便换了模型、改好了提示词，
   *   它仍然有概率退化，而退化时主管看到的是一排没头没尾的按钮。
   */
  const firstText = rest.findIndex((b) => b.kind === 'text');
  const isRooted = (i: number) => firstText !== -1 && i > firstText;

  /**
   * 确认单卡片摆在哪：**有根的 `sheet_anchor` 就摆那儿，否则钉在末尾**（今天的行为）。
   *
   * ⭐ 摆在锚点上时**流式期间也渲染** —— 这正是这个锚点存在的理由：
   *   卡片不必再等整轮跑完（那一轮现在还含着几次插引导节点的往返）。
   * 🔴 漏调 / 无根 → 走老路（等流式结束、钉末尾）。⛔ 卡片永远不会不出现。
   */
  const anchorAt = rest.findIndex((b, i) => b.kind === 'sheet_anchor' && isRooted(i));
  const rearrangeAnchorAt = rest.findIndex(
    (b, i) => b.kind === 'rearrange_anchor' && isRooted(i),
  );
  const orphan = rest.filter((b, i) => b.kind === 'guidance' && !isRooted(i));
  const orphanKeys = new Set(
    orphan.map((b) => (b.kind === 'guidance' ? b.signalKey : '')).filter(Boolean),
  );

  const body: Block[] = [];
  for (let i = 0; i < rest.length; i++) {
    const b = rest[i]!;
    if (b.kind === 'guidance' && !isRooted(i)) continue; // 无根引导 → 挪到正文之后
    if (b.kind === 'sheet_anchor') {
      if (i === anchorAt) body.push(...sheets); // 锚点本身不渲染，只是卡片的位置
      continue;
    }
    if (b.kind === 'rearrange_anchor') {
      if (i === rearrangeAnchorAt) body.push(...rearranges);
      continue;
    }
    body.push(b);
  }
  /**
   * 🔴 **无根引导流式期间不渲染**（2026-08-14 走查）。
   *
   * 走查现象：点完「只选儿牙会员那 76 人」调整，正文才写到「这一格总人数：76 人（刚好就是」，
   * 底下就已经挂出了「15 人的专属客服这轮已排满」那一排按钮 —— 一个**派活**层面的节点，
   * 压在还没写完的**选人**那一段下面。
   *
   * 成因是已知的 qwen 退化态（`[工具×4][文本]`，见上面 `isRooted` 那段）：
   * 引导块比正文先到，被判为无根、挪到正文之后 —— 而"正文"这时只有半句。
   * ⚠️ **等文本长完，它自己就落到对的地方了**，所以这不是位置算错，是**过早显示**。
   *
   * ⇒ 与下面那行 `sheets` 用的是同一条规矩：**位置不可信的东西，等说完再摆**。
   *   ⛔ 别把有根的也一起延后 —— 那些的上方文本已经完整，它们不会跳。
   */
  const ordered = [
    ...body,
    ...(streaming ? [] : orphan),
    ...(anchorAt !== -1 || streaming ? [] : sheets),
    // 与确认单同一条规矩:漏调锚点 / 无根 → 等说完、钉末尾。⛔ 卡片永远不会不出现
    ...(rearrangeAnchorAt !== -1 || streaming ? [] : rearranges),
  ];
  return (
    /**
     * ⚠️ 末尾那条 arbitrary variant 是 `first:mt-0` 的替身：**只把整条消息的第一个标题**
     *   顶到 0，⛔ 不再是"每个文本块的第一个"（见 Markdown 里 h3 那段）。
     *   写成后代选择器是因为 Markdown 自带一层 `<div>` 包裹。
     */
    <div className="min-w-0 space-y-2.5 [&>div:first-child>h3:first-child]:mt-0">
      {ordered.map((b, i) => (
        <BlockView
          // 🔴 确认单**必须用稳定 key**(requestId),⛔ 不能用下标。
          //   下标 key + "把 sheet 钉到消息末尾"的排序 = 助手每追加一句文本,
          //   sheet 的下标就变 → React 卸载重建组件 → **本地状态全丢**:
          //   主管删的条、拖的改派、逐条时限、以及"能不能撤销"的确认时刻,
          //   都会在助手说下一句话时被悄悄清空(实测:确认后撤销按钮直接不出现)。
          //   ⚠️ 这是排序改动引入的回归 —— 排序本身没错,错在 key 跟着位置走。
          key={
            b.kind === 'assignment_sheet'
              ? `sheet:${b.requestId}`
              : // 🔴 同理:调整单也必须用稳定 key,否则助手每追加一句话,
                //   主管拖的改派 / 改的时限 / 移出的那几条会被悄悄清空
                b.kind === 'rearrange_sheet'
                ? `rearrange:${b.operationId}`
                : `b:${i}`
          }
          block={b}
          placedKeys={placedKeys}
          signalOf={signalOf}
          streaming={streaming}
          onLiveSignalsChange={onLiveSignalsChange}
          busyIntent={busyIntent}
          orphanKeys={orphanKeys}
          onSheetConfirmed={onSheetConfirmed}
          onSheetEdited={onSheetEdited}
          onRearrangeEdited={onRearrangeEdited}
          onRearrangeConfirmed={onRearrangeConfirmed}
          onSignalIntent={onSignalIntent}
          onSignalCompose={onSignalCompose}
          onSheetReplaced={onSheetReplaced}
        />
      ))}
      {showHint && <GeneratingHint label={hintLabel} />}
      {/**
       * ⛔ 「执行过程 · N 步 出分配方案 → 摆选项按钮 → …」**已删**（2026-08-19 产品定）。
       *   同上面工具灰行那条理由：主管要判断的是这一版分得对不对，
       *   而这行小结讲的是**装置内部怎么跑的** —— 它连"摆按钮"这种渲染动作都列出来了。
       * ⚠️ `StepTrace` 组件本身**留着**，裸台（`/assistant-lab`）还在用它排查。
       */}
    </div>
  );
}

function ModelSelect({
  model,
  setModel,
  disabled,
}: {
  model: string;
  setModel: (m: string) => void;
  disabled?: boolean;
}) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const h = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener('mousedown', h);
    return () => document.removeEventListener('mousedown', h);
  }, []);
  const label = MODELS.find((m) => m.value === model)?.label ?? model;
  return (
    <div className="relative" ref={ref}>
      {/*
        ⭐ 无边框、无底色、次要字号（2026-08-15 对齐 Claude Code 底部那排）。
        ⛔ 去掉了名字前面那个绿点：它**恒绿**，不表达任何状态 ——
          一个永远为真的指示灯只会稀释真正有状态的那几处（失败红、录音红）。
      */}
      <button
        type="button"
        disabled={disabled}
        onClick={() => setOpen((o) => !o)}
        className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-[12px] text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 disabled:opacity-50"
      >
        {label}
        <ChevronDown className={cn('h-3 w-3 text-slate-400 transition-transform', open && 'rotate-180')} />
      </button>
      {open && (
        // ⚠️ 向**上**弹:它现在坐在窗口最底下,朝下弹会被裁掉
        <div className="absolute bottom-full right-0 z-50 mb-1 w-40 rounded-lg border bg-white py-1 shadow-lg">
          {MODELS.map((m) => (
            <button
              key={m.value}
              type="button"
              onClick={() => {
                setModel(m.value);
                setOpen(false);
              }}
              className={cn(
                'flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12.5px] hover:bg-slate-50',
                m.value === model ? 'font-medium text-brand-700' : 'text-slate-700',
              )}
            >
              {m.value === model ? <Check className="h-3.5 w-3.5" /> : <span className="w-3.5" />}
              {m.label}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

export function AssistantChat({
  variant = 'page',
  onClose,
  onToggleMaximize,
  maximized = false,
  examples,
}: {
  /** page = /assistant 整页;widget = 详情页右下角吸附小窗(紧凑头部 + 可收起) */
  variant?: 'page' | 'widget';
  onClose?: () => void;
  /** widget:最大化/还原切换(不传则不显示最大化按钮) */
  onToggleMaximize?: () => void;
  /** widget:当前是否最大化(控制图标 Maximize2↔Minimize2) */
  maximized?: boolean;
  /** 空态开场建议(规则生成,调用方按场景定制);不传用页面版默认 */
  examples?: string[];
} = {}) {
  const {
    messages,
    status,
    model,
    setModel,
    send,
    stop,
    appendAssistantNote,
    settleSheet,
    replaceSheet,
    applySignalIntent,
    busyIntent,
  } = useAssistantChat();

  /**
   * 占位符按**角色**分。
   *
   * ⚠️ 原来只有一句「问关于患者画像 / 事实 / 召回计划的问题」—— 那是**客服**的活。
   *   主管打开助手是来分配和看批次跑得怎么样的,占位符从不提这两件事,
   *   等于把他能用的一半能力藏起来了(T16:主管本质也是客服,所以两件事都要留)。
   * ⚠️ 判据用 `PLAN_DISPATCH` —— 与召回池 / 确认单同一个闸,⛔ 别另立标准。
   */
  const canDispatch = useHasPermission(Permission.PLAN_DISPATCH);
  /**
   * ⚠️ 两句都**去掉了「(Enter 发送,Shift+Enter 换行)」** —— 助手窗的输入框只有 300px,
   *   而原来那句连提示带正文 450px,**那半句从来就没显示出来过**(实测量的)。
   *   占着长度却看不见,还挤掉了真正该说的"你能让我干什么"。
   *   换行键这种约定靠试一次就知道,⛔ 不值得用唯一一行提示位去讲。
   */
  const placeholder = canDispatch
    ? '让我出一批分配方案，或问某批分得怎么样…'
    : '问患者画像 / 事实 / 召回计划…';

  /**
   * 确认单落库成功。两件事一个都不能少:
   *   ① 卡片切终态(按钮禁用 + 显示批次号)
   *   ② ⭐ **往消息流注入一条文本块** —— `toApiMessage` 只回传文本块,
   *      不注入的话下一轮主管说「刚才那批改成 5 天」,模型手里没有"那批"的任何痕迹,
   *      会当成新需求重新提议一次。改动极小但极易漏,漏了会被当成模型能力问题。
   */
  const onSheetConfirmed = (
    requestId: string,
    assignmentId: string,
    summary: string,
    modelFacts?: string,
  ) => {
    // 🔴 曾经在这里把 `postConfirm`(补挂福利那条引导)挂进这张单的 signals ——
    //   删于 2026-08-15:确认单里不做福利引导(见 assignment-confirm-sheet 的 confirm())。
    settleSheet(requestId, assignmentId);
    // ⭐ 第二个参数带完整批次 id —— 模型撤销时唯一的 id 来源(见 assignment-confirm-sheet)
    // ⚠️ `asNewMessage`:主管点确认发生在模型发言之外,贴到上一条尾巴上会被当成上一段的续写,
    //   他根本不觉得助手说了话(实测反馈:"agent 没有输出引导")。自成一条才读得出来。
    appendAssistantNote(summary, modelFacts, { asNewMessage: true });
  };
  /**
   * 助手改完确认单,界面把**实际结果**回一句进消息流。
   *
   * ⭐ 理由同上:模型看不到卡片。它只知道"指令已交给界面",成没成、有没有人没找到,
   *   全靠这一句。不注入的话它下一轮就会自信地说"已经移除了",而那个人可能还在。
   */
  const onSheetEdited = (summary: string, modelText?: string) =>
    appendAssistantNote(summary, modelText);
  /**
   * 调整单那条路的两句回灌 —— ⛔ 与上面确认单的**各走各的**。
   * 走的是同一条 `appendAssistantNote`(那只是"把一句话放进消息流"),
   * 但入口分开:两条路的语义不同,合并之后哪句是谁说的就查不出来了。
   */
  const onRearrangeEdited = (summary: string, modelText?: string) =>
    appendAssistantNote(summary, modelText);
  const onRearrangeConfirmed = (summary: string, modelFacts: string) =>
    // ⚠️ `asNewMessage`:确认发生在模型发言之外,贴上一条尾巴上他读不出来助手说了话
    appendAssistantNote(summary, modelFacts, { asNewMessage: true });
  /** 「重新排一版」回来 → 整块换掉那张卡(人群没变、人换了) */
  const onSheetReplaced = (requestId: string, next: AssignmentProposal) =>
    replaceSheet(requestId, next);
  /**
   * requestId → 主语还在的引导节点 key，由确认单上报（见它的 liveSignals）。
   * ⚠️ 只在**内容真变了**时才 setState —— 上报走的是 effect，无脑写会自激成循环。
   */
  const [liveSignalKeys, setLiveSignalKeys] = useState<Record<string, string[]>>({});
  const onLiveSignalsChange = useCallback((requestId: string, keys: string[]) => {
    setLiveSignalKeys((prev) => {
      const cur = prev[requestId];
      if (cur && cur.length === keys.length && cur.every((k, i) => k === keys[i])) return prev;
      return { ...prev, [requestId]: keys };
    });
  }, []);

  const [input, setInput] = useState('');
  /**
   * 引导节点的兜底 —— 把一句**开头**填进输入框并聚焦，⛔ 不直接发送。
   *
   * ⭐ 为什么不直接发：那句里带着「关于「XXX」——」的上下文前缀，
   *   主管**得看得见**它会替自己说什么，不想要能自己删。⛔ 界面不替他说他看不见的话。
   * ⚠️ 光标要落到**末尾**，否则他一开口就打在前缀中间。
   * ⚠️ 高度要跟着重算 —— 和 textarea 的 onChange 用同一套（`min(scrollHeight,140)`），
   *   ⛔ 别只 setInput：填一句长的进去，框还是一行高，后半截看不见。
   */
  const composeDraft = useCallback((draft: string) => {
    // ⚠️ 先**同步**聚焦：在用户手势的调用栈里 focus 最可靠，⛔ 别都推迟到回调里。
    taRef.current?.focus();
    /**
     * 空串 = **只聚焦**，到此为止。
     * ⛔ 别 setInput('')：会清掉主管已经打了一半的字。
     * ⛔ 也别去动光标和高度：他没让你改内容，你就不该动他的输入框 ——
     *   实测这里重算过高度，结果留下一个 `height:140px` 的残留内联样式，
     *   框空着却占了五行高。
     */
    if (!draft) return;
    setInput(draft);
    /**
     * ⚠️ ⛔ 别用 `requestAnimationFrame`：窗口没聚焦 / 标签页在后台时 rAF 会被暂停，
     *   焦点和光标就都不会落上（实测：`document.hasFocus()` 为 false 时整个回调不执行）。
     *   `setTimeout(0)` 在同样情况下照常跑。
     */
    setTimeout(() => {
      const ta = taRef.current;
      if (!ta) return;
      ta.focus();
      ta.setSelectionRange(ta.value.length, ta.value.length);
      ta.style.height = 'auto';
      ta.style.height = Math.min(ta.scrollHeight, 140) + 'px';
    }, 0);
  }, []);
  const scrollRef = useRef<HTMLDivElement>(null);
  const taRef = useRef<HTMLTextAreaElement>(null);
  // 是否"贴底":用户在底部附近时跟随自动滚;一旦上滚阅读则停,直到滚回底部。
  const stickRef = useRef(true);
  /// ⭐ 同一件事的**可渲染**副本 —— ref 改了不触发重渲染,而"回到底部"按钮要跟着显隐。
  ///   ⚠️ 两者必须在同一处更新(见 onScroll),分开写必然漂。
  const [atBottom, setAtBottom] = useState(true);

  // 流式/新消息时,仅在贴底状态才滚到底(否则尊重用户当前阅读位置)。
  useEffect(() => {
    const el = scrollRef.current;
    if (el && stickRef.current) el.scrollTop = el.scrollHeight;
  }, [messages]);

  // 用户滚动 → 据是否接近底部(<80px)更新贴底状态。
  const onScroll = () => {
    const el = scrollRef.current;
    if (!el) return;
    const near = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
    stickRef.current = near;
    setAtBottom(near);
  };
  const toBottom = () => {
    const el = scrollRef.current;
    if (!el) return;
    el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
    stickRef.current = true;
    setAtBottom(true);
  };

  // 发起新提问 → 重新贴底(让用户看到自己的消息 + 回答)。
  const fire = (text: string) => {
    stickRef.current = true;
    void send(text);
  };

  // ⭐ 外部投递的提问(「移交助手」等业务入口)—— 见 assistant-store。
  //   ⚠️ 只在 widget 变体里消费:widget 常驻挂载,而 /assistant 整页可能同时开着,
  //   两处都消费会把同一句话发两遍。
  //   ⚠️ 依赖只写 pending?.seq:写整个 pending 对象会因引用变化在 StrictMode 下重复触发。
  const pending = useAssistantStore((st) => st.pending);
  const consumePending = useAssistantStore((st) => st.consume);
  const pendingSeq = pending?.seq;
  useEffect(() => {
    if (variant !== 'widget' || !pending) return;
    if (status === 'streaming') return; // 上一轮还在跑,等它完了再发,别打断
    fire(pending.text);
    consumePending(pending.seq);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pendingSeq, status, variant]);

  const submit = () => {
    if (!input.trim() || status === 'streaming') return;
    fire(input);
    setInput('');
    if (taRef.current) taRef.current.style.height = 'auto';
  };

  // ── 实时听写:点击开启 → 边说边出字到输入框;再点 = 纯退出(已上屏文字保留)──
  // 链路:麦克风 PCM16 16k(RMS 静音门控)→ socket.io → 网关按停顿断句调 SenseVoice
  //   dictation:partial = 当前句滚动识别(覆盖式);dictation:final = 本句定稿(累加)
  const [voice, setVoice] = useState<'idle' | 'live'>('idle');
  const [voiceErr, setVoiceErr] = useState<string | null>(null);
  /**
   * 思考**展不展开**（2026-08-15 产品加；2026-08-19 起工具调用不在它管辖内了）。
   *
   * ⚠️ 默认展开：模型讲了什么、怎么想的，主管看得懂也用得上。
   * ⚠️ 只活在这个组件里，刷新回到默认 —— 它是一次阅读时的取舍，⛔ 不是账号设置。
   * ⚠️ 「模型到底调没调工具」这件事**不再靠界面看**（工具行已删）——
   *   它落在 `agent_invocations.output.steps` 里，回查见那边注释。
   */
  const [showTrace, setShowTrace] = useState(true);
  const dictRef = useRef<{
    socket: Socket;
    stream: MediaStream;
    ctx: AudioContext;
    base: string;
    finalized: string;
  } | null>(null);

  const stopVoice = () => {
    const d = dictRef.current;
    dictRef.current = null;
    if (d) {
      d.socket.disconnect();
      d.ctx.close().catch(() => undefined);
      d.stream.getTracks().forEach((t) => t.stop());
    }
    setVoice('idle');
  };

  const toggleVoice = async () => {
    setVoiceErr(null);
    if (voice === 'live') {
      stopVoice();
      return;
    }
    const token = useAuthStore.getState().accessToken;
    if (!token) {
      setVoiceErr('未鉴权,请重新登录');
      return;
    }
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
      });
      const socket = io(`${env.apiBaseUrl}/pac/v1/assistant/dictation`, {
        query: { token },
        transports: ['websocket'],
        forceNew: true,
      });
      const ctx = new AudioContext();
      const source = ctx.createMediaStreamSource(stream);
      const proc = ctx.createScriptProcessor(4096, 1, 1);
      // 静音门控:无声不发帧 → 帧间隙就是网关的断句信号(同 realtime-coach 参数)
      const SILENCE_RMS = 0.03;
      const ONSET_FRAMES = 2;
      const HANGOVER_FRAMES = 6;
      let hangover = 0;
      let loudStreak = 0;
      proc.onaudioprocess = (e) => {
        if (socket.disconnected) return;
        const f32 = e.inputBuffer.getChannelData(0);
        let sum = 0;
        for (let i = 0; i < f32.length; i++) sum += f32[i]! * f32[i]!;
        const rms = Math.sqrt(sum / f32.length);
        if (rms > SILENCE_RMS) {
          loudStreak++;
          if (loudStreak >= ONSET_FRAMES) hangover = HANGOVER_FRAMES;
        } else {
          loudStreak = 0;
        }
        if (hangover <= 0) return;
        hangover--;
        const pcm = floatToPcm16(downsampleTo16k(f32, ctx.sampleRate));
        socket.emit('audio:frame', { frame: bytesToBase64(pcm) });
      };
      source.connect(proc);
      proc.connect(ctx.destination);

      const d = { socket, stream, ctx, base: input.trim() ? `${input.trimEnd()} ` : '', finalized: '' };
      dictRef.current = d;
      const render = (partial: string) => setInput(`${d.base}${d.finalized}${partial}`);
      socket.on('dictation:partial', ({ text }: { text: string }) => render(text));
      socket.on('dictation:final', ({ text }: { text: string }) => {
        if (text) d.finalized += text;
        render('');
      });
      socket.on('dictation:error', ({ message }: { message: string }) => setVoiceErr(message));
      socket.on('connect_error', () => {
        setVoiceErr('听写连接失败');
        stopVoice();
      });
      setVoice('live');
      taRef.current?.focus();
    } catch {
      setVoiceErr('无法访问麦克风(请检查浏览器授权)');
      stopVoice();
    }
  };

  const isWidget = variant === 'widget';
  return (
    // ⭐ 白底(2026-08-15 对齐 Claude Code):会话区不是"一块卡片",是一页纸。
    //   ⚠️ Composer 用的是 `bg-inherit`,跟着这里走 —— ⛔ 别在那边再写死一次底色。
    <div className={cn('flex flex-col bg-white', isWidget ? 'h-full' : 'h-[100dvh]')}>
      {/*
        Header —— **无边框、无底色、只剩小图标**（2026-08-15 对齐 Claude Code）。
        🔴 去掉的三样：分隔线、`backdrop-blur` 的半透明白、以及左边那个实色产品图标。
          它们合起来把头部做成了"另一块面板"，而这一屏只该有一块 —— 内容本身。
        ⚠️ 标题降到次要色：这一行不是内容，是路标。⛔ 别加粗、⛔ 别用主色。
        ⚠️ 模型下拉**挪到底部工具栏**：它是"这一次要用什么发"，属于发送前的决定，
          跟输入框在一起才顺手；摆在头部时它是整屏视觉最重的东西，而它一天点不到一次。
      */}
      <header className="flex-none">
        <div
          className={cn(
            'mx-auto flex items-center gap-2',
            isWidget ? 'h-10 px-3' : 'h-12 max-w-3xl px-4',
          )}
        >
          <h1 className="text-[13px] font-medium text-slate-600">
            {isWidget ? '助手' : '外部助手'}
          </h1>
          <div className="ml-auto flex items-center gap-0.5">
            {onToggleMaximize && (
              <button
                type="button"
                onClick={onToggleMaximize}
                title={maximized ? '还原' : '最大化'}
                className={ICON_BTN}
              >
                {maximized ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
              </button>
            )}
            {onClose && (
              <button
                type="button"
                onClick={onClose}
                title="收起"
                className={ICON_BTN}
              >
                <ChevronDown className="h-4 w-4" />
              </button>
            )}
          </div>
        </div>
      </header>

      {/* Messages */}
      <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
        <div className="mx-auto max-w-3xl space-y-5 px-4 py-5">
          {messages.length === 0 ? (
            <div className={cn('flex flex-col items-center justify-center gap-4 text-center', isWidget ? 'py-8' : 'py-20')}>
              {/* ⛔ 这里原来摆着一个 48px 的实色圆角方块 + 机器人图标 ——
                  空态是这一屏唯一没有内容的时刻,而它把最重的视觉给了一个装饰。
                  ⇒ 只留一句话和几个例子,点哪个都能直接开始。 */}
              {/* ⛔ 这句话对**两种登录人**都得成立,所以不提任何一条具体业务线。
                  ⛔ 也别写回「问我关于患者的问题,我会查询 PAC 实时数据」——
                     「患者」把它窄成单人查询,而主管这一屏四条例句没有一条是问患者的;
                     「实时」是假话,患者事实来自 DW,每天 08:00(沪)才落库。 */}
              <div className="max-w-md text-[13.5px] text-slate-500">
                想问什么、想让我做什么，直接说；也可以点下面一条开始。
              </div>
              <div className="flex flex-wrap justify-center gap-1.5">
                {(examples ?? (canDispatch ? EXAMPLES_LEADER : EXAMPLES_STAFF)).map((ex) => (
                  <button
                    key={ex}
                    type="button"
                    onClick={() => fire(ex)}
                    className="rounded-full border bg-white px-3 py-1.5 text-[11.5px] text-slate-600 transition-colors hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700"
                  >
                    {ex}
                  </button>
                ))}
              </div>
            </div>
          ) : (
            /**
             * 🔴 **作废那一版整条不显示**（2026-08-13 产品定，推翻此前"作废的留在对话里
             *   并显式标注"那条）。理由：它一个字都没落库，人数、分给谁全是废数，
             *   而它在对话里占着大半屏 —— 主管往回翻时读到的是一份不存在的方案。
             *
             * ⚠️ 「他要能看见自己出过几版」这个诉求由**他自己那两句话**保住了：
             *   「出一份分配方案」→「每人每天按 20 通算，重新排一版」，一眼看得出调整过。
             * ⚠️ 判据与上下文回传**共用** `isDiscardedDraft` ——
             *   ⛔ 别在这里另写一份"哪种算作废"，界面藏了而上下文没清是最难查的那种不一致。
             */
            messages.filter((m) => !isDiscardedDraft(m)).map((m, i, shown) => (
              <MessageView
                key={m.id}
                message={m}
                streaming={status === 'streaming' && i === shown.length - 1 && m.role === 'assistant'}
                onSheetConfirmed={onSheetConfirmed}
                onSheetEdited={onSheetEdited}
                onRearrangeEdited={onRearrangeEdited}
                onRearrangeConfirmed={onRearrangeConfirmed}
                onSheetReplaced={onSheetReplaced}
                onSignalIntent={applySignalIntent}
                onSignalCompose={composeDraft}
                liveSignalKeys={liveSignalKeys}
                onLiveSignalsChange={onLiveSignalsChange}
                busyIntent={busyIntent}
                showTrace={showTrace}
              />
            ))
          )}
        </div>
      </div>

      {/* Composer —— 与会话区**同底色、无边框、无分隔线**(2026-08-03 走查,对齐 Claude Code 那种)。
          ⚠️ 底色写死 `bg-slate-50` 就会跟根容器漂;这里直接 `bg-inherit`,
          根容器换底色时它自动跟上。 */}
      <div className="relative flex-none bg-inherit">
        {/* ⭐ 「回到底部」—— 只在**没贴底**时出现,浮在输入框正上方居中(对齐 Claude Code)。
            ⚠️ 判据复用 onScroll 里那个 80px 阈值,⛔ 别再写第二个 —— 两个阈值不一致时,
            会出现"按钮还在、但其实已经到底了"这种解释不清的状态。 */}
        {!atBottom && messages.length > 0 && (
          <button
            type="button"
            onClick={toBottom}
            title="回到最新"
            className="absolute -top-4 left-1/2 z-10 inline-flex h-7 w-7 -translate-x-1/2 items-center justify-center rounded-full border bg-white text-slate-500 shadow-md transition-colors hover:bg-slate-50 hover:text-slate-700"
          >
            <ChevronDown className="h-4 w-4" />
          </button>
        )}
        <div className="mx-auto max-w-3xl px-4 pb-3 pt-1">
          {/* 输入框**有边框**(与会话同底,但自己成一块) —— 无边框那版太散,找不到落点 */}
          <div className="flex items-end gap-2 rounded-xl border bg-white px-3 py-2 transition-colors">
            <textarea
              ref={taRef}
              rows={1}
              value={input}
              onChange={(e) => {
                setInput(e.target.value);
                e.target.style.height = 'auto';
                e.target.style.height = Math.min(e.target.scrollHeight, 140) + 'px';
              }}
              onKeyDown={(e) => {
                if (e.key === 'Enter' && !e.shiftKey) {
                  e.preventDefault();
                  submit();
                }
              }}
              placeholder={placeholder}
              className="max-h-[140px] flex-1 resize-none bg-transparent py-1 text-[13.5px] leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
            />
            {status === 'streaming' ? (
              <button
                type="button"
                onClick={stop}
                title="停止"
                className="mb-0.5 inline-flex h-8 w-8 flex-none items-center justify-center rounded-lg bg-slate-100 text-slate-500 transition-colors hover:bg-slate-200"
              >
                <Square className="h-3.5 w-3.5" />
              </button>
            ) : (
              <button
                type="button"
                onClick={submit}
                disabled={!input.trim()}
                title="发送"
                className={cn(
                  'mb-0.5 inline-flex h-8 w-8 flex-none items-center justify-center rounded-lg transition-colors',
                  input.trim() ? 'bg-brand-600 text-white hover:bg-brand-700' : 'bg-slate-100 text-slate-400',
                )}
              >
                <Send className="h-4 w-4" />
              </button>
            )}
          </div>

          {/*
            工具栏 —— 输入框**下面**一行，左边是输入方式、右边是"用什么发"
            （2026-08-15 对齐 Claude Code）。
            ⚠️ 它整行都是次要色：这一行不该跟正在输入的那句话抢注意力。
            ⛔ 别把它塞回输入框里面：麦克风和模型是两件事，挤在一行里点错的是麦克风。
          */}
          <div className="mt-1.5 flex h-7 items-center gap-1 px-0.5">
            {VOICE_INPUT_ENABLED && (
              <button
                type="button"
                onClick={toggleVoice}
                title={voice === 'live' ? '结束听写' : '语音输入(边说边出字)'}
                className={cn(
                  'inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors',
                  // ⚠️ 录音中是**状态**,红 + 脉冲要保留 —— ⛔ 别为了"风格统一"抹平它
                  voice === 'live'
                    ? 'animate-pulse text-rose-600'
                    : 'text-slate-400 hover:bg-slate-100 hover:text-slate-600',
                )}
              >
                <Mic className="h-4 w-4" />
              </button>
            )}
            {/*
              思考的开关 —— ⚠️ 收起时**图标变实**（不是变淡）：
              「有东西被我收起来了」需要一直看得见。
              ⛔ 别用变淡表示"关"：那读起来像"这个功能不可用"。
            */}
            <button
              type="button"
              onClick={() => setShowTrace((v) => !v)}
              title={showTrace ? '收起思考' : '展开思考'}
              className={cn(
                'inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors',
                showTrace
                  ? 'text-slate-400 hover:bg-slate-100 hover:text-slate-600'
                  : 'bg-slate-100 text-slate-600 hover:bg-slate-200',
              )}
            >
              <Brain className="h-4 w-4" />
            </button>
            {voiceErr && <span className="text-[11.5px] text-rose-600">{voiceErr}</span>}
            <div className="ml-auto">
              <ModelSelect model={model} setModel={setModel} disabled={status === 'streaming'} />
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}
