---
url: /chat/chatbot.md
---
# Chatbot 对话窗口

开箱可用的 AI 对话窗口：消息流（Markdown 渲染、代码块复制、思考过程、附件、流式光标）、输入区（Enter 发送 / Shift+Enter 换行、输入法组字安全、字数与附件上限、停止生成）、动作条（复制 / 重新生成，键盘与触屏可达）。接口层完全由你承接——`send` 事件拿到输入，回写 `modelValue` 即完成闭环。

::: tip 这个家族住在独立包里
本页组件属于 `@wil-works/evoke-chat`（2026-09 从 `@wil-works/evoke-business-ui` 拆出，`marked` / `highlight.js` 只在用得上时才进依赖树）。装两个包并各自注册一次即可：

```js
import EvokeBusinessUI from '@wil-works/evoke-business-ui'   // 底座：基础组件 + 设计令牌
import '@wil-works/evoke-business-ui/styles'
import EvokeChat from '@wil-works/evoke-chat'
import '@wil-works/evoke-chat/styles'

app.use(EvokeBusinessUI)
app.use(EvokeChat)
```

:::

## 基础对话

发送后回写消息数组，异步回复用 `loading` 显示打字态：

## 流式输出与思考过程

> 示例统一用 460~520px 的窗口高度：输入区固定约占 96px，正文区吃掉其余空间——高度给太小会只露出半条消息。

配合导出的 `useChatEngine` 组装流式会话：`appendContent` 逐段回写正文（状态自动进入 streaming，正文末尾出现流式光标，重解析按帧合并），`appendThinkContent` 写入思考内容（消息上方出现可折叠的思考块，流式期间自动展开、结束后可收起），`completeMessage` 收尾并自动记录回答用时。`regenerate` 事件里删掉旧回复后按上文重新流式输出即可：

不想等流式也能看清思考块的三种形态——**思考中**（自动展开、内容实时追加）、**已深度思考**（结束后收起、可点开）、**带用时**（结束时间点折成 `thinkDuration`，标题右侧给「（用时 X）」）：

消息项上的思考字段：`thinking: true` 表示正在思考（流式期间强制展开），`thinkContent` 是思考正文，`thinkDuration`（毫秒）是思考耗时——由引擎的 `appendThinkContent` / `stopThinking` / `completeMessage` / `cancelMessage` 自动结算，宿主手写静态数据时也可直接给；`thinkInterrupted: true` 表示思考阶段就被停止，思考块标题据此改说「思考已中断」。

## 停止生成与中断态

`stoppable` 让发送钮在 `loading` 期间变成停止钮，点击抛 `stop`（AbortController 由你自持）。中断时用 `cancelMessage` 把该条消息置为 `cancelled` 而不是 `error`：已流出的正文原地保留，下面挂一行「已停止生成」灰标，而不是整段被红色错误块替换；若停的时候还在思考，思考块标题改说「思考已中断」并补上思考用时，不会谎报「已深度思考」。生成中输入框保持可打字，便于准备下一句；Enter 既不并发投递也不会误触中断。

## 头像、名称与纯文本

`user-name` / `assistant-name` 决定双方昵称与头像首字，`avatar-user` / `avatar-assistant` 传图片地址可替换为图片头像；`render-mode` 切换为 `text` 后消息不再解析 Markdown，适合展示日志、代码原文等纯文本。

`show-avatar` / `show-name` 把头像与昵称整块隐去（适合头像已由外层提供、或消息密集的窄栏），传 `{ user: false }` / `{ assistant: false }` 可分侧关闭；**时间戳默认就在消息下方**，与动作条同排、悬浮消息（或键盘聚焦）时一起出现，`show-time` 可整块关掉：

也想分侧控制时传对象——只隐去自己那侧（对方仍带头像昵称）：

## 自定义动作与事件埋点

`actions` 在助手消息的动作条上追加自定义动作（`{ key, label, icon? }`，icon 使用组件库图标名）；`copy` / `regenerate` / `action` 事件把消息对象透传给页面，在此上报埋点或执行业务逻辑。动作条悬停消息时显示：

