对话子组件
EbChatbot 之外的家族成员。绝大多数场景不需要直接用它们——EbChatbot 已经把这些组装好了。它们用于两种情形:只想要其中一块(比如独立渲染一段 Markdown),或要自己编排(ChatList + 自定义发送区)。
本页组件在独立包
@wil-works/evoke-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 对话窗口 同名事件 |
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 |
| 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。
正文管线支持 GFM:表格、任务列表、行内码 与代码块。
| 能力 | 默认 |
|---|---|
| 代码高亮 | highlight.js(36 种语言) |
| 数学 / 图表 | 宿主注入渲染器才启用 |
const md = renderChatMarkdown(source)原文 HTML 一律转义为纯文本,链接与图片走协议白名单。
| Props | 类型 | 默认 | 说明 |
|---|---|---|---|
content | string | '' | Markdown 原文 |
streaming | boolean | false | 流式态:末尾补拖尾,重解析按帧合并 |
| 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 | 类型 | 默认 | 说明 |
|---|---|---|---|
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 字段。
参数
{
"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 两种——「总是允许」属于会话级权限模式,不塞进单次审批。
需要执行测试命令
工具 web_search 请求越权执行
| 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(宿主接侧边栏预览)。
- +88-12
- +42-3
- +120
- 二进制
| 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 色 + 加粗),文件树是嵌套列表。
✓ parseDiff 单文件单 hunk
✓ parseDiff 多文件各自归组
✗ ansi 换行内序列
Tests 2130 passed
Time: 18.2sEbChatDiff
吃标准统一 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 插槽就是为它们留的扩展点:
<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>更直接的写法是逐条接管工具卡的结果区:
<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 为空是配置态、非空是已创建态。
<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)
const { supported, speaking, speak, stop } = useSpeech({ lang: 'zh-CN', rate: 1 })
speak('念这段') // 空文本返回 false;再点会先 cancel 上一条EbChatSpeak 是动作条上的那颗按钮(图标 volume,朗读中转 pause 并脉动)。开箱用法是开 speech 开关,它会自动出现在助手消息上、用户消息上没有:
<eb-chatbot v-model="messages" speech />单实例单次朗读:同一条里再次点击会先停掉上一条,避免多条消息同时念。
口述(useSpeechInput · EbChatVoiceInput)
const { supported, listening, error, start, stop, toggle } = useSpeechInput({
lang: 'zh-CN',
onInterim: (text) => { /* 临时结果:覆盖填充输入框 */ },
onFinal: (text) => { /* 定稿片段:追加,光标不跳 */ },
onError: (code) => { /* not-allowed / no-speech / network ... */ },
})结果分两路是刻意的:临时结果随时在变,直接覆盖填充;定稿才追加,这样光标不会往回跳。一次定稿多句时逐段交出,不拼成一坨。
EbChatVoiceInput 是独立按钮,丢进输入区已有的插槽即可,不需要改 composer:
<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)。现在默认改为排队,并在每轮结束后自动带出下一条。
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)
有的产品希望生成中发出的消息注入到当前这一轮(「顺便也看下另一个指标」),而不是等下一轮。这需要宿主能往进行中的请求里塞内容,所以交给宿主:
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 立即发送是同一诉求,不然用户只能删了重打。
<eb-chat-queue
v-if="engine.pending.value.length"
:items="engine.pending.value"
@remove="engine.dequeue"
@clear="engine.clearQueue"
@recall="onQueueRecall"
@send-now="onQueueSendNow"
/>「取回编辑」是两步:出队 + 回填草稿。「立即发送」要宿主自己串——队列语义是"排队等下一轮",而"立即"意味着插到当前轮之前:
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
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 组字不受影响。
<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>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 门控——隐藏时仍占位,悬浮出现不引起任何位移, 也不会出现"看不见却能点"。
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 与成本都汇总):
<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),模板是最小可复用面。
engine.setTrace(msg.id, { traceId: 'run_abc123' }) // 或直接给 traceUrl<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) 让宿主跳源码。
<eb-chat-stack-trace :stack="err.stack" :max-app-frames="5" @frame-click="openInEditor" />不做 source map 映射:那要拉 map 文件,宿主映射好再传 frames 进来(stack 也接受 frames 数组)。
EbChatTestResults
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。历史几千轮的会话不会因为"全渲染"而卡住。
<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 不可用。
<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)优先于译文。