项目正在快速迭代中,API 与视觉细节可能随版本调整,请勿用于生产环境。

对话子组件 ​

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

本页组件在独立包 @wil-works/evoke-chat 里,安装与注册见 Chatbot 对话窗口顶部说明。

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

结构类 ​

EbChatContent ​

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

面板标题(header 插槽)
中间是你的内容区(消息流 / 任意视图)
Props类型默认说明
height / widthstring | number100%数字按 px 处理
borderedbooleanfalse加边框,并给头尾加分隔线
Slots说明
header面板顶部
footer面板底部
默认主体内容(不自动滚动,滚动由 ChatList 负责)

EbChatList ​

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

我
我

这个库支持消息虚拟滚动吗?

06:56
A
AI助手

支持。ChatList 的 virtual 打开后超过阈值(默认 60 条)只渲染可视窗口,长会话不再拖慢页面。

06:57
Props类型默认说明
messagesarray[]消息数组
showThinking / renderMode / autoScroll—true / markdown / true透传给每条消息
avatarUser / avatarAssistant / userName / assistantName—— / 我 / AI助手透传
actionsarray[]动作条自定义动作
editable / editMaxLengthboolean / numberfalse / 0用户消息可编辑重发
feedback / feedbackReasonsboolean / arrayfalse / []评审与原因词汇表
toolRetryablebooleantrue失败的工具卡给不给重试钮
Events载荷
copy / regenerate / edit / feedback / suggestion-click / citation-click / tool-retry见 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 是居中弱化的系统提示(无头像、昵称、动作条)。

我
我

四种消息形态分别是?

06:57
A
AI助手

user / assistant 是双方气泡,system 与 notice 是居中弱化的系统提示。

06:58 1.8s

会话已开启联网搜索

本回答由模型生成,重要信息请二次核对

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
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 → 正文),零改动。

A
AI助手
合成答案(用时 2.4s)
A
AI助手

查到近 6 个月的数据:

对比行业均值来看,增速高于大盘:

结论:领先约 4 个百分点,主要来自 Q3 的新客放量。

4.2s

EbChatMarkdown ​

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

正文管线支持 GFM:表格、任务列表、行内码 与代码块。

能力 默认
代码高亮 highlight.js(36 种语言)
数学 / 图表 宿主注入渲染器才启用
js
const md = renderChatMarkdown(source)

原文 HTML 一律转义为纯文本,链接与图片走协议白名单。

Props类型默认说明
contentstring''Markdown 原文
streamingbooleanfalse流式态:末尾补拖尾,重解析按帧合并
Events载荷
citation-click(id) 行内引用上标被点击或回车

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

管线配置(协议白名单、主题色、追加高亮语言)见 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类型默认说明
toolCallobject{}{ id, name, label?, args?, result?, resultType?, status, duration?, error?, streaming?, argsStreaming? }
expandedboolean自动未指定时有参数或结果才可展开
retryablebooleantrue失败态是否给重试钮
Events载荷
toggle(toolCall, open)
retry(toolCall)
Slots作用域
args / result{ toolCall },可换成 EbJsonViewer 等

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

执行中

参数

{
  "query": "热泉口 化能合成 最新进展"
}

结果

{
  "hits": 3,
  "top": "深海热泉口微生物固碳研究(2026)"
}

参数

{
  "url": "https://pricing.example.com"
}

结果

定价页截图

EbChatApproval ​

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

需要执行测试命令

pnpm test -- --run

工具 web_search 请求越权执行

已拒绝
Props类型默认说明
requestobject{}{ id, toolName, reason?, detail?, status? };status 取 pending / approved / rejected
answeredboolean—受控的已响应态;不传则组件自己记(点完立即禁用,等宿主持久态覆盖)
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 取消(组字中、带修饰键时不抢)。

第 1/1 个问题0/1 已回答

按哪个口径对比?

第 1/1 个问题已回答

覆盖哪些渠道?

已回答
Props类型默认说明
requestobject{}见上;status 取 pending / answered / cancelled
answeredboolean—受控的已响应态;不传则组件自己记
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(宿主接侧边栏预览)。

  • +88-12
  • +42-3
  • +120
  • 二进制