## 追问建议、评价与编辑重发

`editable` 给你的消息加「编辑」，点开后原地变输入框，保存抛 `edit`（原文与新文两参）；`feedback` 在助手回答下加点赞点踩，点踩会展开结构化原因与备注面板，提交抛 `feedback`（消息 + `{ value, reasons, note }`）；回答尾部的追问 chips 来自消息的 `suggestions` 字段，点击抛 `suggestion-click`。三者都由 `modelValue` 承载状态，宿主负责回写。**版面约定：追问 chips、评价、动作条落在消息下方同一行**（chips 吃掉剩余宽度、可换行；评价与动作条靠右，动作条仍随悬浮出现）；点踩展开原因面板时，评价块整行铺开——面板太窄读不了：

## 来源引用与行内上标

消息带 `citations` 数组即渲染来源卡列表（序号 / favicon / 标题 / 域名 / 摘要，可折叠；条目可带 `status` 说明定位状态——如「未定位」——与域名同行展示，不必把它拼进标题）。正文里的行内引用用 **`source:` 协议**书写——`[1](source:c1)` 渲染成可点击上标，点击会展开来源列表并高亮对应卡片，同时抛 `citation-click`：

链接文字是纯数字就直接用作序号；不是数字则按本次渲染递增分配。刻意不做裸 `[1]` 自动识别——那会和有序列表、脚注、代码里的方括号打架；宿主若拿到的是 Perplexity 风格的裸数字，在自己的 transport 里改写成 `[n](source:id)` 即可。

## 工具调用卡

消息带 `toolCalls` 数组即在正文之前渲染执行轨迹（先执行再作答）：每步一行，带状态标记、状态文案与耗时，有参数或结果时可展开。多个步骤自动归到组标题下——跑着说「正在执行 N 个步骤」（带流光），跑完结算成「执行了 N 个步骤（用时 X）」。失败态给重试钮并在折叠行露出错误首行，点击抛 `tool-retry`（工具调用 + 消息两参）。

配合 `useChatEngine` 的状态机驱动：`startToolCall(msgId, { name, args })` 新建并转执行中（传已存在的 `id` 则复用），`addSubToolCall(msgId, parentCallId, { name, args })` 给某个调用挂子调用（返回子调用 id，与 `startToolCall` 一致；父不存在或超过 16 层返回 null），这三个写操作都按 id **递归**作用到子调用上；`appendToolCallResult(msgId, callId, chunk)` 逐片回写工具输出（状态自动转执行中并打上 `streaming`，卡片露出光标、自动展开并贴底），`completeToolCall(msgId, callId, result)` 收尾并自动记耗时（**省略 `result` 时保留已流出的输出**），`failToolCall(msgId, callId, error)` 转失败。整轮被 `cancelMessage` 中断时，还在跑/等待的调用会落 `cancelled`（「已停止」），且不再接收迟到的增量或收尾。参数与结果默认走带环检测的 JSON `<pre>`，宿主可用 `#args` / `#result` 插槽换成 `EbJsonViewer` 等。

## 系统提示消息

`role` 为 `system` 或 `notice` 的消息按系统提示呈现：居中一行、弱化底色，不带头像 / 昵称 / 动作条，也不参与复制、评价与编辑。适合「以上为历史对话」「已切换到某模型」这类分隔性说明。正文仍走 Markdown，`render-mode="text"` 时原样显示。

`message` 插槽接管整条消息的渲染，作用域参数给 `message` / `index` / `isLast` / `itemProps`（`itemProps` 就是 `ChatList` 本来要传给内部 `ChatMessage` 的全量 props）。只想让某几类消息长得不一样、其余照旧，用 `itemProps` 显式回落即可，不必自己重接头像与动作条：

