---
url: /chat/chat-subcomponents.md
---
# 对话子组件

`EbChatbot` 之外的家族成员。**绝大多数场景不需要直接用它们**——`EbChatbot` 已经把这些组装好了。它们用于两种情形：只想要其中一块（比如独立渲染一段 Markdown），或要自己编排（`ChatList` + 自定义发送区）。

> 本页组件在独立包 `@wil-works/evoke-chat` 里，安装与注册见 [Chatbot 对话窗口](/chat/chatbot)顶部说明。

下表标题即导出名——`EbChatMessage`、`EbChatSender` 这样带 `Eb` 前缀整名导出。

## 结构类

### EbChatContent

面板外壳：固定尺寸、可选边框、头部/底部槽位。`EbChatbot` 用它包住消息区与输入区。

| Props | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `height` / `width` | string | number | `100%` | 数字按 px 处理 |
| `bordered` | boolean | `false` | 加边框，并给头尾加分隔线 |

| Slots | 说明 |
| --- | --- |
| `header` | 面板顶部 |
| `footer` | 面板底部 |
| 默认 | 主体内容（不自动滚动，滚动由 `ChatList` 负责） |

### EbChatList

消息滚动容器。负责自动贴底、`role="log"` 流式播报、回到底部钮、空态；不负责发送。

| Props | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `messages` | array | `[]` | 消息数组 |
| `showThinking` / `renderMode` / `autoScroll` | — | `true` / `markdown` / `true` | 透传给每条消息 |
| `avatarUser` / `avatarAssistant` / `userName` / `assistantName` | — | — / 我 / AI助手 | 透传 |
| `actions` | array | `[]` | 动作条自定义动作 |
| `editable` / `editMaxLength` | boolean / number | `false` / `0` | 用户消息可编辑重发 |
| `feedback` / `feedbackReasons` | boolean / array | `false` / `[]` | 评审与原因词汇表 |
| `toolRetryable` | boolean | `true` | 失败的工具卡给不给重试钮 |

| Events | 载荷 |
| --- | --- |
| `copy` / `regenerate` / `edit` / `feedback` / `suggestion-click` / `citation-click` / `tool-retry` | 见 [Chatbot 对话窗口](/chat/chatbot) 同名事件 |
| `action` | `(key, message)` |
| `scroll` | 原生滚动事件 |

| Slots | 说明 |
| --- | --- |
| `message` | 整条接管；作用域 `{ message, index, isLast, itemProps }`，用 `v-bind="p.itemProps"` 回落默认渲染 |
| `message-content` | 只换正文；作用域 `{ message, content, renderMode, streaming }` |
| `header` / `empty` | 消息区顶部 / 空态 |

默认槽产出为空时 Vue 会回落默认渲染——不能靠"某几行留空"隐藏消息。

### EbChatMessage

单条消息。按 role 分流：`user` / `assistant` 是对话气泡，`system` / `notice` 是居中弱化的系统提示（无头像、昵称、动作条）。

Props 与 `ChatList` 的透传面一致，外加 `message` 本身；其中 `show-avatar` / `show-name` 隐去消息头部的头像与昵称（传对象可分侧：`{ user: false }` / `{ assistant: false }`），`show-time` 控制时间戳（默认在消息下方、与动作条同排，悬浮或键盘聚焦时显示）。

| Events | 载荷 |
| --- | --- |
| `copy` / `regenerate` / `edit` / `feedback` / `suggestion-click` / `citation-click` / `tool-retry` | 见 [Chatbot](/chat/chatbot) |

| Slots | 说明 |
| --- | --- |
| `content` | 只换气泡内正文；作用域 `{ message, content, renderMode, streaming }` |

消息项的字段：`{ id, role, content, status, thinking?, thinkContent?, thinkDuration?, thinkInterrupted?, attachments?, suggestions?, feedback?, feedbackReasons?, feedbackNote?, edited?, citations?, toolCalls?, duration?, usage?, model?, provider?, progress?, progressLog?, parts?, error? }`，`status` 取 `pending / streaming / done / error / cancelled`；`thinking` 为真表示正在思考（流式期间思考块强制展开），`thinkDuration` 是思考耗时（毫秒，引擎自动结算），`thinkInterrupted` 为真表示思考阶段就被中断（思考块标题改说「思考已中断」）。`toolCalls[].argsStreaming` 为真表示入参在流式（JSON 文本逐片拼接，收尾自动 parse 回对象）；`model` / `provider` 是模型归属（多模型路由时底部读数行显示 `provider/model`，引擎只透传不解析）；`progress` 是当前阶段读数（`{ label, detail?, elapsedMs?, percent? }`），`progressLog` 是流水线轨迹（`appendProgress` 逐条追加并联动当前行，≥2 条时收进「前 N 个阶段」折叠，收尾清空）；`parts` 是到达顺序轨迹（`[{ id, type: 'text' | 'reasoning' | 'tool', text?, toolCallId? }]`，引擎在 `appendContent` / `appendThinkContent` / `startToolCall` 时自动记录）——有 parts 时组件按到达顺序**交错渲染**（正文→工具→正文），`content` / `thinkContent` / `toolCalls` 是它的平面投影（复制、朗读等平面消费照旧）；无 parts 的老消息走固定顺序（thinking → tools → 正文），零改动。

### EbChatMarkdown

Markdown 渲染器：GFM 表格与任务列表、代码块工具条（语言标签 + 复制）、自定义协议引用芯片、`source:` 行内引用上标、流式拖尾。原文 HTML 一律转义为纯文本；图片 `src` 只放行 `http` / `https` / `data`。