Props类型默认说明
filesarray[][{ path, display?, added?, deleted?, binary?, oversized? }]
summaryobjectnull{ total?, added?, deleted? };缺了按文件列表自己加
collapsedRowsnumber4折叠时先露几行
defaultOpenbooleanfalse初始是否展开列表
Events载荷
select(file)

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

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

Props类型默认说明
itemsarray[]{ id?, index?, title, url?, source?, snippet?, favicon? }
collapsiblebooleantrue关掉就是常驻列表
defaultOpenbooleantrue初始展开
Events载荷
item-click(item, index)

代码 agent 三卡 ​

EbChatDiff · EbChatTerminal · EbChatFileTree

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

src/utils/parse.ts+2−1
index a1b2c3d..d4e5f6a 100644
--- a/src/utils/parse.ts
+++ b/src/utils/parse.ts
@@ -12,6 +12,7 @@ export function parse(input: string) {export function parse(input: string) {
const lines = input.split("\n")
return lines.map((l) => l.trim())
// 保留缩进:下游靠它判断层级
return lines.map((l) => l.replace(/\s+$/, ""))
}
README.md+1−0
--- a/README.md
+++ b/README.md
@@ -1,2 +1,3 @@
# 项目说明
新增了代码 agent 三卡的用法章节。
见 docs。
npm run test -- --reporter=dotexit 0
✓ parseDiff 单文件单 hunk
✓ parseDiff 多文件各自归组
✗ ansi 换行内序列
Tests  2130 passed
Time: 18.2s

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 上一条
浏览器不支持 Web Speech 时按钮整个不渲染(Firefox 目前不支持)

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。

我
我

用一句话介绍这个组件库

A
AI助手

Evoke Business UI 是一套面向中后台的 Vue 3 组件库,内置了完整的 AI 对话家族——从消息流、工具卡到多会话与浮动挂件。

当前浏览器没有 speechSynthesis,朗读钮不会渲染(能力检测生效)

生成中的输入:排队与转向 ​

此前生成期间发出的消息会被静默丢弃(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 或自己排布):

生成中,3 条排队等待发送再帮我算一下同比把结论整理成三点(仅附件)

队列不是只读列表:每条还能立刻插队发送(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 只做「从文本与光标位置算出该不该弹、弹什么、选中后文本变成什么」这件非平凡的事:

输入 / 选择命令

  • /clear清空当前会话
  • /summarize把上文压缩成摘要
  • /export导出为 Markdown
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类型默认说明
usednumber0已用 token(压力值 / 预估值)
capacitynumber0上下文窗口容量;为 0 或 used 为 0 时不渲染
breakdownobjectnull{ 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 门控——隐藏时仍占位,悬浮出现不引起任何位移, 也不会出现"看不见却能点"。

输入 1.8k,输出 460
近 2 条合计,输入 1.8k,输出 460
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类型默认说明
usageobjectnull单条用量:{ promptTokens?, completionTokens?, totalTokens?, cacheReadTokens?, cacheWriteTokens?, reasoningTokens?, ttftMs?, tokensPerSecond?, cost?, currency?, segments? },segments 为 [{ label, tokens }](token 记账的分段细分,不计入总量)
itemsarraynull多条用量(按会话汇总,token 与成本累加)
sizestring'compact'compact 用于消息元信息行,default 用于独立展示
barebooleantrue去掉底色,直接融进上下文
disclosurebooleantrue关掉阶梯披露,只留一行摘要

观测性深链 ​

消息带 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 / V8at Object.fn (http://x/y.js:1:2)、at http://x/y.js:1:2
Nodeat fn (/path/file.js:1:2)
Firefoxfn@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 解析。

12 通过2 失败1 跳过3.42s
  • 期望 is-fg-green,实际 is-fg-red is-fg-green

长会话虚拟滚动 ​

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 是估算行高:滚动中会按实测收敛,给个接近的值能少跳几次
  • 条数不到阈值时不启用(短会话白搭一层没有收益)
我
我

第 0 条短消息

A
AI助手

第 1 条:这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。

我
我

第 2 条:

js
const n = 2
function f() { return n * 2 }
A
AI助手

第 3 条

我
我

第 4 条短消息

A
AI助手

第 5 条:这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。

我
我

第 6 条:

js
const n = 6
function f() { return n * 2 }
A
AI助手

第 7 条

我
我

第 8 条短消息

A
AI助手

第 9 条:这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。这是一段用来撑高行的说明文字,验证长消息下的虚拟滚动。

我
我

第 10 条:

js
const n = 10
function f() { return n * 2 }
A
AI助手

第 11 条

我
我

第 12 条短消息

300 条消息:DOM 里只有可视窗口那十几条,滚动条长度按估算高度撑开

沙箱运行与网页预览 ​

这两张是本仓唯一会承载外部内容的组件。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 不会再被绑上去盖掉外部沙箱地址。

模型生成的页面srcdoc

EbChatWebPreview ​

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

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

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

example.com
网页预览

当前显示的是静态截图:目标站设置了 X-Frame-Options / frame-ancestors,无法嵌入。

Props类型默认说明
srcstring''预览地址(iframe 用)
screenshotstring''静态截图地址;目标站禁嵌时回退到它
embeddableboolean | nullnull宿主在服务端 HEAD 判定:true 直用 iframe、false 有截图就用截图、不传先试 iframe
titlestring''卡片标题(通常写域名)
screenshotAltstring''截图的替代文本;不传时退回 title
heightstring | number320预览区高度
extraSandboxarray[]追加的 sandbox 白名单项(白名单外的忽略)
allowstring''iframe allow 特性串(如 clipboard-write)

边框与强调的档位(对话家族) ​

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

档restinghoverfocus / 激活
输入类(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 猜。状态条把它变成一句话 + 一个可点的动作,常驻在输入台上方。

思考中
正在执行 抓取正文
已排队 2 条
压缩上下文中已压缩 12 条历史
上游 503,请稍后重试
Props类型默认说明
statusobjectnull{ phase, label?, tool?, elapsed?, hint?, queue?, error? };phase 为 idle 或缺省时不渲染
stoppablebooleanfalse当前阶段能否中断,决定是否给「停止」钮
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类型默认说明
modelValuestring''v-model
placeholder / disabled / loading—— / false / false—
allowAttachments / maxAttachmentsboolean / numbertrue / 5—
maxLength / showWordCountnumber / boolean2000 / false真正约束 textarea,传 0 不限
minRows / maxRowsnumber1 / 6自增高范围
sendOnEnterbooleantrue关掉后 Enter 只换行
stoppablebooleanfalseloading 时发送钮变停止钮;此时连按两次 Esc(500ms 内、不带修饰键)也抛 stop
accept / maxFileSizestring / number— / 0附件类型与体积;拖拽粘贴同样校验
allowDropbooleantrue允许拖拽与粘贴投递
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。

Esc 取消 / Enter 保存(按钮同义)

EbChatSuggestion ​

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

EbChatFeedback ​

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

点赞立即提交;点踩展开原因面板
Props类型默认说明
value'up' | 'down' | nullnullv-model:value
reasonsarray内置六项点踩原因词汇表
showNotebooleantrue是否显示备注框
disabledbooleanfalse—
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)。

季度报表.pdf340.0 KB
会议纪要.docx50.0 KB已上传
数据导入.csv1.0 MB类型不在白名单
方案.pptx2.0 MB
素材包.zip8.0 MB
界面稿.png500.0 KB
main.ts4.0 KB
演示录像.mp415.0 MB

不想要彩色图标时关掉即可(同一份数据,图标统一跟随正文色):

季度报表.pdf340.0 KB
会议纪要.docx50.0 KB已上传
数据导入.csv1.0 MB类型不在白名单
方案.pptx2.0 MB
素材包.zip8.0 MB
界面稿.png500.0 KB
main.ts4.0 KB
演示录像.mp415.0 MB

文案与多语言 ​

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