只换正文、保留消息外壳（头像 / 思考块 / 工具卡 / 来源卡 / 动作条）用 `message-content`，作用域参数给 `message` / `content` / `renderMode` / `streaming`。两者同时给以 `message` 为准。`EbChatMessage` 单独使用时对应插槽名为 `content`。

一点要注意：插槽**产出为空时 Vue 会回落默认渲染**（空模板、仅注释、`v-if` 为假都算空）。所以不能靠「把不想显示的行留空」来隐藏消息——那样得到的是默认渲染，不是空白。

## 附件与输入控制

`max-length` 限制输入长度，`show-word-count` 显示字数；`max-attachments` 限制附件数量。附件投递支持点 `+` 选文件、**拖拽进输入区**、**直接粘贴剪贴板里的图片**三种方式（`allow-drop` 可关）。

类型与体积由 `accept` / `max-file-size` 约束——浏览器对 `input[accept]` 只是建议，拖拽和粘贴路径组件会自己按同一套规则校验；不合格的文件经 `attachment-reject(file, reason)` 交回，`reason` 取 `type` / `size` / `limit` / `empty`，提示文案由你决定。

上传状态由你回写附件对象驱动（组件不自己发请求）：`status: 'uploading'` + `progress` 显示进度条，`'error'` + `error` 显示失败原因，`'done'` 显示已上传；不写 `status` 的历史附件不出现任何状态位。

## 自定义区域与实例方法

`header` 定制面板标题栏，`empty` 定制空会话占位，`tip` 替换底部提示，`sender-toolbar` 在输入框工具栏追加按钮（示例配合 `v-model:input-value` 受控输入）；实例方法 `focus()` / `scrollToBottom(smooth)` / `reset()` 支持外部操控，注意 `reset()` 不会回写 `v-model`，受控使用时需同步清空绑定数组：

## 正文渲染配置

对话正文的 Markdown 管线从包入口导出四个函数，宿主可在挂载前统一调配置：

```js
import {
  configureChatMarkdown,
  getChatMarkdownConfig,
  renderChatMarkdown,
  registerHighlightLanguage,
} from '@wil-works/evoke-chat'

// 自定义协议链接的主题色（默认 entity→primary、doc→info、action→danger …）
configureChatMarkdown({ protocolThemes: { ticket: 'warning' } })

// 放行宿主自己的协议；data: 默认不在链接白名单内（聊天内容不可信）
configureChatMarkdown({ standardProtocols: { add: ['https'] } })

// 默认高亮只带 highlight.js/lib/common 的 36 种语言，冷门语言自行注册
import cobol from 'highlight.js/lib/languages/cobol'
registerHighlightLanguage('cobol', cobol)
```

数学公式与图表同理——**渲染器由你注入**，包内不引这两个依赖：

```js
import katex from 'katex'
import 'katex/dist/katex.min.css'   // 样式与字体要自己引

configureChatMarkdown({
  // (tex, displayMode) => html；返回的内容按原样插入，转义由你的渲染器负责
  math: (tex, display) => katex.renderToString(tex, { displayMode: display, throwOnError: false }),
})

// mermaid 是异步的，塞不进同步渲染管线：围栏保留为代码块，旁边多一个
// 「渲染图表」按钮，点击后调这个函数并把代码块换成返回的 SVG
configureChatMarkdown({
  mermaid: async (source) => {
    const { svg } = await mermaid.render(`m${Date.now()}`, source)
    return svg
  },
})
```

不注入就不出现：`$x$` 与 `$$…$$` 原样保留，mermaid 围栏就是普通代码块。数学的行内识别带两条保守规则——`$` 内侧不留空白，且纯数字内容（`$100 与 $200` 这类金额写法）直接放行不当公式。宿主渲染器抛错时退回转义原文，不影响整篇。

安全边界（都是刻意选择，不是漏做）：正文里的 raw HTML 一律转义为纯文本展示，`<script>` / `<iframe>` / 注释都进不来；链接协议走白名单，非白名单协议降级成可配置的引用芯片；**图片 `src` 只放行 `http` / `https` / `data`**，其余协议退回可读纯文本，并统一补 `loading="lazy"` 与 `referrerpolicy="no-referrer"`。