| Props | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `content` | string | `''` | Markdown 原文 |
| `streaming` | boolean | `false` | 流式态：末尾补拖尾，重解析按帧合并 |

| Events | 载荷 |
| --- | --- |
| `citation-click` | `(id)` 行内引用上标被点击或回车 |

行内锚点的生成助手是 `anchorSource(refId, text?)`（主入口导出）：返回 `[text](source:refId)` 片段，`refId` 与 `citations[].id` 对应；`text` 省略按出现顺序自动编号，给数字则固定序号。引用列表本身是**回合级**语义（渲染在正文之后），行内关系走这个协议。

管线配置（协议白名单、主题色、追加高亮语言）见 [Chatbot 的正文渲染配置](/chat/chatbot#正文渲染配置)。

## 过程类

### EbChatThinking

思考过程折叠块。流式期间强制展开，结束后回到用户可控的折叠态。`{ content, thinking, duration, interrupted }`（`duration` 为思考耗时，显示成「（用时 X）」；消息上下文里取 `message.thinkDuration`；`interrupted` 为真时标题说「思考已中断」，用于思考阶段就被停止的那一轮），无事件。

### EbChatLoading

三点起伏的打字指示。可选 `text` 文案。

### EbChatToolCall

工具调用卡：状态标记（等待/执行/失败/完成/已停止）、耗时、参数与结果的折叠展开，失败态给重试钮。**支持子调用**：`toolCall.subCalls` 是同一形状的数组（并行派发 / PTC 子步），展开后按层级递归渲染、左侧细轨标出从属关系，折叠时头部给子调用计数；嵌套上限 16 层（引擎与组件两侧都设了，异常自引用数据也不会炸）。`toolCall.streaming` 为真时结果区末尾补光标并自动展开盯跑（收尾后回到用户可控的折叠态）；`argsStreaming` 是入参侧的同款待遇——长参数（SQL / 查询 DSL）边收边看，收尾自动 parse 回对象；失败态在折叠行直接给**错误首行**，不展开也知道为什么失败；`status: 'cancelled'`（运行中被停止）用停止标记与「已停止」，不给重试钮、也不再转圈。

非文本结果：`toolCall.resultType` 给 `image` / `file` 时默认渲染换形态——image 认 `{ url, alt? }` 渲染成限高图片，file 认 `{ name, url?, size? }` 渲染成一行文件卡（图标按后缀、可点下载）；URL 只放行 `http` / `https` / `data`，其余退回文本（不留破图/坏链）。

| Props | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `toolCall` | object | `{}` | `{ id, name, label?, args?, result?, resultType?, status, duration?, error?, streaming?, argsStreaming? }` |
| `expanded` | boolean | 自动 | 未指定时有参数或结果才可展开 |
| `retryable` | boolean | `true` | 失败态是否给重试钮 |

| Events | 载荷 |
| --- | --- |
| `toggle` | `(toolCall, open)` |
| `retry` | `(toolCall)` |

| Slots | 作用域 |
| --- | --- |
| `args` / `result` | `{ toolCall }`，可换成 `EbJsonViewer` 等 |

参数与结果默认走带环检测的 JSON `<pre>`（宿主传入的对象常带循环引用）；`resultType: 'image' | 'file'` 时见上——引擎侧 `completeToolCall(msgId, callId, result, { resultType })` 写入，事件驱动走 `tool/result` 的 `resultType` 字段。

### EbChatApproval

审批面板：agent 要执行越权动作（跑命令、联网、改文件）时**接管输入区**——待审批期间发送台让位，用户只需做一个决定。`{ id, toolName, reason?, detail?, status? }`：给了 `reason` 就用它当标题，否则显示「工具 X 请求越权执行」；`detail` 常放被拦命令原文（等宽、限高可滚）。键位 **Enter = 允许一次 / Esc = 拒绝**（组字中、带修饰键、焦点在输入控件里时都不抢）。结论只有 `allowed-once | rejected` 两种——「总是允许」属于会话级权限模式，不塞进单次审批。

| Props | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `request` | object | `{}` | `{ id, toolName, reason?, detail?, status? }`；`status` 取 `pending / approved / rejected` |
| `answered` | boolean | — | 受控的已响应态；不传则组件自己记（点完立即禁用，等宿主持久态覆盖） |

| Events | 载荷 |
| --- | --- |
| `respond` | `(outcome, request)`，`outcome` 取 `allowed-once` / `rejected` |

`EbChatbot` 与 `EbAiConsole` 都有 `approval` prop：传了就接管输入区，响应经 `approval-respond` 抛给宿主（`(outcome, request)` 两参）。`useChatSession` 的 `approval` + `respondApproval(outcome)` 已经把这条链路接好（事件的 `approval/request` → 待审批，`approval/decided` → 收起）。

### EbChatQuestion

提问面板：agent 需要澄清时**接管输入区**（审批优先——越权动作必须先答）。`{ id, items: [{ id, question, header?, multiSelect?, allowCustom?, options: [{ key, label, recommended?, description? }] }], status? }`：逐题作答，单选互斥、多选累加；每题可写自定义答案（默认给输入框，`allowCustom: false` 关掉）；**跳过**算已作答（`skipped: true`），**取消**则整批作废。键位 **Enter 逐题前进、最后一题提交 / Esc 取消**（组字中、带修饰键时不抢）。

| Props | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `request` | object | `{}` | 见上；`status` 取 `pending / answered / cancelled` |
| `answered` | boolean | — | 受控的已响应态；不传则组件自己记 |

| Events | 载荷 |
| --- | --- |
| `respond` | `(answer, request)`；`answer = { status: 'answered' \| 'cancelled', answers: [{ id, question, selected, custom, skipped }] }` |

`EbChatbot` / `EbAiConsole` 的 `question` prop 传了就接管输入区，响应经 `question-respond(answer, request)` 抛出；`useChatSession` 的 `question` + `respondQuestion(answer)` 已把链路接好（`question/request` → 待回答，`question/decided` → 收起）。

### EbChatSources

### EbChatChanges

本轮改动汇总卡：标题给「已编辑 N 个文件」（单文件时直接给文件名），右侧 `+A -R`；展开后逐行列文件，每行带自己的增删数，二进制 / 过大不给行数只给标记。超出 `collapsedRows`（默认 4）折叠，给「全部 N 个文件」。路径等宽、**不折行**（保住目录层级），过长省略并留 `title`；点行抛 `select`（宿主接侧边栏预览）。

| Props | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `files` | array | `[]` | `[{ path, display?, added?, deleted?, binary?, oversized? }]` |
| `summary` | object | `null` | `{ total?, added?, deleted? }`；缺了按文件列表自己加 |
| `collapsedRows` | number | `4` | 折叠时先露几行 |
| `defaultOpen` | boolean | `false` | 初始是否展开列表 |

| Events | 载荷 |
| --- | --- |
| `select` | `(file)` |

消息带 `changes`（`{ files, total?, added?, deleted? }`）时 `EbChatMessage` 自动渲染；引擎侧 `setChanges(messageId, changes)` 写入，`useChatSession` 认 `workspace/changes` 事件（带 `messageId` 精确挂，不带则挂最后一条助手消息）。

来源引用卡列表：序号 / favicon / 标题 / 域名 / 摘要，可折叠。点击上标会自动展开并高亮对应卡片——该联动由 `ChatMessage` 内部接好，单独用时可调 `highlight(id)`（经 `defineExpose`）。

| Props | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `items` | array | `[]` | `{ id?, index?, title, url?, source?, snippet?, favicon? }` |
| `collapsible` | boolean | `true` | 关掉就是常驻列表 |
| `defaultOpen` | boolean | `true` | 初始展开 |

| Events | 载荷 |
| --- | --- |
| `item-click` | `(item, index)` |

## 代码 agent 三卡

`EbChatDiff` · `EbChatTerminal` · `EbChatFileTree`

三者都**零额外依赖**：diff 是纯解析 + CSS，终端自己解最小 ANSI（8/16 色 + 加粗），文件树是嵌套列表。

### EbChatDiff

吃标准统一 diff 文本（`git diff` 与各家 agent 输出的那种），按文件分组，头部给路径与 `+N −M`，行按增/删/上下文着色，带 old/new 双行号栅格。

`{ diff, showLineNumbers, collapsible, defaultOpen, maxHeight }`，抛 `toggle(index, open)` / `copy(rawDiff)`。**复制出去的是原始 diff 文本**，不是渲染结果（行号在 `aria-hidden` 的独立栅格里，不会被选进复制内容）。

认不出的行按上下文处理——宁可少染一点色，也不猜错语义。

### EbChatTerminal

命令输出卡：头部给命令与退出码，正文等宽渲染且**不折行**（保住终端列结构，横向滚动交给容器），`status: 'running'` 时末尾有光标。**长输出只渲染尾部**（默认 40 行），给省略数与展开入口——命令输出动辄上千行，全渲染会拖垮消息列。

ANSI 只认 8/16 色前景与加粗，其余码忽略（不认识的样式宁可不染）。安全性上先把文本转义、再只把解析器自己构造的 `span` 拼进去，原文里的任何字符都不会被当标记透传。

`{ command, output, status, exitCode, collapsible, defaultOpen, maxHeight, tailLines }`，抛 `toggle(open)` / `copy(纯文本)`。

### EbChatFileTree

扁平路径列表 → 嵌套树。目录在前、名称升序；文件带类型图标、状态徽标（新增/修改/删除/重命名）与增删行数。目录默认展开，折叠状态 `aria-expanded` 完整。

`{ files: [{ path, status?, additions?, deletions? }], defaultExpandAll }`，抛 `select(file, path)` / `toggle(path, open)`。

### 怎么接到消息上

**diff 与终端不加消息字段**——`EbChatToolCall` 的 `#result` 插槽就是为它们留的扩展点：

```vue
<eb-chatbot v-model="messages">
  <template #message="p">
    <eb-chat-message v-bind="p.itemProps">
      <template #content>
        <component :is="renderBody(p.message)" />
      </template>
    </eb-chat-message>
  </template>
</eb-chatbot>
```

更直接的写法是逐条接管工具卡的结果区：

```vue
<eb-chat-message :message="msg">
  <template #tool-result="{ toolCall }">
    <eb-chat-terminal
      v-if="toolCall.name === 'run_tests'"
      :command="toolCall.label"
      :output="toolCall.result"
      :exit-code="toolCall.exitCode"
    />
    <eb-chat-diff v-else-if="toolCall.resultType === 'diff'" :diff="toolCall.result" />
  </template>
</eb-chat-message>
```

**文件树**是改动集汇总、不属于单个工具，所以给了消息级字段 `message.fileTree`，渲染在产物之后，抛 `file-select(file, path, message)`。

## EbChatShare 会话分享弹层

收集「可见范围 + 有效期」，链接的生成与权限校验由你的服务端负责——组件**不发请求**；`link` 为空是配置态、非空是已创建态。

```vue
<eb-chat-share
  v-model="shareOpen"
  v-model:scope="scope"
  v-model:expiry="expiry"
  :title="activeThread?.title"
  :link="shareLink"
  :creating="creating"
  @create="createLink"
  @revoke="revokeLink"
/>
```

`create` 触发时按当前 `scope` / `expiry` 去你的服务端换链接，把结果回灌给 `link`，弹层即切到已创建态。`scopes` / `expiries` 可整组替换，默认给「任何人可查看 / 仅本组织 / 仅受邀成员」与「7 天 / 30 天 / 永久」。

## 语音：朗读与口述

两个 composable 提供能力，两个按钮组件提供入口。**都按 Web Speech API 的能力检测决定给不给入口**——Firefox 目前没有 `SpeechRecognition`，Safari 部分版本只认 `webkit` 前缀；不支持时 `supported` 为 `false`、按钮整个不渲染，调用 `speak` / `start` 也是安全空操作。

### 朗读（`useSpeech` · `EbChatSpeak`）

```js
const { supported, speaking, speak, stop } = useSpeech({ lang: 'zh-CN', rate: 1 })
speak('念这段')   // 空文本返回 false；再点会先 cancel 上一条
```

`EbChatSpeak` 是动作条上的那颗按钮（图标 `volume`，朗读中转 `pause` 并脉动）。**开箱用法**是开 `speech` 开关，它会自动出现在助手消息上、用户消息上没有：

```vue
<eb-chatbot v-model="messages" speech />
```

单实例单次朗读：同一条里再次点击会先停掉上一条，避免多条消息同时念。

### 口述（`useSpeechInput` · `EbChatVoiceInput`）

```js
const { supported, listening, error, start, stop, toggle } = useSpeechInput({
  lang: 'zh-CN',
  onInterim: (text) => { /* 临时结果：覆盖填充输入框 */ },
  onFinal: (text) => { /* 定稿片段：追加，光标不跳 */ },
  onError: (code) => { /* not-allowed / no-speech / network ... */ },
})
```

结果分两路是刻意的：临时结果随时在变，直接覆盖填充；定稿才追加，这样光标不会往回跳。一次定稿多句时逐段交出，不拼成一坨。

`EbChatVoiceInput` 是独立按钮，丢进输入区**已有的插槽**即可，不需要改 composer：

```vue
<eb-chatbot v-model="messages" v-model:input-value="draft">
  <template #sender-toolbar>
    <eb-chat-voice-input lang="zh-CN" @interim="draft = $event" @result="draft += $event" />
  </template>
</eb-chatbot>
```

`EbAiPromptBox` 侧同理走 `#toolbar-extra`。

## 生成中的输入：排队与转向

此前生成期间发出的消息会被**静默丢弃**（`sendMessage` 在 loading 时直接 return）。现在默认改为**排队**，并在每轮结束后自动带出下一条。

```js
const engine = useChatEngine({ transport })

engine.sendMessage('第一个问题')
engine.sendMessage('第二个问题')   // 返回 'queued'，不丢
engine.pending.value              // [{ id, content, attachments, context }]
engine.dequeue(id) / engine.clearQueue() / engine.flushQueue()
```

`flushQueue()` 生成中不动作、空闲时手动带出一条——自动带出已经接在每轮结束，这个入口留给宿主做「立即发送」。

## 转向（steer）

有的产品希望生成中发出的消息**注入到当前这一轮**（「顺便也看下另一个指标」），而不是等下一轮。这需要宿主能往进行中的请求里塞内容，所以交给宿主：

```js
const engine = useChatEngine({
  transport,
  steerable: true,
  onSteer: (text) => myStream.appendToCurrentRun(text),   // 由你决定怎么注入
})
engine.sendMessage('补充一句')     // 返回 'steered'
```

`steerable` 为真但没给 `onSteer` 时**退回排队**——宁可晚一轮，也不丢。

## 输入区的配合

`EbChatbot` 加 `queueable` 后，生成中 Enter 与发送钮都会照常发出（宿主交给引擎即自动入队）；不开则维持原样「生成中拦下」。与 `stoppable` 互不干扰：停止钮仍是停止钮，点了不会变成发送。

`EbChatQueue` 是待发送队列那条窄带，宿主放在输入区上方即可（`#sender-prepend` 或自己排布）：

队列不是只读列表：每条还能**立刻插队发送**（`send-now`）与**取回编辑**（`recall`）——Claude Code 的 `↑` 取回、Codex 的 `Esc` 立即发送是同一诉求，不然用户只能删了重打。

```vue
<eb-chat-queue
  v-if="engine.pending.value.length"
  :items="engine.pending.value"
  @remove="engine.dequeue"
  @clear="engine.clearQueue"
  @recall="onQueueRecall"
  @send-now="onQueueSendNow"
/>
```

「取回编辑」是两步：出队 + 回填草稿。**「立即发送」要宿主自己串**——队列语义是"排队等下一轮"，而"立即"意味着插到当前轮之前：

```js
function onQueueRecall(id) {
  const item = engine.pending.value.find((i) => i.id === id)
  if (!item) return
  engine.dequeue(id)
  consoleRef.value.setDraft(item.content)   // EbAiConsole 暴露的方法
  consoleRef.value.focus()
}

async function onQueueSendNow(id) {
  const item = engine.pending.value.find((i) => i.id === id)
  if (!item) return
  engine.dequeue(id)                        // 先出队：引擎在本轮结束时会 flush 队列
  const streaming = engine.messages.value.find((m) => m.status === 'streaming')
  if (streaming) {
    engine.cancelMessage(streaming.id)      // 1) 协作中断当前轮
    await transport.cancel?.()
    await new Promise((r) => setTimeout(r, 0))
  }
  consoleRef.value.send(item.content)       // 2) 再走与输入台同一条发送路径
}
```

## 斜杠命令与 @ 提及

`useTriggerMenu`（判定）+ `EbChatCommandMenu`（渲染）+ 输入区让出键盘，三件拼起来。键盘不放在弹层里是刻意的——**焦点始终在输入框**，弹层不该抢走输入。

`useTriggerMenu` 只做「从文本与光标位置算出该不该弹、弹什么、选中后文本变成什么」这件非平凡的事：

```js
const menu = useTriggerMenu({
  triggers: [
    { char: '/', items: COMMANDS },                                   // 命令
    { char: '@', items: PEOPLE, insert: (p) => `<${p.label}>` },       // 提及
  ],
})
menu.text.value = draft        // 与输入框同步
menu.caret.value = caret       // 光标位置（ChatSender 会抛 caret-change）
menu.visible.value             // 该不该弹
menu.items.value               // 过滤后的候选
menu.move(1) / menu.reset()    // 键盘移动
menu.pick(i)                   // → { text, caret }，交给输入框
```

**触发边界**（都有用例钉）：触发符必须在词首——路径里的 `/`、邮箱里的 `@` 都不会误触发；查询词里出现空白或换行即视为这个词已写完；光标退到触发符之前也不再弹。

输入区侧只需一条极窄的协作：`menuOpen` 为真时把 Enter / ↑ / ↓ / Esc 交出来（Enter 是选中而不是发送），Shift+Enter 与 IME 组字不受影响。

```vue
<eb-chatbot
  v-model="messages"
  v-model:input-value="draft"
  :menu-open="menu.visible.value"
  @caret-change="menu.caret.value = $event"
  @menu-key="onMenuKey"
>
  <template #sender-menu>
    <eb-chat-command-menu
      :items="menu.items.value"
      :highlight="menu.highlight.value"
      :visible="menu.visible.value"
      :title="menu.active.value?.char === '/' ? '命令' : '引用成员'"
      @hover="menu.highlight.value = $event"
      @select="applyMenuItem"
    />
  </template>
</eb-chatbot>
```

```js
function onMenuKey(key) {
  if (key === 'up') menu.move(-1)
  else if (key === 'down') menu.move(1)
  else if (key === 'escape') menu.visible.value = false   // 由你的状态收口
  else if (key === 'enter') applyMenuItem(menu.highlight.value)
}
function applyMenuItem(index) {
  const out = menu.pick(index)
  if (!out) return
  draft.value = out.text
  nextTick(() => senderRef.value?.setCaret(out.caret))   // 光标放回插入点之后
}
```

`#sender-menu` 渲染在输入区**上方**（`sender-prepend` 是左右并排的，放不了这个）。

## 上下文占用

### EbChatContextMeter

输入台工具栏右侧的占用环（`EbAiConsole` 经 `#toolbar-meta` 传进输入台，不再占输入区一行）：`{ used, capacity, breakdown? }`，环 + 百分比，点开给「已用 / 窗口」与三段构成（系统提示词 / 工具定义 / 对话消息）。**两侧缺一就不显示**——拿不到窗口容量时画个环只会误导；百分比封顶 100%，到 **65% 转警告色、85% 转危险色**（竞品文档里最常见的口径），hover 直接给一行摘要「~已用 / 窗口 · 系统 · 工具 · 消息」，点击才开面板，动画在 `prefers-reduced-motion` 下关掉。`capacity` 与三段都是宿主的业务口径（模型窗口、提示词预算），组件只做占比与呈现。

| Props | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `used` | number | `0` | 已用 token（压力值 / 预估值） |
| `capacity` | number | `0` | 上下文窗口容量；为 0 或 `used` 为 0 时不渲染 |
| `breakdown` | object | `null` | `{ system?, tools?, messages? }`，三者都是 token 数；缺的段不画 |

`EbChatbot` / `EbAiConsole` 的 `context` prop 直接 `v-bind` 这三个字段；`useChatSession` 用 `context` + `setContext({ used, capacity, breakdown })` 维护，也可由 `context/usage` 事件驱动（瞬时或持久形态都认，且不污染会话日志）。

## token 与成本计量

`EbChatUsage` 只展示宿主给的用量——**成本要价目表，那是宿主的业务数据**，组件与引擎都不猜。
消息里的位置：`EbChatMessage` 把 `message.usage`（与 `duration`、时间）渲染在**本条消息的底部行**——
**与复制 / 重试等动作条同一行**，不在头部行（头部只留发送者名字）。
显隐口径：底部读数（时间 / 耗时 / tokens 用量）与动作条**整组随悬浮出现**（触屏常显），
靠 opacity + `pointer-events` 门控——隐藏时仍占位，悬浮出现不引起任何位移，
也不会出现"看不见却能点"。

```js
engine.setUsage(msg.id, {
  promptTokens: 120,
  completionTokens: 380,
  totalTokens: 500,
  cost: 0.0032,        // 可选；不给就不显示成本位
  currency: '$',       // 可选；混币种取第一个出现的，不做汇率换算
  segments: [          // 可选；宿主自定义的分段记账（如按阶段），披露行跟在标准分项后面
    { label: '检索阶段', tokens: 320 },
    { label: '生成阶段', tokens: 180 },
  ],
})
```

消息带 `usage` 时元信息行自动出现一颗用量徽标（`1.2k tokens · $0.0032`）。**点开给分项明细**（输入 / 输出 / 缓存读 / 缓存写 / 推理 / 自定义分段 / 首字 / 速度 / 成本）——为 0 的分项不渲染，宿主没给的成本也不出现；没给任何用量就完全不渲染，只有总量时分项为空、徽标不可点（不做假按钮）。多条汇总时 `segments` 按 `label` 合并累加。

单条用 `usage`，按会话汇总用 `items`（多条累加，token 与成本都汇总）：

```vue
<eb-chat-usage :items="messages.map((m) => m.usage).filter(Boolean)" size="default" :bare="false" />
```

千与百万以上折算成 `12.4k` / `2.3M`，成本小额保留四位（`$0.0032`）常规两位（`¥1.24`）。

| Props | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `usage` | object | `null` | 单条用量：`{ promptTokens?, completionTokens?, totalTokens?, cacheReadTokens?, cacheWriteTokens?, reasoningTokens?, ttftMs?, tokensPerSecond?, cost?, currency?, segments? }`，`segments` 为 `[{ label, tokens }]`（token 记账的分段细分，不计入总量） |
| `items` | array | `null` | 多条用量（按会话汇总，token 与成本累加） |
| `size` | string | `'compact'` | `compact` 用于消息元信息行，`default` 用于独立展示 |
| `bare` | boolean | `true` | 去掉底色，直接融进上下文 |
| `disclosure` | boolean | `true` | 关掉阶梯披露，只留一行摘要 |

## 观测性深链

消息带 `traceId` 时，动作条上出现一颗跳转外链——契约是 **URL 模板**而不是 URL 生成器：各家追踪平台路径差异极大（LangSmith / Langfuse / Phoenix / 自建 Jaeger），模板是最小可复用面。

```js
engine.setTrace(msg.id, { traceId: 'run_abc123' })   // 或直接给 traceUrl
```

```vue
<eb-chatbot v-model="messages" trace-url="https://smith.example.com/o/acme/runs/{traceId}" />
```

* `message.traceUrl` 直接给全量地址时优先于模板（你自己拼好了就别再套模板）
* `{traceId}` 会被 `encodeURIComponent` 编码，`a/b?c=1` 这类 id 不会破 URL
* 解析不出地址（无 id、无模板、模板里没占位符）就不渲染，**不留死链**
* 外链是 `<a target="_blank" rel="noopener noreferrer">` 而不是按钮——导航语义本来就该是锚点

要完全自定义这块，用已有的 `#message` scoped 插槽整条接管即可，没有另开 `#trace` 插槽——那与 `#message` 能力重叠。

## 测试结果与 stack trace

`EbChatTestResults` · `EbChatStackTrace`。两张都是纯数据渲染，无额外依赖。

### EbChatStackTrace

吃 stack 字符串或已解析的 frames，认三种常见形态：

| 形态 | 例 |
| --- | --- |
| Chrome / V8 | `at Object.fn (http://x/y.js:1:2)`、`at http://x/y.js:1:2` |
| Node | `at fn (/path/file.js:1:2)` |
| Firefox | `fn@http://x/y.js:1:2` |

按 `node_modules` / `/vendor/` / `<anonymous>` / `node:internal` 把依赖帧**折叠成一行**——它们对定位业务问题没用。每帧点击抛 `frame-click(frame)` 让宿主跳源码。

```vue
<eb-chat-stack-trace :stack="err.stack" :max-app-frames="5" @frame-click="openInEditor" />
```

**不做 source map 映射**：那要拉 map 文件，宿主映射好再传 frames 进来（`stack` 也接受 frames 数组）。

### EbChatTestResults

```js
message.testResults = {
  summary: { passed: 12, failed: 2, skipped: 1, duration: 3400 },
  cases: [{ name: 'parseDiff 单文件', suite: 'diff', status: 'failed', duration: 12, message: '期望 2 实际 1', stack: '…' }],
}
```

汇总条给通过 / 失败 / 跳过与总耗时；**失败项默认展开**（报错信息是这条结果的全部意义），并可一键「只看失败」。`status` 的归一规则：`passed / pass / ok` → 通过；`skipped / skip / todo` → 跳过；**没给状态** → 按跳过（不猜，既不宣称通过也不宣称失败）；**报了但认不出** → 按失败（漏报失败比虚报通过安全）。

`summary` 优先用宿主给的——跳过数这类信息组件自己数不出来。cases 里的 `stack` **组合** `EbChatStackTrace` 渲染，不内联解析：只看测试结果的宿主不该背上 stack 解析。

## 长会话虚拟滚动

`EbChatList`（以及 `EbChatbot`）加 `virtual` 后，超过 `virtualThreshold`（默认 60）条即启用虚拟滚动——只有可视窗口内的消息进 DOM。历史几千轮的会话不会因为"全渲染"而卡住。

```vue
<eb-chatbot v-model="messages" virtual :virtual-threshold="60" :estimated-item-size="120" />
```

* **只虚拟化，不拆容器**：整段对话仍是**一个滚动条**。方案里曾设计"末端活跃窗口不虚拟化"，实现时去掉了——那要求两个滚动容器，会破坏"一个滚动条看完整对话"的基本体验；而逐帧重测的代价本来就被"只渲染可视窗口"限住了（每帧测十几到二十个节点）
* `role="log"` / `aria-live` / 回到底部钮 / `#message` 与 `#message-content` 插槽在虚拟模式下**全部保留**，语义落在虚拟列表的滚动容器上
* `estimatedItemSize` 是估算行高：滚动中会按实测收敛，给个接近的值能少跳几次
* 条数不到阈值时不启用（短会话白搭一层没有收益）

## 沙箱运行与网页预览

这两张是本仓唯一会承载外部内容的组件。`EbChatSandbox` 跑一段宿主给的 HTML/JS，`EbChatWebPreview` 嵌一个网址。

### EbChatSandbox

组件**不执行代码**，只把一个隔离 iframe 管起来：宿主给 HTML → 组件拼 bootstrap → `srcdoc` + sandbox 属性 → iframe；console 与错误经 `postMessage` 上来，**组件只转发不解析**。

**sandbox 旗标策略**（全部安全性的落点）：

| 旗标 | 默认 | 说明 |
| --- | --- | --- |
| `allow-scripts` | 开 | 不开跑不起来 |
| `allow-same-origin` | **永久禁** | 与 `allow-scripts` 同开等于没有沙箱：iframe 能拿同源存储并操作父文档。宿主传了也会被剔除并告警 |
| `allow-modals` / `allow-forms` / `allow-popups` / `allow-downloads` / `allow-pointer-lock` 等 | 禁 | 经 `extra-sandbox` 按需开，白名单外的一律忽略 |
| `allow-top-navigation` | 禁 | 不在白名单内 |

不给 `allow-same-origin` ⇒ 不透明源 ⇒ **代价是沙箱内 `localStorage` 不可用**。

```vue
<eb-chat-sandbox :html="generatedHtml" title="用户脚本" auto-height boot-console @error="onSandboxError" />
```

`auto-height` 由内容上报高度自动撑开（内容抖会带着卡片跳，默认关）；`reload` 会重建 iframe，避免复用旧 frame 留着上一段的状态。**`html` 优先于 `src`**——`srcdoc` 在规范上就压过 `src`，所以为空的 `srcdoc` 不会再被绑上去盖掉外部沙箱地址。

### EbChatWebPreview

iframe + 截图双模式。**有一个绕不过去的硬限制**：目标站返回 `X-Frame-Options: DENY` 或 `frame-ancestors` 时浏览器显示空白错误页，而跨域下脚本**无法可靠区分**「禁嵌的空白页」与「真的空白内容」——`load` 两种情况都触发。

所以判断只能由宿主在服务端 `HEAD` 拿到后传进来：`embeddable` 为 `true` 直接用 iframe、`false` 且有截图就直接用截图、不传则先试 iframe。两种素材都在时给手动切换——白框时用户能自救。

**「在新窗口打开」不是可选项，是必须项**：iframe 直嵌在目标站禁嵌时必然是白框，逃生口是唯一的确定性出路。

| Props | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `src` | string | `''` | 预览地址（iframe 用） |
| `screenshot` | string | `''` | 静态截图地址；目标站禁嵌时回退到它 |
| `embeddable` | boolean | null | `null` | 宿主在服务端 HEAD 判定：`true` 直用 iframe、`false` 有截图就用截图、不传先试 iframe |
| `title` | string | `''` | 卡片标题（通常写域名） |
| `screenshotAlt` | string | `''` | 截图的替代文本；不传时退回 `title` |
| `height` | string | number | `320` | 预览区高度 |
| `extraSandbox` | array | `[]` | 追加的 sandbox 白名单项（白名单外的忽略） |
| `allow` | string | `''` | iframe `allow` 特性串（如 `clipboard-write`） |

## 边框与强调的档位（对话家族）

对话家族的容器**默认没有真边框**，一律用 `box-shadow: 0 0 0 1px <色> inset` 画**发丝环**（不占布局、hover 只动环、动效只动 `box-shadow`）。按容器性质分三档，与底座 `EbInput` 同一套语言：

| 档 | resting | hover | focus / 激活 |
| --- | --- | --- | --- |
| 输入类（`EbAiPromptBox` / `EbChatSender`） | `--eb-border-color-lighter` | `--eb-border-color-hover` | `--eb-input-focus-border-color`（**1px，不外发光**），`transition: box-shadow var(--eb-transition-duration-fast)` |
| 卡片类（`EbChatToolCall` / `EbChatChanges` / `EbChatContextMeter`） | `--eb-border-color-extra-light` | `--eb-border-color-lighter` | 展开/激活时才用 primary |
| 状态类（`EbChatApproval` / `EbChatQuestion` / 拖拽投放态） | **保留真边框 + 状态色**（虚线投放态需要真边框） | — | — |

注意：输入类的 hover 选择器要**排除 `.is-focus`**——鼠标点进输入台时 hover 与 focus 同时命中，不排除的话 hover 的灰色会盖掉聚焦环（实测踩过）。

强调不靠边框：标题走**字重（`--eb-font-weight-medium`）+ 主色**，数值走 `tabular-nums` + 语义色（`--eb-color-success` / `--eb-color-danger`），图标只补信息位不加装饰。

## 状态条（输入台上方）

### EbChatStatusBar

「AI 现在在做什么」此前只能靠 `aria-busy` 猜。状态条把它变成**一句话 + 一个可点的动作**，常驻在输入台上方。

| Props | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `status` | object | `null` | `{ phase, label?, tool?, elapsed?, hint?, queue?, error? }`；`phase` 为 `idle` 或缺省时**不渲染** |
| `stoppable` | boolean | `false` | 当前阶段能否中断，决定是否给「停止」钮 |

| Events | 载荷 |
| --- | --- |
| `stop` | `()`（与输入台的 `stop` 同源，宿主接一处即可） |
| `view-queue` | `()` |

**阶段与呈现**（状态不只靠颜色区分，形状与运动也不同）：

| phase | 文案 | 圆点 | elapsed |
| --- | --- | --- | --- |
| `thinking` | 思考中 | 呼吸点 | ✅ |
| `running` | 正在执行 `<tool>`（无 tool 时「执行中」） | 转环 | ✅ |
| `approval` | 待你确认 | 静态环（警告色） | — |
| `question` | 等你回答 | 静态环（主色） | — |
| `queued` | 已排队 N 条 | 实心点 + 右侧计数钮 | — |
| `retrying` | 重试中 | 虚线转环 | ✅ |
| `compacting` | 压缩上下文中 | 呼吸点 | ✅ |
| `error` | 出错了（或宿主给的 `error` 原文） | 危险色实心点 | — |

**无障碍口径**：`role="status"` 的 live region **只包状态句**——`elapsed` 每秒都在跳，整块播报会变成噪音，所以它单独放在 `aria-hidden` 的兄弟节点里；`hint` 作为弱化补充同样不进 live region；`prefers-reduced-motion` 下所有动效关闭。宿主侧接线：`EbChatbot` / `EbAiConsole` 的 `status` + `statusStoppable`，`stop` 与输入台共用，队列钮抛 `status-queue`。

## 输入类

### EbChatSender

输入区：自增高、Enter 发送、IME 组字安全、附件（点选 / 拖拽 / 粘贴）、字数限制、发送↔停止变形。`EbChatbot` 内部用它。

| Props | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `modelValue` | string | `''` | v-model |
| `placeholder` / `disabled` / `loading` | — | — / false / false | — |
| `allowAttachments` / `maxAttachments` | boolean / number | `true` / `5` | — |
| `maxLength` / `showWordCount` | number / boolean | `2000` / `false` | 真正约束 textarea，传 `0` 不限 |
| `minRows` / `maxRows` | number | `1` / `6` | 自增高范围 |
| `sendOnEnter` | boolean | `true` | 关掉后 Enter 只换行 |
| `stoppable` | boolean | `false` | loading 时发送钮变停止钮；此时**连按两次 Esc**（500ms 内、不带修饰键）也抛 `stop` |
| `accept` / `maxFileSize` | string / number | — / `0` | 附件类型与体积；拖拽粘贴同样校验 |
| `allowDrop` | boolean | `true` | 允许拖拽与粘贴投递 |

| Events | 载荷 |
| --- | --- |
| `send` | `(text, attachments)` |
| `stop` | — |
| `attachment-add` | `(file, item)`，`item` 是列表内的响应式对象，回写 `status` / `progress` 即驱动状态显示 |
| `attachment-reject` | `(file, reason)`，`reason` 取 `type / size / limit / empty` |

| Slots | 说明 |
| --- | --- |
| `toolbar` | 工具栏（附件按钮右侧） |

### EbChatMessageEdit

用户消息的原地编辑器。Esc 取消、Enter 保存、IME 安全、maxlength 生效。`{ modelValue, placeholder, maxLength, sendOnEnter }`，抛 `save(text)` / `cancel`。

### EbChatSuggestion

追问 chips。`items` 接受 string 或 `{ text, prompt? }`；`layout` 取 `row`（消息尾部）或 `column`（欢迎区）；`icon` 传图标名则每枚前置。抛 `pick({ text, prompt })`。

### EbChatFeedback

点赞点踩。点赞立即提交；点踩展开结构化原因面板与备注。取消评价也会交出 `{ value: null }`——否则宿主存下来的评价清不掉。

| Props | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `value` | `'up' \| 'down' \| null` | `null` | v-model:value |
| `reasons` | array | 内置六项 | 点踩原因词汇表 |
| `showNote` | boolean | `true` | 是否显示备注框 |
| `disabled` | boolean | `false` | — |

| Events | 载荷 |
| --- | --- |
| `submit` | `({ value, reasons, note })`，取消时 `value` 为 `null` |

### EbChatActionbar

消息动作条：复制（内置，含已复制回执）、重新生成（仅 assistant）、编辑（仅 user）、自定义动作。默认悬停显示，`:focus-within` 与触屏常显。自定义动作**给了 `icon` 就是图标按钮，没给则回落成文字按钮**；每颗按钮悬浮都出**库内 Tooltip**（Teleport 到 body，消息列表滚动时不会被裁切），自定义动作可给 `desc` 作为更长的说明（`{ key, label, icon?, desc? }`，不给就用 `label`），`aria-label` 同步取它。「查看调用链」需要**模板含 `{traceId}` 且消息给了 `traceId`**（或消息直接给全量 `message.traceUrl`），解析不出就不渲染——免得给一个点了没用的按钮。

`{ message, actions, showCopy, showRegenerate, showEdit }`，抛 `copy` / `regenerate` / `edit` / `action`。

### EbChatAttachments

附件卡片列表：图片预览或**按后缀匹配的专用图标**（pdf / word / excel / ppt / zip / image / music / video / code / text，认不出退 MIME 大类、再退通用文档图标）、文件名、体积、移除钮；**图标默认按类型染色**（pdf 红 / word 蓝 / excel 绿 / ppt 橙 / zip 灰蓝…，全部映射到 `--eb-*` 语义令牌，随主题与暗色自动走），传 `colored="false"` 可关掉染色、统一跟随正文色；宿主回写 `status` 时显示进度条（`uploading` + `progress`）、失败原因（`error`）或已上传（`done`）。`{ attachments, removable }`，抛 `remove(file)`。

不想要彩色图标时关掉即可（同一份数据，图标统一跟随正文色）：

## 文案与多语言

子组件的文案同样来自本包语言包（`src/locale/{zh-CN,en}.js`，`en` 全量；其余语言或缺失的键回退 `zh-CN`），经 `useChatLabels()` 取用，因此单独挂载子组件时也认 `EbConfigProvider` 的 `locale`——只要它在 Provider 子树里（语言名由底座给，译文由本包查）。宿主可传入的文案（如 `EbChatSender` 的 `placeholder`、`EbChatMessage` 的 `user-name`）优先于译文。