**脚注**：marked v18 不带脚注扩展，管线里自己接了一个。`结论[^1]` 配 `[^1]: 出处说明` 渲染成可点击上标 + 文末尾注列表——编号按定义出现顺序（与 GFM 一致），未被引用的定义不进尾注，没有对应定义的引用退回原样文本，代码块里的 `[^1]:` 不会被当成定义。上标与 `source:` 引用共用 `.eb-chat-citation` 类名，宿主可统一着色。

Mermaid 的图是点击后直接改 `v-html` 出来的 DOM——流式期间重渲染会覆盖它，所以按钮只在已经渲染完成的块上有意义。

## 接真实后端：会话日志层

默认接法（`onSend` 里自己回写引擎）在**断线、重连、补历史**时会丢状态——没有东西记得"读到哪了"。`useChatSession` 补的就是这一层：宿主只提供一个窄适配器，游标、缺口、幂等发送由它管。

```js
import { useChatSession } from '@wil-works/evoke-chat'

const session = useChatSession({
  sessionId: 's-1',
  transport: {
    open: ({ cursor, onEvent }) => subscribeMyStream({ from: cursor + 1 }, onEvent), // 返回 unsubscribe
    page: ({ from, to }) => fetchEvents({ from, to }),                              // 补页：返回持久事件数组
    send: ({ requestId, content, mode }) => rpc('session/prompt', { requestId, content, mode }),
    cancel: ({ sessionId }) => rpc('session/cancel', { sessionId }),                 // 协作式中止
  },
})

session.open({ cursor, records })            // 打开/重开：装快照并订阅
session.submit('帮我看下这个报错')            // 幂等：requestId 关联乐观气泡，失败可 retrySend 同 id 重发
session.stop()                               // 停止（本层不持 AbortController）
session.messages                             // 折叠后的消息数组，直接喂 <eb-chatbot v-model>
```

**`open()` 与生命周期的三条口径**：

* `submit` 前忘了 `open()` 也能跑——首次发送会自动补开并 `console.warn` 一声（不补的话走事件出口的流式事件会全部静默丢失，页面停在“生成中”）。但快照装载、断线重连的语义都在 `open()`，正式接入请显式调用。
* **切会话 = 再调一次 `open()`**（换快照）：旧订阅的迟到事件（在途轮的增量、收尾）由代际守卫就地丢弃，不会在新会话里复活幽灵消息；乐观气泡、审批、提问等瞬态同时清空，新视图从干净状态开始。
* 整个会话不再用了调 `session.dispose()`：断开订阅、丢弃一切后续事件并通知 `transport.close`；之后再 `open()` 可复活。组件卸载（scope 销毁）会自动 dispose。

事件契约（宿主把自家 wire 数据映射成这几类，其余类型按 `ignorable` 处理）：

| 事件 | 数据 | 折叠成 |
| --- | --- | --- |
| `user/message` | `{ requestId?, message: { content, attachments? } }` | 用户消息（同 `requestId` 的乐观气泡自动摘除） |
| `assistant/delta`（瞬时） | `{ messageId, text?, think? }` | `appendContent` / `appendThinkContent` |
| `assistant/progress`（瞬时） | `{ messageId, label, detail?, elapsedMs?, percent? }` | `setProgress`：消息上一行阶段读数（阶段名 + 细节/耗时）；收尾态自动清空 |
| `assistant/progress-log`（瞬时） | `{ messageId, label, detail?, elapsedMs?, percent? }` | `appendProgress`：逐条追加成流水线轨迹（`progressLog`），当前行联动；≥2 条时历史收进「前 N 个阶段」折叠 |
| `assistant/message` | `{ messageId, message: { content, thinkContent?, usage?, model?, provider? }, interrupted? }` | 落定（`model` / `provider` 透传成底部模型归属读数）；`interrupted` 走中断态 |
| `tool/call` | `{ messageId, callId, name, args? }` | 工具卡转执行中 |
| `tool/args`（瞬时） | `{ messageId, callId, chunk }` | `appendToolCallArgs`：入参 JSON 分片逐片拼接，收尾自动 parse 回对象 |
| `tool/result` | `{ messageId, callId, result?, resultType?, error?, duration? }` | 完成 / 失败；`resultType: 'image' / 'file'` 换非文本默认渲染；`error.code === 'interrupted'` 落「已停止」 |
| `turn/end` | `{ messageId, reason: { kind } }` | `completed` 收尾、`max-tokens` 截断保留、`aborted`/`blocked` 中断、`error` 红块 |

三条不变量（违反即上报 `onViolation`，绝不静默硬接）：

* **连续性**：`seq` 必须等于上一条 +1；跳号就挂起后到事件并请宿主补页（`transport.page`），补平才继续——事件顺序永不倒置。
* **游标只被持久事件推进**：瞬时通知要显式标 `transient: true`，不碰游标、不进窗口；没有 `seq` 又没这个标记的按坏信封上报。
* **恢复不倒退**：`open()` 之后服务端若从更早的 `seq` 重放，判 `stale-replay` 违规——静默接受会让窗口出现重影。

不认识的类型：`ignorable: true` 可安全跳过；否则标记 `state.degraded` 并回调 `onDegraded`，由宿主决定是否重拉整窗（不认识的事件可能携带视图状态，跳过会画错）。

> 纯逻辑内核 `createSessionLog()` 与折叠函数 `applySessionEvent(engine, event)` 都单独导出：不用 Vue、不接网络，可以只取日志层，或只在测试里复用折叠规则。

## 给 AI 读的契约

文档站除了 prose 版的 `llms.txt` / `llms-full.txt`，还随站发布两份**结构化契约**：

| 文件 | 内容 |
| --- | --- |
| [/chat/ai-contract](/chat/ai-contract) | 硬规则 → 任务配方（含代码与坑）→ 逐组件的 props / 事件 / 插槽 / 实例方法表 |
| [/ai/evoke-chat.components.json](https://evoke-business-ui.wil-works.com/ai/evoke-chat.components.json) | 同一份契约的机器可读版（组件 + 具名导出 + 配方） |

两份产物由 `packages/evoke-chat/scripts/gen-ai-docs.mjs` 从源码抽取，`--check` 已进构建链：源码改了没重新生成，构建即失败（与文档 API 门同一思路）。

## 接入 OpenAI / Anthropic

两家模型服务各一个适配器（`openai` / `anthropic`），**纯映射、零 SDK 依赖**：请求体、SSE 解析、wire → 本库事件三件事都在里面，接上 `useChatSession` 就能跑。

```js
import { createChatTransport, useChatSession } from '@wil-works/evoke-chat'

const transport = createChatTransport({
  provider: 'anthropic',        // 'openai' | 'anthropic'，也可直接给自定义 adapter 对象（见下）
  apiKey: '...',                // 浏览器直连不安全：生产走你自己的后端代理（url 换成代理地址）
  model: 'claude-sonnet-4-5',
  system: '你是运营助手',
  tools: [{ name: 'web_search', description: '联网检索', parameters: { type: 'object', properties: { query: { type: 'string' } } } }],
  contextWindow: 32000,         // 给了才会报上下文占用
  getMessages: () => session.messages.value,   // 历史来源（本库消息数组）
  // 标准协议之外的载荷（RAG 引用 / 进度 / 运行卡）在这里拦截：
  onExtension: ({ event, data, json }) => {
    if (json?.type === 'citations') { session.receive(toCitationEvent(json)); return true }
    // 返回 true = 本帧已被扩展消费，跳过标准映射；不返回则照常走 adapter
  },
})

const session = useChatSession({ transport })
session.open({ cursor: 0, records: [] })
session.submit('帮我诊断渠道下滑')
```

自研后端不必再手写整条 transport：**provider 直接给一个实现了适配器契约的对象**（`createState` / `buildRequest` / `headersOf` / `frameToEvents` / `finalize`），fetch 错误处理与「abort → `turn/end(aborted)`」的收尾广播都由 `createChatTransport` 复用。

**映射表**（各家差异都在适配器里吸收，宿主不用管）：

| 环节 | OpenAI | Anthropic |
| --- | --- | --- |
| 正文 | `choices[0].delta.content` | `content_block_delta.text_delta` |
| 思考 | `delta.reasoning_content`（非官方字段，有则透传） | `content_block_delta.thinking_delta` |
| 工具参数 | `delta.tool_calls[].function.arguments`：**分片 JSON**，按 index 拼完再 parse | `content_block_delta.input_json_delta.partial_json`：同理 |
| 工具交卡时机 | 流结束 `finalize` 时一次性交（参数必须拼完） | `content_block_stop` 时交 |
| 结束语义 | `finish_reason`：`stop`→完成、`length`→截断（保留已产出）、`content_filter`→失败 | `stop_reason`：`end_turn`→完成、`max_tokens`→截断、`refusal`→失败 |
| 用量 | `usage.prompt_tokens / completion_tokens / total_tokens` | `message_start.input_tokens` + `message_delta.output_tokens`（分两处，适配器合并） |
| 工具 schema | `tools[].function.{name,description,parameters}` | `tools[].{name,description,input_schema}` |
| 历史回灌 | `role:'tool'` + `tool_call_id` | `tool_result` 内容块 |
| 中断 | 客户端 `AbortController`；abort 会解除 SSE 挂起中的 read（signal 直通解析层，`readSseFrames` 也接受 `{ signal }`），随后统一补一条 `turn/end(aborted)` | 同 |

**边界（刻意不做的事）**：这两家**没有** follow 流与补页协议，所以适配出来的 transport `open` 只登记事件出口、`page` 返回空——**历史要宿主自己存**（刷新后从你的后端拿）；`approve` / `answerQuestion` 是应用级交互，不属于 provider 适配。想用完整的会话日志层（游标/缺口补齐/断线恢复），把 `open` / `page` 接到你自己的后端即可，其余照旧。

单测用录制的流式 fixture 覆盖：纯文本、思考、分片工具参数、用量、截断、HTTP 错误、中断保留已流出正文（见 `test/chat-adapters.test.js`）。

## 多语言

对话家族的文案只有一份，住在**本包**的语言包里（`src/locale/zh-CN.js` 与 `en.js`）。**语言名由底座决定**：组件读 `EbConfigProvider` 的 `locale`（或全局默认），拿到 `name`（`zh-cn` / `en` …）后在本包语言表里取译文，因此切换后已挂载的消息、动作条、输入区就地更新，不必重建组件。

```vue
<eb-config-provider :locale="en">
  <eb-chatbot :model-value="messages" />
</eb-config-provider>
```

要点：

* **局部生效**：`EbConfigProvider` 只影响子树；不套 Provider 就跟随全局语言（`app.use(EvokeBusinessUI, { locale })`）。
* **缺失回退**：没译文的语言（如 `ja` / `ko`）或某条键缺失时回退基准包 `zh-CN`——显示中文，而不是 `actionbar.copy` 这样的裸键。
* **宿主传值优先**：`placeholder` / `user-name` / `assistant-name` 这类可传入的文案，宿主给了就用宿主的；不传才取当前语言包的默认值（prop 默认值在模块加载时冻结，所以这是渲染期解析的）。
* **语言包入口**：`@wil-works/evoke-chat/locale` 导出 `zhCN` / `en` 与 `getChatLocalePack(name)`；底座的语言包在 `@wil-works/evoke-business-ui/locale`。
* **非组件调用**：`renderChatMarkdown(content)` 不传第二参时用基准包静态文案；需要跟随语言的宿主可以自己传 `renderChatMarkdown(content, labels)`。

## API
