zen-ai-chat-ui

一个精美的大模型对话 UI 组件库,基于 Vue 3 + TypeScript。支持流式输出、思考过程折叠、Markdown 渲染(Shiki 代码高亮)、附件上传、浅色/深色双主题。可发布到 NPM,供其他项目安装复用。
3,306
Install
npm install zen-ai-chat-ui@beta
Latest Version:0.1.0-beta.25
Vue:3
License:MIT
Last Updated:Oct 14, 2026
Links: GitHub  ·  npm
Maintainer: xzisme

zen-ai-chat-ui

一个精美的大模型对话 UI 组件库,基于 Vue 3 + TypeScript。支持流式输出、思考过程折叠、Markdown 渲染(Shiki 代码高亮)、Mermaid 流程图 / 时序图、附件上传、工具调用折叠、浅色/深色双主题。可发布到 NPM,供其他项目安装复用。

特性

  • 流式输出:逐字渲染 + 光标,支持 content / reasoning 双通道分片
  • 停止生成:生成中发送按钮自动变停止按钮,点击抛 stop 事件,由业务侧中断请求
  • 生成中排队发送:开 allowQueue 后生成中也能发 —— 消息先落进宿主的队列(输入框里的条带实时展示、可逐条移除),本轮结束由宿主依次发出;流被中止 / 发送失败时队列暂停,队首出现「立即发送」
  • 向用户提问:内置提问面板(单选点击即提交 / 多选勾选后提交 / 可选自由输入),适合 agent 中途停下来向用户要一个选择
  • 会话列表:独立的 ConversationList(搜索 / 新建 / 行内重命名 / 删除事件 + 生成中徽标、来源角标、紧凑模式、可选折叠 —— 收起后只剩一条收纳条,列宽由宿主收),和 ChatContainer 并排就是完整的对话应用骨架;窄到放不下两列时,演示页会自动切成「列表页 ↔ 对话页」两页,输入框则用 showInput=false 拆出来常驻在卡片最下方,列表页也有一条(VSCode 那种感觉)
  • 输入框可拆:ChatContainer 传 showInput=false 就不渲染内置输入框,改由宿主用导出的 ChatInput 自己摆位;整个应用只挂一个实例,切页时草稿与待发附件都还在
  • 思考过程:独立的可折叠「思考中」区块,流式时展开 + 动画,完成后折叠;正文超高时内部滚动,不会把气泡撑得老长(滚动条默认悬停才淡入,不干扰阅读)。标题右侧带思考段耗时,流式实时跳、折叠也看得见
  • 工具调用:默认把同一条消息里的多个调用折叠成一组、只展示最新一个,点击可展开全部
  • 任务计划:计划类工具调用(update_plan / TodoWrite …)自动渲染成带勾选态的清单(三态 + 进度条 + 说明),并在工具组折叠时保持常驻——折叠只藏别的调用,不藏当前计划
  • 消息操作栏:气泡下方内置纯图标「复制」(提问 + 回答)与「重新生成」(回答),悬停该条消息才显示,复制带绿色对勾 + 浮层提示
  • 运行元信息:可选在气泡下方展示回答耗时、首字延迟、token 用量(1.2s · 510ms · ↑26 ↓571),耗时由 useStreaming 自动计时;与操作栏同步悬停显示(可设常显)
  • Markdown 渲染:基于 markdown-it + Shiki,双主题代码高亮、表格、引用、任务列表,代码块带语言标签与一键复制。高亮结果按「语言 + 源码」缓存,且流式期间不重复高亮"还在写、尚未闭合"的那个代码块(流结束再补一次)—— 正文是每来一段增量就整篇重渲的,没有这两层保护时一次长回答能把主线程堵住好几秒
  • Mermaid 图表:```mermaid 代码块直接画成流程图 / 时序图 / 状态图…(跟随主题配色、可切源码、语法错误退回源码),mermaid 本体只在真出图时才动态加载
  • 附件上传:点击 / 拖拽,图片缩略图 + 文件卡片,可移除;图片点击可放大预览(灯箱:左右切换 / 键盘导航 / 点背景关闭)
  • 消息侧边条:消息列表左边缘一列短横条,一轮问答一根,条宽反映这一轮的篇幅;静止时低透明不打扰,悬停浮出「提问 + 回答摘要」,点击跳到该轮提问
  • 开场白 + 预设问题:首屏欢迎语 + 可点击的话题卡片
  • 内容列宽度可配:开场白、消息列表、输入框共享同一个 maxWidth。默认 min(100%, 920px)——窄容器跟随容器,宽屏截断居中,不会再出现「开场白 640 / 消息 768」那种对不齐,也不会在 4K 屏上把一行文字拉成两米宽
  • 双主题:浅色 / 深色 / 跟随系统,通过 CSS 变量驱动,可深度定制
  • 样式自洽:所有组件带 acu- 前缀,CSS 变量作用域隔离,不污染宿主
  • 令牌收敛:字号 / 间距 / 圆角 / 动效 / 状态色全部走 --acu-* 令牌,组件内不写死色值与尺寸;正文轮次间距、气泡列宽、过渡节奏都能一处改全局生效
  • 可访问性:可交互元素带 :focus-visible 焦点环,过渡属性显式声明(不用 transition: all),prefers-reduced-motion 下自动关闭动画

安装

npm install zen-ai-chat-ui
# 或
pnpm add zen-ai-chat-ui

vue 为 peerDependency,需 >= 3.3。markdown-it、shiki、mermaid 为 dependency,会自动安装 (mermaid 只在真的渲染图表时才被 import(),不进首屏体积,见下文「图表(Mermaid)」)。

快速开始

import { createApp } from 'vue'
import App from './App.vue'
import 'zen-ai-chat-ui/style.css'

createApp(App).mount('#app')
<template>
  <ChatContainer
    :messages="messages"
    :preset-questions="questions"
    theme="light"
    @send="onSend"
  />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ChatContainer, useStreaming, uid, type ChatMessage, type StreamChunk } from 'zen-ai-chat-ui'

const messages = ref<ChatMessage[]>([])
const streaming = useStreaming()

const questions = [
  { id: 'q1', label: '介绍一下你自己', prompt: '介绍一下你自己' }
]

async function onSend({ text, files }: { text: string; files: any[] }) {
  messages.value.push({
    id: uid('u'), role: 'user', content: text, status: 'done',
    attachments: files.map(f => ({ id: f.id, name: f.file.name, size: f.file.size, type: f.file.type, preview: f.preview }))
  })

  const assistant = streaming.createAssistant()
  messages.value.push(assistant)

  // 调用大模型接口,逐块 append
  for await (const chunk of callLLM(text)) {
    streaming.append(assistant, chunk as StreamChunk)
  }
}
</script>

流式输出

useStreaming().append(message, chunk) 按 chunk.type 分发:

type delta 说明
reasoning 思考增量文本 写入 message.reasoning
content 正文增量文本 写入 message.content
done - 标记完成
error - 标记错误

首个 content 到达时,reasoning 自动标记为完成。

停止生成

生成过程中,输入框右侧的按钮会从「发送」变成「停止」,点击抛出 stop 事件,真正的中断由业务侧决定:

输入框的发送按钮变为停止按钮

<script setup lang="ts">
import { ref } from 'vue'
import { ChatContainer } from 'zen-ai-chat-ui'

const busy = ref(false)
let aborter: AbortController | null = null

async function onSend({ text }: { text: string }) {
  busy.value = true
  aborter = new AbortController()
  try {
    const res = await fetch('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ text }),
      signal: aborter.signal        // ← 关键:把 signal 交给 fetch
    })
    // ...流式读取 res.body 并 streaming.append()
  } finally {
    busy.value = false
    aborter = null
  }
}

function onStop() {
  aborter?.abort()                  // ← 真正中断请求
}
</script>

<template>
  <ChatContainer
    :messages="messages"
    :disabled="busy"
    :generating="busy"
    @send="onSend"
    @stop="onStop"
  />
</template>

组件本身不碰网络,stop 只是个信号。用 fetch 就传 AbortController 的 signal;用 SSE / WebSocket / 自研 SDK 就调各自的 close() / cancel()。

中断后组件不会把消息标成错误——已经产出的内容原样保留,由业务侧决定要不要补一句「(已停止生成)」之类的收尾文案。

disabled 和 generating 是两件事

想要的交互 传什么
生成中锁住输入框(按钮变停止) :disabled="busy" :generating="busy"
生成中仍可继续输入下一条(按钮变停止,Enter 被拦下) 只传 :generating="busy"
生成中也能发出去(先排队,本轮结束后依次发出) :generating="busy" :allow-queue="true" :queued="queue"(见下一节)
只是锁住输入,不要停止按钮 只传 :disabled="busy"

第二种模式下 Enter 不会误发(generating 期间发送被拦掉),等生成结束再按 Enter 即可;想让那一下变成"排队",走下面的 allowQueue。

停止按钮用柔和的危险色:--acu-error-soft 作底、--acu-error 作图标色,覆盖这两个变量即可换色。

生成中排队发送(allowQueue / queued)

生成中用户往往已经想好了下一句。开 allowQueue 后生成期间的 Enter / 发送按钮照常抛 send —— 宿主把它塞进自己的队列,再把队列作为 queued 传回来,输入框里就长出排队条带:

排队中  2   等本轮跑完依次发送
① 帮我把 README 补上                    📎 shot.png  ✕
② 然后跑一遍测试                                     ✕
[📎] [ 输入消息…                            ] [⊙] [↑] [■]
  • 生成中:发送按钮旁多一颗「加入队列」(↑,草稿为空时置灰);停止按钮固定在最右,位置与空闲态一致 —— 排队和打断是两件事
  • 暂停(queuePaused):流被中止 / 上一条发送失败时置 true,条带提示换成 pausedHint,队首出现「立即发送」按钮
  • 事件:unqueue(id)(点了 ✕)、flush-queued(点了「立即发送」)
<script setup lang="ts">
import { ref } from 'vue'
import { ChatContainer, uid, type QueuedMessage, type SelectedFile } from 'zen-ai-chat-ui'

const busy = ref(false)
const queue = ref<QueuedMessage[]>([])   // ← 队列本体留在宿主手里
const paused = ref(false)

function onSend({ text }: { text: string; files: SelectedFile[] }) {
  if (busy.value) {
    queue.value.push({ id: uid(), text })   // 生成中 → 排队(此时 busy 还是 true)
    paused.value = false
    return
  }
  startTurn(text)
}

// 本轮正常跑完 → 发队首;被停止 / 报错 → paused = true(内容留在队列里等手动接续)
async function onTurnEnd(ok: boolean) {
  if (!ok) { paused.value = queue.value.length > 0; return }
  const next = queue.value.shift()
  if (next) startTurn(next.text)
}
</script>

<template>
  <ChatContainer
    :messages="messages"
    :generating="busy"
    :allow-queue="true"
    :queued="queue"
    :queue-paused="paused"
    @send="onSend"
    @unqueue="(id) => (queue = queue.filter(q => q.id !== id))"
    @flush-queued="onTurnEnd(true)"
  />
</template>

⚠️ 别顺手把 disabled 也绑成 busy —— disabled 连输入框一起锁掉(pointer-events: none),排队就没得排了。三个开关的分工见上一节的表。

库为什么不管队列本身:它手里没有那条流的句柄(fetch / SSE / SDK 都在宿主侧),不知道流什么时候结束,也就无从决定"什么时候发下一条"。所以库只做两件事:生成中把发送放行、把队列画出来,再把「移除 / 立即发送」抛回去。

文案:queueLabels 逐字段覆盖 —— title(默认「排队中」)/ hint(「等本轮跑完依次发送」)/ pausedHint(「已暂停,点「立即发送」继续」)/ send(「加入队列」)/ flush(「立即发送」)/ remove(「移出队列」)。

配色:条带默认走中性面(--acu-surface-2 + --acu-border)。想换成自己的语义色(比如"待处理"档的灰蓝),覆盖 --acu-queue-edge / --acu-queue-surface / --acu-queue-ink / --acu-queue-wash 四个变量即可,不用去碰库的 class。

向用户提问

智能体中途需要用户拍板(ask_user 一类)时,把问题交给 ChatContainer 的 question,面板会出现在消息列表与输入框之间:

<ChatContainer
  :messages="messages"
  :question="pendingQuestion"          <!-- null 时不渲染 -->
  :question-submitting="submitting"    <!-- 提交请求飞行中:整体禁用,防重复提交 -->
  :question-answers="questionAnswers"  <!-- 非空 → 面板转只读「已回答」留档 -->
  @answer="onAnswer"
/>
const pendingQuestion = ref<AskUserQuestion | null>({
  question: '你想让我接下来做点什么?',
  options: ['看看最近有哪些 Git 项目', '随便聊两句', '检查某个项目的代码'],
  multiple: false,        // 默认 false
  allowFreeText: true     // 默认 true;选项为空时恒为 true
})

// 单选 1 项;多选 N 项;自由输入的内容作为额外一项
const questionAnswers = ref<string[]>([])

function onAnswer(answers: string[]) {
  questionAnswers.value = answers     // 先记下"选了什么",面板立刻转只读留档
  submit(answers)                     // 后台慢慢提交;换下一个问题时再清空
}

已回答留档(默认收起):一行「✓ 已回答 · 答了什么」,点一下展开

展开后:选项保留、所选那项打勾、其余淡化

交互约定:

  • 点选即有反馈:点下去的那一刻所选选项立刻打勾 + 主色高亮、其余淡化、标题转「已回答」——不等宿主回传 submitting 或清掉 question。面板一提交就整体 disabled,如果选中的项也跟着变灰,用户等于没看到自己选了什么
  • 单选:点选项即提交(answers = [所选选项]),不必再点按钮
  • 多选:勾选若干项后点「提交回答」;自由输入的内容作为额外一项并入
  • 提交中:传 questionSubmitting,面板整体禁用;组件内部还有一层闩锁,防止请求回流前连点导致重复提交
  • 失败重试:questionSubmitting 从 true 回到 false 时闩锁自动放开、单选的"已选"标记撤回,面板可再次提交
  • 留档:把答案回传给 questionAnswers,面板原地转成只读「已回答」(选项保留、所选那项打勾、其余淡化、输入框换成答案 chip)。不传的话这轮问答在页面上不留痕——宿主清掉 question 就没了,只剩你自己 push 的那条用户消息
  • 留档默认收起成一行:面板常被钉在输入框上方,答完之后整块选项一直杵着会把对话挤掉大半屏,而这时用户要看的是对话本身。收起后只留「✓ 已回答 · 答了什么」,整行是个开关,点一下展开回看完整问题与选项。想让它答完仍然整块铺开,传 :default-answered-expanded="true"
  • 留档还有个更省事的路子:让它留在消息流里。ask_user 那份工具调用本来就同时带着问题和答案(参数里有 question/options,结果里是用户提交的答案),所以只要把工具调用照常挂到 message.toolCalls 上,答完之后 ToolCallBlock 会自己把那一条渲染成只读留档块 —— 跟着对话一起滚,宿主这一侧一个字段都不用存。readAskUser() 是对应的解析函数,宿主也可以拿它自己筛。两个路子(questionAnswers 钉在原处 / 消息流里内联)选一个即可,都传不会重复画:姿态不同,一边是"等回答",一边是"答过了"
  • 换问题:question 变化时自动清空上一轮的勾选与输入;留档态要宿主自己把 questionAnswers 清回 []

文案默认中文(等待你的回答 / 已回答 / 输入回答 / 提交回答,展开开关的无障碍文案 展开完整问答 / 收起完整问答),需要 i18n 就传 questionLabels:

<ChatContainer
  :messages="messages"
  :question="pendingQuestion"
  :question-labels="{
    title: t('waiting'),
    answered: t('answered'),
    placeholder: t('inputAnswer'),
    submit: t('submit')
  }"
/>

也可以单独用 <AskUserPanel>(自己摆放位置与动效):

<AskUserPanel
  question="选一个目标分支"
  :options="['main', 'develop']"
  :submitting="submitting"
  :answers="answers"      <!-- 传了就是只读留档态(默认收起成一行),不传就是可交互的新提问 -->
  @answer="onAnswer"
/>

思考过程

assistant 消息的 reasoning 字段会渲染成一个独立的可折叠「思考中」区块:流式时自动展开 + 三点动画 + 光标,完成后自动折叠。

模型的思考过程动辄几千字,默认给正文加了 320px 的高度上限,超出后内部滚动,避免把气泡撑到几千像素高、把真正的回答挤到屏幕外:

思考块超出后内部滚动

滚动条默认悬停时才淡入,静止状态完全不干扰阅读(也可设为常显或彻底隐藏):

思考块悬停时滚动条淡入

┌─────────────────────────────────────────┐
│ ◐ 思考中 •••                           │  ← 头部常驻,随时可折叠
├─────────────────────────────────────────┤
│ 先看看项目结构……                    ▐   │  ← 正文:超出 320px 后内部滚动
│ Node v26.9.0,client engines 要求……  ▐   │
│ ……                                      │
└─────────────────────────────────────────┘

流式输出时正文会自动贴底跟随新内容;用户手动往上滚查看前文后跟随暂停,滚回底部自动恢复。滚动到边界不会把外层消息列表一起带走(overscroll-behavior: contain)。

标题右侧还会显示思考段耗时——流式期间实时跳动,结束后定格;折叠着也看得见,所以不用展开就知道「它想了多久」。取的是「第一个思考分片 → 最后一个思考分片」,不含思考结束后等正文的那段空档。

思考块标题右侧的耗时(折叠态)

这个数字的来源,按优先级取第一个拿得到的:

  1. message.meta.reasoningMs(宿主显式给,历史记录里就靠它)
  2. message.reasoningStartedAt / reasoningEndedAt(useStreaming().append() 收到 reasoning 分片时自动记,finish() 时折进 meta.reasoningMs)

手写流式(不走 useStreaming())的宿主在收到第一段和最后一段思考时各记一个时间戳、或直接给 reasoningMs 即可;一个都没有时什么都不显示,不会出现 — 这类占位。

ThinkingConfig 字段

字段 类型 默认值 说明
scrollable boolean true 正文超出高度上限时是否内部滚动;设 false 恢复为全部铺开
maxHeight number 320 正文最大高度(px)
followStream boolean true 流式输出时是否自动贴底跟随
scrollbar 'hover' | 'always' | 'hidden' 'hover' 正文滚动条显隐策略:悬停淡入 / 常显 / 完全隐藏(滚动能力都保留)
defaultExpanded boolean - 初始是否展开;不传则沿用默认(streaming 展开、完成折叠)
showDuration boolean true 标题右侧是否显示思考段耗时(拿不到耗时就不显示,不会留占位)
<!-- 默认:320px 上限 + 内部滚动 + 流式跟随 -->
<ChatContainer :messages="messages" @send="onSend" />

<!-- 更长一些,且不要自动跟随(让用户自己滚) -->
<ChatContainer
  :messages="messages"
  :thinking-config="{ maxHeight: 480, followStream: false }"
  @send="onSend"
/>

<!-- 恢复旧行为:思考全部铺开 -->
<ChatContainer
  :messages="messages"
  :thinking-config="{ scrollable: false }"
  @send="onSend"
/>

<!-- 滚动条常显(默认是悬停才淡入) -->
<ChatContainer
  :messages="messages"
  :thinking-config="{ scrollbar: 'always' }"
  @send="onSend"
/>

<!-- 彻底不要滚动条(仍可滚动,只是不显示) -->
<ChatContainer
  :messages="messages"
  :thinking-config="{ scrollbar: 'hidden' }"
  @send="onSend"
/>

也可以单独用 <ThinkingBlock>:

<ThinkingBlock
  :content="msg.reasoning"
  :streaming="true"
  :config="{ maxHeight: 240 }"
  :duration-ms="msg.meta?.reasoningMs"   <!-- 耗时;不传就按时间戳推、都没有则不显示 -->
/>

跑 npm run dev 后点预设里的「超长思考滚动」可以直接看到效果(含流式贴底跟随)。

scrollbar: 'hover' 为什么不是纯 CSS?因为 Blink 在祖先 :hover 状态变化时不会重算 ::-webkit-scrollbar-thumb 的样式(&:hover::-webkit-scrollbar-thumb 实测无效),所以组件内部用 mouseenter/mouseleave 切一个 is-scrollbar-visible 类来驱动。这是经验证的实现细节,正常使用无需关心。

会话列表

ConversationList 把"一段段对话"按列表呈现(新建 / 搜索 / 选中 / 重命名 / 删除),和 ChatContainer 并排就是完整的对话应用骨架:

<div style="display: flex; height: 100%">
  <aside style="width: 260px">
    <ConversationList
      :items="conversations"
      :active-id="activeId"
      :loading="loading"
      @select="openConversation"
      @new="createConversation"
      @rename="renameConversation"
      @delete="confirmDelete"
    />
  </aside>
  <ChatContainer :messages="messages" @send="onSend" />
</div>
const conversations = ref<ConversationItem[]>([
  { id: 'c1', title: '组件库能力总览', meta: '今天 · 12 条', badge: 'CLI' },
  { id: 'c2', title: '流式输出怎么接', meta: '昨天 · 6 条', generatingText: '正在生成中...' }
])

设计口径:

  • 纯展示组件:列表数据与选中态由消费方持有,组件只抛事件;删除只抛 delete,确认弹窗由消费方做(组件不内置弹窗)
  • 搜索在组件内过滤:按 title + searchText(可放模型名等不展示字段)匹配;传 :show-search="false" 可关掉
  • 行内重命名:点铅笔或双击条目进入编辑,Enter / 失焦提交、Esc 取消;标题没变或为空则不抛事件
  • 时间等文案由消费方格式化:库不做相对时间与 i18n,meta / generatingText / badge 都是现成字符串(要 i18n 就传 labels)
  • 紧凑模式 compact:更小行高与字号,适合窄面板 / 侧栏
  • 可选折叠 collapsible:工具条右端多一枚收起按钮,收起后整列只剩一条收纳条(图标 + 竖排文案,整条可点)。列宽由宿主自己收 —— 库不知道宿主是 flex 还是 grid 在排这两列,推荐收到 36–44px(padding 也要跟着收,否则 40px 里只剩十几px)。状态用 v-model:collapsed 受控;不绑也能用(组件自己记)
  • 选中项用主色软底 + 左侧 3px 竖条;操作按钮悬停或聚焦时才显形;activeId 变化时当前项自动滚进视野
Prop 类型 默认值 说明
items ConversationItem[] - 会话列表(必填)
activeId string | null null 当前选中的会话 id
loading boolean false 加载中(列表为空时显示 loading 文案)
showSearch boolean true 是否展示搜索框
showNew boolean true 是否展示新建按钮
compact boolean false 紧凑模式(窄面板 / 侧栏)
collapsible boolean false 是否可折叠(工具条右端多一枚收起按钮)
collapsed boolean - 折叠态;配 v-model:collapsed 用,不传则组件自己记
labels Partial<ConversationListLabels> - 文案覆盖(走 i18n 时用)
Event Payload 说明
select (id: string, item: ConversationItem) 选中某条会话
new - 点击新建
rename (id: string, title: string) 提交重命名(仅新标题非空且变化时触发)
delete (id: string, item: ConversationItem) 请求删除(确认弹窗由消费方负责)
update:collapsed (value: boolean) 折叠态变化(收起按钮 / 收纳条都会抛)

折叠的接线(宿主侧只有"收列宽"这一件事):

<aside :style="{ width: collapsed ? '40px' : '260px' }">
  <ConversationList
    :items="conversations"
    collapsible
    v-model:collapsed="collapsed"
    @select="onSelect"
  />
</aside>
/* 列宽收了,内边距也要跟着收,否则 40px 里只剩十几 px 给收纳条 */
aside { padding: 12px; transition: width var(--acu-duration) var(--acu-easing); }
aside:has(> .acu-conv.is-collapsed) { padding: 12px 4px; }

跑 npm run dev 后,配置面板里「会话列表」一组可以开关列表、切紧凑模式、打开折叠、给首项加"生成中"徽标;点选 / 新建 / 重命名 / 删除都会即时作用在左侧那份演示数据上。

窄屏怎么办:分两页,而不是硬挤

ConversationList 是纯展示组件,本身不做响应式——并排还是分页是宿主的版面决策,库不该替宿主定断点。但窄屏下别把 260px 的侧栏硬塞进 400px 的容器:挤到最后两列都没法看。推荐「列表页 ↔ 对话页」两个页面,输入框拆出来常驻在底部(VSCode 那种感觉:换的是中间区,输入框一直在):

<template>
  <!--
    主题 / 盒模型 / 列宽都挂在这一层:它是拆出去的输入框能蹭到的最近祖先。
    acu-root 是库的组件根类,盒模型重置(box-sizing: border-box)限定在它子树里。
  -->
  <div
    class="chat-app acu-root"
    :data-theme="resolvedTheme"
    :style="{ '--acu-max-width': maxWidth }"
  >
    <!-- 分页时顶部多一条页栏:返回按钮 + 当前页标题 -->
    <div v-if="split" class="pagebar">
      <button v-if="pane === 'chat'" @click="pane = 'list'">← 会话列表</button>
      <span>{{ pane === 'list' ? '会话列表' : activeTitle }}</span>
    </div>

    <!-- 中间区:列表页 / 对话页二选一 -->
    <div class="stage">
      <aside v-if="!split || pane === 'list'">
        <ConversationList :items="conversations" :active-id="activeId" @select="onSelect" @new="onNew" />
      </aside>
      <!-- 切页用 v-show:对话的滚动位置、流式中的消息要留着 -->
      <!-- show-input=false:输入框不在这里,拆到下面常驻 -->
      <ChatContainer
        v-show="!split || pane === 'chat'"
        :show-input="false"
        :messages="messages"
        :theme="theme"
      />
    </div>

    <!-- 输入框常驻最下方:对话页、会话列表页都有 -->
    <div class="composer">
      <ChatInput :generating="busy" @send="onSend" @stop="onStop" />
    </div>
  </div>
</template>
const pane = ref<'list' | 'chat'>('list')
// 判据用「容器宽度」而不是视口宽度:宿主页面的 padding、浏览器缩放变了都不用跟着改阈值
const split = computed(() => cfg.showList && containerWidth.value - 260 < 560)
watch(split, (now) => { if (now) pane.value = 'list' })   // 跨过阈值时落在列表页

function onSelect(id: string) { activeId.value = id; load(id); pane.value = 'chat' }
// 列表页也能发消息(输入框常驻),发完得跳到对话页,否则自己刚发的那条看不见
function onSend(p: { text: string; files: SelectedFile[] }) { pane.value = 'chat'; /* ... */ }

// 输入框已经不在 ChatContainer 里,'auto' 得自己解析一次,否则它会一路浅色
const resolvedTheme = useResolvedTheme(() => cfg.theme)
/* 想和内置的那条长得一样,留白对齐 .acu-chat-footer */
.composer {
  flex-shrink: 0;
  padding: var(--acu-space-3) var(--acu-space-4) var(--acu-space-4);
  /* 纯色。别用 `linear-gradient(to top, var(--acu-bg) 70%, transparent)`:
     库内那条渐变是 no-op(footer 与 .acu-chat 同色),而宿主把输入框拆出来后
     它是 grid 兄弟节点,透明端会露出宿主自己的面板底色 —— 输入框上方多一条异色带 */
  background: var(--acu-bg);
}
  • 阈值看容器,不看视口:260(列表占用)+ 560(对话列的最小可用宽度) 就是分界点。视口宽度只是近似值,容器宽度才是对话列真正拿到的。
  • 切页用 v-show,列表用 v-if:ChatContainer 一旦销毁,滚动位置、流式中的消息、附件预览全丢;ConversationList 重建反而正好(搜索框、滚动位置重置回干净状态)。
  • 选中会话后自动进对话页:select / new 里顺手把 pane 切到 'chat',省一次点击。
  • 只有列表真的在展示时才可能分页:宿主关掉列表(cfg.showList === false)就没有「两页」可言,对话直接铺满。
  • ChatInput 全局只挂一个:拆出来之后它不再跟着 ChatContainer 销毁重建,列表页 ↔ 对话页 来回切时草稿和待发附件都还在。

拆出输入框后有三件事必须自己接上,否则会出现「消息深色、输入框浅色」「两列左边缘差 16px」「输入框横着溢出父容器」:

要接的东西 怎么接 不接会怎样
主题 在共同祖先上挂 :data-theme('auto' 用 useResolvedTheme 解析成 'light' | 'dark') 拆出去的那层不在 ChatContainer 的 DOM 里,拿不到它解析好的主题;'auto' 时一路浅色
列宽 在共同祖先上写 --acu-max-width(ChatContainer 会继承,不用再传 maxWidth prop;想只改对话区就单独传 prop,两者同值时不会打架) 输入框和消息列各走各的,左边缘对不齐
盒模型 共同祖先加 acu-root 类 库把 box-sizing: border-box 重置限定在 .acu-root 子树里(不污染宿主)。ChatInput 根节点是 div,缺了这层会退回 content-box,width: 100% 变成「100% + 左右 padding」,横着溢出父容器(button / textarea 有浏览器 UA 的 border-box 兜着,所以只有输入框会露馅)

跟输入框一起「搬家」的还有这几个 prop / 事件:placeholder、disabled、generating、send、stop——拆出去后它们都归 ChatInput,ChatContainer 那边不用再传(showInput=false 时它也不会再读)。另外提问面板(question)仍留在 ChatContainer 里,也就是永远在常驻输入框的上方。

演示页就是这么做的:main 宽度减去列表宽度不足 560px 时自动分页(约等于视口 852px),用 ResizeObserver 量容器,所以缩放、改 padding 都会自动跟上;输入框始终在卡片最下方,列表页也在。

输入框:卡片与工具条(gitBranch / model)

输入框是一张大圆角卡片(WorkBuddy 式布局):上面是文字区,下面是常驻工具条。文字长高时按钮不会被顶走,工具条里的内容也终于有地方放。

<ChatInput
  :generating="busy"
  git-branch="main"              <!-- 工具条左组:git 图标 + 分支名(纯展示) -->
  model="Step-5-Preview"         <!-- 工具条右组:模型名(纯展示) -->
  @send="onSend"
  @stop="onStop"
/>
Prop 类型 默认 说明
gitBranch string '' 当前 git 分支名。不传(或空串)则不渲染那一片
model string '' 当前模型名。不传则不渲染那一片

两个都是纯展示片:库不读 git、也不认模型列表,宿主算好传进来,库只负责排版(超长省略 + title 全文)。它们因此不是按钮 —— 没有 hover 变色和指针光标,宿主没接点击时不会「看着能点、点了没反应」。想做成下拉选择器,在宿主侧覆盖即可。

工具条的完整形态:附件 · git 分支 …… 上下文占用圆环 · 模型 · 发送 / 停止(生成中且开了 allowQueue 时停止左侧多一颗「加入队列」)。

组件 API

<ChatContainer>

主组件,组合消息列表、开场白、输入框。

Prop 类型 默认值 说明
messages ChatMessage[] - 消息列表(必填)
presetQuestions PresetQuestion[] [] 预设问题
welcomeTitle string 见默认 开场白标题
welcomeDescription string 见默认 开场白描述
assistantName string 'AI 助手' 模型名称
assistantAvatar string - 模型头像:图片 URL / data URL。想用内置品牌键名要先过 resolveAvatar(),见「内置 AI 品牌头像」
userAvatar string - 用户头像:图片 URL / data URL(同样支持 resolveAvatar())
showAvatar boolean true 是否显示头像
placeholder string 见默认 输入框占位文字
theme 'light' | 'dark' | 'auto' 'light' 主题
disabled boolean false 禁用输入(生成中)
generating boolean false 生成中:发送按钮变停止按钮,点击抛 stop
allowQueue boolean false 生成中允许发送(先排队):Enter / 发送按钮照常抛 send,由宿主入队(详见上方「生成中排队发送」)
queued QueuedMessage[] [] 排队中的消息:非空时输入框里渲染排队条带(标题 / 计数 / 提示 / 序号 / 正文 / 附件名 / 移除)
queuePaused boolean false 队列暂停(流被中止 / 发送失败):条带提示换成 pausedHint,队首出现「立即发送」
queueLabels Partial<QueueLabels> - 排队条带文案覆盖(走 i18n 时用)
showInput boolean true 是否渲染内置输入框。传 false 时输入框由宿主自己摆(配合导出的 ChatInput),见「窄屏怎么办」
question AskUserQuestion | null null 向用户提问:非空时在消息列表与输入框之间渲染提问面板(详见上方)
questionSubmitting boolean false 提问面板是否正在提交(请求飞行中,面板整体禁用)
questionAnswers string[] [] 提问面板的已作答内容:非空时面板转只读「已回答」留档
questionLabels Partial<AskUserLabels> - 提问面板文案覆盖(走 i18n 时用)
uploadConfig Partial<UploadConfig> {} 附件上传配置
gitBranch string '' 输入框工具条展示的 git 分支名(透传给内置 ChatInput;宿主自己摆输入框时直接给那个 ChatInput 传)
model string '' 输入框工具条展示的模型名(同上)
followup FollowupInput - 追问建议(详见下方)
toolCallsConfig ToolCallsConfig - 工具调用展示配置(详见下方)
planConfig PlanConfig - 计划清单展示配置(详见下方)
thinkingConfig ThinkingConfig - 思考块展示配置(详见下方)
actionsConfig MessageActionsConfig - 气泡下方操作栏配置(详见下方)
messageMetaConfig MessageMetaConfig - 耗时 / token 元信息行配置(详见下方)
messageRailConfig MessageRailConfig - 侧边消息条配置(详见下方)
maxWidth string | number - 内容列最大宽度,一处管开场白 / 消息列表 / 输入框;不传走 --acu-max-width 令牌(min(100%, 920px))(详见下方)
Event Payload 说明
send { text: string; files: SelectedFile[] } 用户发送
select PresetQuestion 点击预设问题
retry ChatMessage 重试失败消息
followup-select (question: PresetQuestion, source: ChatMessage) 点击追问建议
stop - 点击输入框右侧「停止生成」
unqueue id: string 从排队条带移除一条(只有开了 allowQueue 才会发生)
flush-queued - 暂停态下点了队首「立即发送」:请宿主立刻把那条发出去
answer string[] 用户回答了提问面板(单选 1 项、多选 N 项,自由输入作为额外一项)

其他可独立使用的组件

MessageList、MessageBubble、ThinkingBlock、ToolCallBlock、ToolCallGroup、PlanBlock、MessageActions、MessageMeta、MessageRail、WelcomeScreen、ChatInput、AskUserPanel、ConversationList、MarkdownRenderer、FollowupSuggestions、ImagePreview 均已导出,可单独使用。

单独用时记得给它们套一层 .acu-root:盒模型重置(box-sizing: border-box)和主题令牌都挂在这个类下面,库靠它做到「组件内样式自洽、不污染宿主」。少了这层,div 类的根节点会退回 content-box(width: 100% + padding 就横着溢出),深色主题也不会生效。

composable 也一并导出:useStreaming(流式接法见「流式输出」)、useResolvedTheme(把 'auto' 解析成 'light' | 'dark',拆出输入框时要用,见「窄屏怎么办」)、useMarkdown。

内容列宽度与大屏适配(maxWidth)

开场白的内容区、消息列表、输入框在视觉上是同一列,三者必须共用同一个上限。通过 ChatContainer 的 maxWidth 一处配置:

<ChatContainer :messages="messages" />                     <!-- 不传:走令牌,默认 min(100%, 920px) -->
<ChatContainer :max-width="900" :messages="messages" />    <!-- 数字按 px -->
<ChatContainer max-width="60ch" :messages="messages" />    <!-- 字符串原样 -->
<ChatContainer max-width="100%" :messages="messages" />    <!-- 显式铺满容器 -->
  • 默认 min(100%, 920px):窄容器(移动端、侧栏面板)下 100% 生效,与容器一致;宽屏下截到 920px 居中。 既不铺满整个 4K 屏(一行文字横跨两米、推荐卡片的箭头飘到屏幕那头,整块看着「空」),也不至于缩在中间一小块。

  • 数字按 px 处理(:max-width="900" 与 max-width="900px" 等价);字符串原样透传,'60ch'、'72rem' 也能用。

  • 实现上是根节点注入一个 --acu-max-width CSS 变量,WelcomeScreen / MessageList / ChatInput / MessageRail 同时消费它。 视觉上确实是同一列,就不会再出现列宽打架。

  • 不传 maxWidth 时根节点不写这条 inline 变量,于是回落到 :root 的 --acu-max-width 令牌。 想全局改上限又不想碰模板,直接在宿主 CSS 里覆盖令牌即可(组件内传入的 maxWidth 仍然优先; 令牌和组件里的是同权重选择器,靠加载顺序决定胜负,宿主样式一般排在 ai-chat-ui/style.css 之后,够用):

    .acu-chat { --acu-max-width: min(100%, 1080px); }
    
  • 三列的左右 padding 都算在列宽以内(.acu-message-list-inner / .acu-input-wrap / .acu-welcome-inner 都是 box-sizing: border-box), 所以列宽一致时三个盒子的左右边缘严格对齐。

  • 侧边消息条(messageRailConfig)用 calc 贴住这一列的左边缘:大屏下内容列居中截断,它跟着列走,而不是留在屏幕最左边。

  • 把输入框拆出去时(showInput=false),这一列要自己接上:给常驻输入框那层写一份同样的 --acu-max-width,否则输入框和消息列各走各的,左边缘差一截。ChatInput 自己就是 max-width: var(--acu-max-width); margin: 0 auto,宿主只要把变量摆对位置。

附件与图片预览

用户上传的图片既可以出现在输入框待发送区,也可以出现在已发送的 user 气泡里。两处的图片缩略图都支持点击放大预览。

  • 缩略图带 cursor: zoom-in 与 role="button",可键盘聚焦(Enter / Space 打开)
  • 灯箱通过 Teleport 挂到 body,不受宿主容器 overflow 裁剪
  • 多图时显示左右切换按钮与 当前 / 总数 计数;支持键盘 ← → 切换、Esc 关闭、点背景关闭
  • 灯箱底色默认固定为深色半透明(这是图片查看器的通用惯例),不跟随组件主题;配色来自一组定义在裸 :root、不随 [data-theme] 变化的令牌,想换浅色灯箱覆盖 --acu-overlay-scrim / --acu-overlay-text / --acu-overlay-surface / --acu-overlay-surface-hover / --acu-overlay-ring / --acu-overlay-shadow 即可

图片点击放大预览(灯箱)

图片附件通过 message.attachments(ChatAttachment[])的 preview 字段提供预览地址:

const attachments: ChatAttachment[] = files.map(f => ({
  id: f.id,
  name: f.file.name,
  size: f.file.size,
  type: f.file.type,
  preview: f.preview    // 图片才有,指向可加载的 URL / data URL
}))

也可以单独用 <ImagePreview>:

<ImagePreview
  v-model:visible="previewVisible"
  v-model:index="previewIndex"
  :images="[{ src: '/a.png', name: 'a.png' }, { src: '/b.png', name: 'b.png' }]"
/>
Prop 类型 默认值 说明
visible boolean false 是否显示(用 v-model:visible 双向绑定)
images PreviewImage[] [] 图片列表:{ src: string; name?: string }
index number 0 当前第几张(用 v-model:index 双向绑定)
Event 说明
update:visible 关闭 / 打开时回传
update:index 切换图片时回传

跑 npm run dev 后,把图片拖进输入框或直接发送,点缩略图即可看到灯箱效果。

消息侧边条

长对话翻起来容易迷路,这列贴在消息列表左边缘的短横条就是一张"目录":一轮问答一根条 (用户提问开启新的一轮,其后到下一个提问之前的消息都算这一轮),条数随轮次增长。

消息侧边条

  • 条宽 = 这一轮的长度(默认 widthBy: 'length',对数缩放):回答长的轮次明显更长,一段对话的节奏一眼可辨;流式输出时最后一根会跟着长出来,自带进度感
  • 静止态低透明,悬停才显形(opacity: 0.3 → 1),不读的时候不抢注意力
  • 高亮跟随阅读位置:以消息列表视口的垂直中心为基准,正在看的那一轮高亮
  • 悬停浮出「提问 + 回答摘要」(各 2 行钳制),用来定位而不用真去读

悬停显示浮层

  • 点击跳转:平滑滚动到这一轮的提问处,落在视口顶部
  • 深色主题自动跟随:

深色主题下的消息侧边条

MessageRailConfig 字段

字段 类型 默认值 说明
enable boolean false 是否启用。默认关闭——会话不长时它属于多余噪音,建议显式打开
widthBy 'length' | 'even' 'length' 条宽依据:按本轮长度(对数),或全部等宽
minWidth number 8 最短条宽(px)
maxWidth number 26 最长条宽(px)
barHeight number 3 条高(px)
maxGap number 10 条间最大间距(px)。轮次多时自动压缩(下限 4px)
maxHeight number 320 条组最大高度(px),超出后条组内部滚动并把当前条带回视野
idleOpacity number 0.3 静止态整体透明度;悬停或键盘聚焦时整组显形
showTooltip boolean true 悬停单根条是否显示浮层
clickToScroll boolean true 点击条是否滚动到该轮提问
<!-- 按本轮长度取宽(默认) -->
<ChatContainer
  :messages="messages"
  :message-rail-config="{ enable: true }"
  @send="onSend"
/>

<!-- 全部等宽:只想看「聊了几轮」、不关心长短时更整齐 -->
<ChatContainer
  :messages="messages"
  :message-rail-config="{ enable: true, widthBy: 'even' }"
  @send="onSend"
/>

<!-- 更细更密,适合轮次很多的场景 -->
<ChatContainer
  :messages="messages"
  :message-rail-config="{ enable: true, barHeight: 2, maxGap: 6, idleOpacity: 0.2 }"
  @send="onSend"
/>

也可以单独用 <MessageRail>(它是 position: absolute,宿主需要有定位上下文):

<div style="position: relative">
  <MessageRail :messages="messages" :active-id="activeId" :config="{ enable: true }" />
</div>

跑 npm run dev 后点预设里的「多轮对话(侧边条)」会一次铺出多轮对话,顶部「全部配置」面板里可切「按长度 / 等宽」。

工具调用

assistant 消息通过 message.toolCalls 携带工具调用列表:

const assistant = streaming.createAssistant()
assistant.toolCalls = [
  { id: 'tc1', name: 'read_file', argsPreview: 'path=package.json', status: 'running' }
]
// 执行完成后回填
assistant.toolCalls[0].status = 'done'
assistant.toolCalls[0].result = '{ "name": "my-app" }'

一条消息里连续出现十几个调用时,平铺会把气泡撑得很长。默认行为是自动折叠成一组、只展示最新(最后一次)的调用,点击组头即可展开全部:

┌───────────────────────────────────────────┐
│ ✓ ⧉ 已调用 12 个工具              全部 ⌄  │  ← 组头:聚合状态 + 数量
├───────────────────────────────────────────┤
│ ✓ 🔧 read_file  path=src/App.vue      ⌄  │  ← 折叠态只保留最新一个
└───────────────────────────────────────────┘

组头状态聚合规则:有调用在跑显示 正在调用工具 3/12;全部结束显示 已调用 12 个工具;有失败追加 · 2 个失败。

ToolCallsConfig 字段

字段 类型 默认值 说明
group boolean true 是否折叠成组;设为 false 恢复为平铺渲染全部调用
collapseThreshold number 2 调用数量达到该值时才折叠(最小生效值 2);只有 1 个调用时不会出现组头
defaultExpanded boolean false 折叠组默认是否展开
<!-- 默认:折叠,只展示最新一个 -->
<ChatContainer :messages="messages" @send="onSend" />

<!-- 平铺:全部调用一次列出 -->
<ChatContainer :messages="messages" :tool-calls-config="{ group: false }" @send="onSend" />

<!-- 5 个以上才折叠,且默认展开 -->
<ChatContainer
  :messages="messages"
  :tool-calls-config="{ collapseThreshold: 5, defaultExpanded: true }"
  @send="onSend"
/>

单独使用 <ToolCallGroup>

<ToolCallGroup :tool-calls="msg.toolCalls ?? []" :config="{ defaultExpanded: true }" />

计划清单(PlanBlock)

计划类工具调用(update_plan / TodoWrite / todo_write …)不显示成一坨参数 JSON,而是渲染成带勾选态的清单:

┌────────────────────────────────────────────┐
│ ☑ 计划                                2/5 │  ← 标题 + 进度
│ ▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │  ← 细进度条
│ 先让库认计划,再让宿主传参数                 │  ← 计划说明(explanation)
│ ✓ 读现有 ToolCallGroup 的折叠逻辑            │
│ ✓ 抽出 parsePlanArgs 并让计划块常驻          │
│ ◉ 宿主升级依赖并跑 tsc / build              │  ← 脉冲点 = 进行中
│ ○ 补一条回归测试                            │
└────────────────────────────────────────────┘

三条约定:

  1. 不用宿主适配:只要工具名是计划类、arguments 里有步骤,组件自己就渲染清单。参数形状各家不同(steps / todos / 直接给数组,字段有 content / text / title / activeForm,状态有 in_progress / done / active…),库在 utils/plan.ts 里统一归一。宿主想自己控制,解析函数是导出的:parsePlanArgs / readPlan / isPlanTool / planProgress。
  2. 折叠时计划不会被折没:同一条消息里的多个工具调用会折叠成一组、只留最新一个,但计划被提到常驻区——模型改完计划往往接着去干别的,折叠会把最该被看着的东西藏起来。常驻区只显示最新一份计划(旧快照不刷屏)。
  3. 原始参数仍可查:清单下面有一个默认收起的「原始参数」,排查和复制时用得到。

toolCall.plan 可以由宿主直接挂(已归一化的 PlanStep[]),挂了就不走自动解析;toolCall.planNote 同理。

PlanConfig 字段

字段 类型 默认值 说明
title string 计划 卡片标题
progress boolean true 标题右侧是否显示 2/5
progressBar boolean true 是否显示细进度条
collapsible boolean true 标题行是否可点,收起 / 展开步骤清单
defaultCollapsed boolean false 初始是否收起
labels PlanLabels - 文案覆盖(走 i18n 的宿主传自己的翻译)

清单可收起:步骤多到十几条时会把气泡顶得很高,标题行整行是折叠开关(button + aria-expanded,带 focus-visible 描边)。收起后标题、2/5 进度、细进度条都留着 —— 一行就能看完「还剩几步」,想看细节再点开。默认展开:计划是执行过程的一部分,摊开才看得到现在卡在哪一步。传 collapsible: false 可退化成纯展示卡片(标题行不再是按钮)。

<!-- 英文界面 -->
<ChatContainer
  :messages="messages"
  :plan-config="{ labels: { title: 'Plan', raw: 'Raw arguments' } }"
/>

<!-- 一上来就收起的计划清单 -->
<ChatContainer :messages="messages" :plan-config="{ defaultCollapsed: true }" />

图表(Mermaid)

模型要画流程图,标准做法就是吐一个 ```mermaid 代码块。以前它会被当成未知语言,降级成一坨源码; 现在库直接把它画出来:

Mermaid 流程图渲染

  • 语言是 mermaid(或老缩写 mmd)就自动走图表链路,宿主不用做任何事
  • 图头一行:类型标题(流程图 / 时序图 / 状态图… 按源码首个关键字自动判断)、放大、复制源码、源码 / 图表切换
  • 配色 / 字号取自 --acu-* 令牌 —— 宿主换肤时图跟着一起变;深浅色切换会自动重画(缓存按主题分键,来回切只画一次)
  • 流式输出期间只挂「正在生成图表…」占位,等这一轮答完再画:半截源码要么报语法错、要么画出一张错的图
  • 画不出来也不白屏:自动退回源码 + 一句说明(鼠标悬停能看到 mermaid 的原始报错)

语法错误时退回源码显示

深色主题下的流程图

看不清怎么办:缩放下限 + 全屏查看器

图比容器宽的时候,逐字缩下去就成了一张糊图 —— 实测 420px 的面板里,993px 宽的流程图 被压到 0.39 倍(14px 的正文字等效只剩 5.5px),甘特图 0.30 倍(4.2px)。所以:

  • 缩放下限(minScale,默认 0.8):容器窄到会把图压破这条件时,不再缩, 改成保持原始尺寸 + 横向滚动(右缘一道渐隐提示还有内容)。轻微超宽(如 0.87 倍)仍然适宽显示, 不是一超宽就滚动。传 minScale: 0 回到「永远适应容器」的老行为。
  • 全屏查看器:图头点「放大」(或直接点图)|滚轮缩放 · 拖动平移 · 双击复位(适应窗口)· Esc 关闭。 默认按 100% 原始尺寸打开,装不下才缩到适应 —— 图不放大超过 100%(小图不会被拉成马赛克)。
  • 甘特图的字号是与其它图型分开配的(mermaid 读自己的 gantt.fontSize,默认 11px; 日期刻度更是渲染器里写死的 10px)—— 库会把它们抬到 14px / 12px,跟正文对齐。
  • 连线和箭头取 --acu-text-muted 一档(浅色 ≈4.7:1、深色 ≈5.4:1):原来的 --acu-border-strong 是给正文分隔线用的,当图形连线太淡(浅 1.44:1 / 深 2.25:1),深色主题里箭头基本是糊的。

体积与加载

mermaid 是 dependency(npm install 会一起装上),但库只在真的遇到 mermaid 代码块时才 import(), 打包器会因此给它单独切一个 chunk —— 从不输出流程图的宿主不付这份体积。

UMD(<script> 直引,没有打包器)场景下动态 import() 会失败,此时图表块降级成源码显示,其余功能不受影响。

setMermaidConfig()

import { setMermaidConfig } from 'zen-ai-chat-ui'

// 1) 整个关掉:```mermaid 退回普通代码块(源码仍然可见)
setMermaidConfig({ enabled: false })

// 2) 覆盖配色(不覆盖就跟随 --acu-* 令牌)
setMermaidConfig({
  themeVariables: {
    light: { primaryColor: '#e0e7ff', primaryBorderColor: '#4f46e5' },
    dark: { primaryColor: '#26263a', primaryBorderColor: '#818cf8' }
  }
})

// 3) 透传 mermaid.initialize 的其它配置
setMermaidConfig({ options: { flowchart: { curve: 'basis' } } })

// 4) 英文界面
setMermaidConfig({
  labels: {
    source: 'Code', chart: 'Diagram', loading: 'Rendering…', error: 'Render failed, showing source',
    zoom: 'Enlarge', zoomIn: 'Zoom in', zoomOut: 'Zoom out', zoomReset: 'Fit', close: 'Close',
    zoomHint: 'Scroll to zoom · drag to pan · double-click to reset · Esc to close'
  }
})

// 5) 缩放下限:传 0 关闭(永远适应容器宽度)
setMermaidConfig({ minScale: 0 })
字段 类型 默认值 说明
enabled boolean true 关掉后 mermaid 代码块退回普通代码块
themeVariables { light?, dark? } - 覆盖 mermaid 主题变量;默认按 --acu-* 令牌取色,令牌读不到时用内置兜底色板
options object - 透传 mermaid.initialize()。⚠️ securityLevel 默认 'strict'(SVG 经 DOMPurify 清洗),模型输出是不可信内容,不要改成 'loose'
minScale number 0.8 正文里图表的缩放下限:容器不够宽就不再缩,改横向滚动(0 = 关闭,永远适应容器)
labels MermaidLabels 中文 文案覆盖(含查看器的 zoom / zoomIn / zoomOut / zoomReset / close / zoomHint)
typeNames Record<string, string> 内置 图表类型标题覆盖(键见 MERMAID_TYPE_NAMES)

单独使用

图表补渲染由 <MarkdownRenderer> 在 DOM 更新后自己完成,传了 source 就行。 流式场景把 streaming 一起传上 —— 它管两件事: ① 半截的 ```mermaid 只挂「正在生成图表…」占位,等这一轮答完再画; ② 还没闭合的那个代码块按纯文本渲染(它每帧都在变,高亮它等于每帧白跑一次 Shiki), 闭合块照常高亮且走缓存。流结束务必把 streaming 翻回 false,那一下会补一次 全量渲染,最后一个代码块的高亮就是这时补上的;不翻会一直停在纯文本态。

<MarkdownRenderer :source="text" :streaming="isStreaming" />

宿主自己 v-html 渲染(不经该组件)时,可以手动补一次:

import { renderMermaidBlocks } from 'zen-ai-chat-ui'

renderMermaidBlocks(document.querySelector('#chat-body'), { streaming: false })

其余导出:isMermaidEnabled / isMermaidReady / renderMermaid / toggleMermaidSource / mermaidTypeLabel / isMermaidLang / mermaidPreviewSvg(从画布里取出可放进查看器的 SVG 标记)/ MermaidZoomDefaults / MERMAID_TYPE_NAMES; 查看器本身也导出成组件(MermaidPreview,默认 Teleport 到 body),要用在别处直接挂 v-model:visible + svg + theme 即可。

消息操作栏

每条消息气泡下方有一排纯图标按钮,默认隐藏,鼠标悬停到该条消息上才会淡入:

  • 复制(图标):user 与 assistant 消息都有,复制的是 Markdown 原文;assistant 会自动剥离 <think> 思考段,只复制可见答案。复制成功后图标变绿色对勾,并冒一个「已复制」浮层提示(默认停留 1.6s)。
  • 重新生成(图标):仅 assistant 消息,默认只出现在最后一条回答上,且流式进行中会隐藏。点击后抛出 retry 事件,由业务侧决定如何重跑。

两个按钮都带 title 与 aria-label,纯图标也不影响可访问性。

<ChatContainer
  :messages="messages"
  @send="onSend"
  @retry="(msg) => regenerate(msg)"
/>

显隐规则:只在支持 hover 的设备(@media (hover: hover))上做悬停隐藏;触摸设备没有 hover,按钮会一直可见,否则用户永远点不到。键盘 Tab 聚焦到按钮时同样会显示(:focus-within)。

MessageActionsConfig 字段

字段 类型 默认值 说明
enable boolean true 是否显示整条操作栏;设 false 全部隐藏
copy boolean true 是否显示「复制」
retry boolean true 是否显示「重新生成」
retryOnlyLast boolean true 只挂在最后一条 assistant 上;设 false 则历史回答也显示
copiedDuration number 1600 「已复制」提示停留时长(毫秒)
<!-- 关闭整个操作栏 -->
<ChatContainer :messages="messages" :actions-config="{ enable: false }" />

<!-- 只保留复制,且历史回答也给重新生成 -->
<ChatContainer
  :messages="messages"
  :actions-config="{ retryOnlyLast: false }"
/>

复制优先使用 navigator.clipboard(要求 HTTPS / localhost 等安全上下文),不可用时自动退回 textarea + execCommand,兼容 http 内网部署。错误态消息仍在气泡内保留「重试」按钮,操作栏不再重复。

单独使用 <MessageActions>

MessageActions 自身不带悬停隐藏逻辑(那由父容器决定),独立使用时常显:

<MessageActions role="assistant" :copy-text="answer" :show-retry="true" @retry="onRetry" />

运行元信息(耗时 / token)

在气泡下方多出一行 21s · 510ms · ↑26 ↓571,告诉你这次回答花了多久、吐了多少 token。

消息元信息

默认关闭,需要显式打开——它是新增的可见元素,默认打开会让所有既有页面的每条消息都多一行:

<ChatContainer
  :messages="messages"
  :message-meta-config="{ enable: true }"
  @send="onSend"
/>

耗时是自动的,token 不是

耗时不需要你做任何事:useStreaming 在 createAssistant 时记下 createdAt,第一个分片到达时记下 firstTokenAt,finish() 时记下 finishedAt 并把折算结果写进 message.meta。

token 必须你自己塞,因为它只能来自接口响应(OpenAI 用 usage.prompt_tokens,Anthropic 用 usage.input_tokens,字段名各家不同):

const assistant = streaming.createAssistant()
messages.value.push(assistant)

const res = await fetch('/api/chat', { method: 'POST', signal: ctrl.signal, body })
for await (const chunk of parseSSE(res)) {
  streaming.append(assistant, chunk)
}

// 流结束后通常还会来一个带 usage 的收尾事件
assistant.meta = {
  ...(assistant.meta ?? {}),
  usage: {
    prompt: usage.prompt_tokens,
    completion: usage.completion_tokens,
    total: usage.total_tokens,
    reasoning: usage.completion_tokens_details?.reasoning_tokens,
    cached: usage.prompt_tokens_details?.cached_tokens
  }
}
streaming.finish(assistant)

组件库不做 token 估算。按字符数猜出来的数字看着像真的,但会误导用户,比不显示更糟。

MessageMetaConfig 字段

字段 类型 默认值 说明
enable boolean false 是否展示元信息行
items MessageMetaItem[] ['duration', 'tokens'] 展示哪些项,按数组顺序渲染。可选 duration(耗时)/ firstToken(首字延迟)/ tokens / time(发送时间)
position 'inline' | 'below' 'inline' inline 与操作栏同一行、below 单独占一行
showForUser boolean false 是否也给 user 消息显示(user 侧通常只有 time 有意义)
visibility 'always' | 'hover' 'hover' 显隐时机。hover 与操作栏一致:悬停该条消息 / 键盘聚焦才淡入;always 常显

显隐规则:元信息和操作栏在同一行,默认都走「悬停才淡入」,一起出现、一起消失——避免出现「一个常驻、一个浮现」的参差感。同样是 opacity 切换,盒子一直在,不产生布局抖动。

元信息默认悬停显示:上为不悬停(两者都隐藏),下为悬停(同时淡入)

触摸设备(@media (hover: none))上 'hover' 会自动退化为常显:没有悬停这个动作,藏起来等于用户永远看不到。这一条对操作栏和元信息同样适用。

不想让它藏,传 visibility: 'always':

<!-- 元信息常显(和旧版行为一致) -->
<ChatContainer
  :messages="messages"
  :message-meta-config="{ enable: true, visibility: 'always' }"
  @send="onSend"
/>

改展示内容

<!-- 只留「首字延迟 + token」,换行排布,user 侧也显示发送时间 -->
<ChatContainer
  :messages="messages"
  :message-meta-config="{
    enable: true,
    items: ['firstToken', 'tokens', 'time'],
    position: 'below',
    showForUser: true
  }"
/>

附加自定义项

meta.extra 里的内容会原样追加到末尾,用来塞模型名、检索命中数这类业务字段:

assistant.meta = {
  ...assistant.meta,
  extra: [{ label: '模型', value: 'MiniMax-M3', title: '本次使用的模型' }]
}

渲染结果:21s · 510ms · ↑26 ↓571 · 模型 MiniMax-M3

逐字段自定义消息

不用 useStreaming 也可以,直接把时间戳和用量写在消息对象上:

messages.value.push({
  id: 'a1',
  role: 'assistant',
  content: answer,
  status: 'done',
  createdAt: t0,          // 可选:不传则耗时项不显示
  finishedAt: t1,
  firstTokenAt: t0 + 510,
  meta: { usage: { prompt: 26, completion: 571 } }
})

耗时优先取 meta.durationMs,其次用 finishedAt - createdAt 推导。两者都没有时该项自动隐藏,不会显示 NaN。

「重新生成」要清计时状态

只重置 content 会让旧的 finishedAt / firstTokenAt 留着,新一次生成算出来的耗时是从上一次开始算的——这个 bug 很隐蔽,因为数字看着仍然「像个耗时」。用导出的小工具一把清干净:

import { resetStreamTiming } from 'zen-ai-chat-ui'

function onRetry(msg: ChatMessage) {
  msg.content = ''
  msg.reasoning = ''
  msg.error = undefined
  msg.status = 'streaming'
  resetStreamTiming(msg)   // 重置 createdAt / finishedAt / firstTokenAt / meta
  runStream(msg)
}

单独使用 <MessageMeta>

它只依赖消息对象,可以塞进自己的气泡组件里:

<MessageMeta :message="msg" :config="{ items: ['duration', 'tokens'] }" />

MessageMeta 自身不带悬停隐藏逻辑(和 MessageActions 一样,那由父容器决定),独立使用时常显。上面说的「默认悬停」是 MessageBubble 配合 visibility: 'hover' 做出来的效果。

配套的格式化函数也导出了,方便你在别处复用同一套口径:formatDuration(1234) === '1.2s'、formatTokens(12345) === '1.2万'、formatClock(Date.now()) === '14:32'。

内置 AI 品牌头像

除了让用户自己填头像 URL,组件库还内置了 13 个知名 AI 产品的头像,零配置直接用。

内置 AI 品牌头像

<script setup lang="ts">
import { ChatContainer, AI_AVATARS, resolveAvatar } from 'zen-ai-chat-ui'
</script>

<template>
  <!-- 1) 直接按键名取用(有类型提示,写错会报错) -->
  <ChatContainer :messages="messages" :assistant-avatar="AI_AVATARS.claude" />

  <!-- 2) 业务里存的是厂商字符串时,用 resolveAvatar 兜底 -->
  <ChatContainer :messages="messages" :assistant-avatar="resolveAvatar(model.provider)" />
</template>

resolveAvatar 的设计意图是透传:已知键名返回内置头像,未知值原样返回。所以你可以放心把「数据库里的厂商名 or 用户自填的 URL or emoji」直接丢给它,不用写 if/else。

可用键名

claude · codex · kimi · opencode · zcode · openai · gemini · mistral · copilot · cursor · perplexity · ollama · huggingface

对应的具名导出为 avatarClaude、avatarCodex…,也可以按需 tree-shaking 引入:

import { avatarClaude } from 'zen-ai-chat-ui'

渲染「选择头像」列表

AI_AVATAR_PRESETS 提供每个头像的元信息(key / name / vendor / color / src),适合直接 v-for 成头像选择器:

<script setup lang="ts">
import { AI_AVATAR_PRESETS } from 'zen-ai-chat-ui'

const emit = defineEmits<{ pick: [key: string] }>()
</script>

<template>
  <button
    v-for="p in AI_AVATAR_PRESETS"
    :key="p.key"
    :title="`${p.name} · ${p.vendor}`"
    @click="emit('pick', p.key)"
  >
    <img :src="https://raw.githubusercontent.com/xz333221/zen-ai-chat-ui/HEAD/p.src" :alt="p.name" width="32" height="32" />
  </button>
</template>

单条消息覆盖头像

assistant-avatar / user-avatar 是全局配置。如果某一条消息想单独换头像(比如一个会话里切换了模型、或者用户给自己起了不同形象),直接在该条消息上写 avatar:

messages.value.push({
  id: 'u1',
  role: 'user',
  content: '你好',
  avatar: 'https://example.com/me.png', // 这条消息专属,优先级高于 user-avatar
  status: 'done'
})

优先级为 message.avatar > 全局 prop > 内置 svg 兜底。user 与 assistant 两种角色都支持;想还原成「跟随全局」,把该字段删掉(或置为 '')即可。

说明

  • 所有头像都会被规范化成 64×64、白色圆底的 SVG data URL,并以几何平均边长对齐视觉尺寸——宽扁的 Logo(如 Claude)不会被缩得比方形 Logo 小一圈。全部 13 个合计约 18.8 KB,已在包内,运行时零请求。
  • 源 SVG 保留在 src/avatars/svg/,生成脚本是 scripts/build-avatars.mjs(npm run build:avatars)。src/avatars/index.ts 是生成产物,请勿手改。
  • 许可:simple-icons 来源的部分为 CC0 1.0;其余为各厂商官方标识,版权归各自所有者,此处仅用于指代对应产品。详见 src/avatars/README.md。

追问建议

每轮 assistant 回复完成后,会在该条气泡下方独立区域展示一组「继续追问」按钮。宽度自适应内容,超过上限会单行省略。

基础用法(静态追问)

直接把数组传给 followup:

<ChatContainer
  :messages="messages"
  :followup="[
    { id: '1', label: '举个例子', prompt: '能举个例子吗?' },
    { id: '2', label: '深入讲讲原理', prompt: '详细讲讲它的原理' },
    { id: '3', label: '和其他方案对比', prompt: '和同类方案相比呢?' }
  ]"
/>

点击追问按钮会自动触发 send 事件(payload 为 { text: q.prompt, files: [] }),等同于用户手动发送。

高级用法(动态生成)

传 FollowupConfig 对象,启用 provider 异步生成追问。每轮 assistant 完成时会自动调用:

<ChatContainer
  :messages="messages"
  :followup="{
    title: '你可能还想问',
    mode: 'latest',
    provider: async (lastMessage, history) => {
      const res = await fetch('/api/followup', {
        method: 'POST',
        body: JSON.stringify({ last: lastMessage, history })
      })
      return (await res.json()).questions
    }
  }"
  @followup-select="(q, source) => console.log('clicked', q, source)"
/>

FollowupConfig 字段

字段 类型 默认值 说明
items PresetQuestion[] - 静态追问列表;与 provider 同时传时优先用 items
provider (last, history) => PresetQuestion[] | Promise - 动态生成追问;返回 Promise 时会展示 loading 骨架
title string '继续追问' 区段标题
mode 'after-answer' | 'latest' 'latest' 'latest' 只展示最后一条消息的追问;'after-answer' 每条完成的 assistant 都展示
showDuringStreaming boolean false 是否在 assistant 流式输出过程中就展示(默认等回复完成)
autoSend boolean true 点击追问是否自动作为用户消息发送;设为 false 则只触发 followup-select 事件

单独使用 <FollowupSuggestions>

如果想完全自定义位置 / 嵌入其他场景,直接用组件:

<FollowupSuggestions
  :items="[{ id: '1', label: '再讲讲', prompt: '再讲讲' }]"
  title="你可能还想问"
  @select="onPick"
/>

主题定制

覆盖任意 CSS 变量即可换肤,变量定义于 :root,切换深色用 [data-theme="dark"]:

:root {
  --acu-primary: #10b981;       /* 主色 */
  --acu-bubble-user-bg: #10b981;/* 用户气泡背景 */
  --acu-bubble-user-text: #fff; /* 用户气泡文字(改成实色底时记得同步) */
  --acu-radius: 14px;           /* 圆角 */
}

组件只消费令牌、不写死色值,所以下面这些「几何/节奏」类令牌也都能改:

令牌 默认 作用
--acu-max-width min(100%, 920px) 内容列(开场白 / 消息列表 / 输入框)宽度上限;组件内传入的 maxWidth 会覆盖它
--acu-bubble-max-width min(680px, 78%) 气泡与追问卡的最大宽度(同一列,改一处两处对齐)
--acu-bubble-assistant-bg transparent assistant 回答的底色。默认没有气泡,回答直接落在页面底色上
--acu-bubble-assistant-border transparent assistant 气泡的内描边(用 inset 阴影实现,不占布局)
--acu-turn-gap 16px 相邻两轮问答之间的间距
--acu-card-bg / -border / -border-hover #ffffff / #dfe0e9 / #c3c5d4 预设问题等可点选卡片的底与描边(深色下为 #1e1e25 / #2f2f39 / #454554)
--acu-space-0-5 -1-5 -2-5 -3-5 2px 6px 10px 14px 半步间距刻度
--acu-font-size-2xs / -2xl / -3xl 11px / 22px / 26px 元信息等小字 / 章节标题 / 开场白主标题
--acu-radius-md 14px 圆角阶梯里 12 → 18 之间的一档(开场白徽标、预设卡片)
--acu-line-height-heading 1.25 标题行高。标题不要继承正文的 --acu-line-height(1.65),否则单行标题会撑出 36px 的行盒
--acu-line-height-tight / -relaxed 1.5 / 1.7 单行控件 / 长文(思考、代码)
--acu-overlay-* 深色系 图片灯箱配色(定义在裸 :root,不随主题变化)

--acu-bubble-assistant-border 用的是 inset 阴影而不是 border:加 border 会把气泡撑大 2px、整列跟着抖一下,inset 阴影贴着圆角画在里面,零布局影响。

把 assistant 气泡「加回来」

默认样式里回答侧没有气泡(Claude / ChatGPT 的做法:靠留白、排版和字号层级分块), user 侧是淡主色气泡。想要两边都有实体气泡的老样式,覆盖三个令牌即可,不用改组件:

:root {
  --acu-bubble-assistant-bg: var(--acu-surface);
  --acu-bubble-assistant-border: var(--acu-border); /* 浅色下没有描边气泡会看不见 */
  --acu-bubble-user-bg: var(--acu-primary);         /* 用户气泡回到实心主色 */
  --acu-bubble-user-text: var(--acu-primary-contrast);
}

本地开发

npm install
npm run dev      # 启动调试(http://127.0.0.1:7788)
npm run build    # 构建组件库产物(dist/)
npm run release  # 一键发版(见下)

演示页的「全部配置」面板

npm run dev 打开的演示页顶栏有一个 全部配置 按钮(按钮上直接显示当前项数,如「全部配置(50 项)」), 展开后按 prop 分组列出组件库对外暴露的每一个可配置项——改一下立刻能看到对话区的变化,不用去翻文档。

全部配置面板

面板由 dev/App.vue 里的一份 SCHEMA 驱动:每个字段声明 { key, label, field, type, options, hint } 就能自动渲染出对应控件(开关 / 下拉 / 数字 / 文本 / 多选),条目计数和「重置配置」也会自动跟上。 新增一个配置项时只需往 SCHEMA 补一行,不会再出现「文档里有、演示里没有」。

演示页本身不再卡宽度(撑满视口),所以在宽屏上就能直接看到内容列被 --acu-max-width 截断居中的效果。 「内容列最大宽度」这一项留空即走组件默认值;想对比「铺满」和「截断」,填 100% 即可,也支持 ?maxWidth=900 这样的 URL 参数初始化。

发布

npm run release -- --dry-run   # 先看一遍计划,不写文件 / 不提交 / 不发布
npm run release                # 正式发版

脚本(scripts/release.js)按顺序做 8 件事:

  1. 环境检查 —— git 仓库 / 分支 / 清理遗留 *.lock / 工作区干净 / npm whoami 已登录
  2. 更新版本号 —— 同步写 package.json 与 package-lock.json
  3. 构建 —— npm run build(含 vue-tsc 类型检查)
  4. 发布物自检 —— 见下
  5. 版本占用检查 —— registry 上已存在同名版本则中止
  6. 提交并打 tag —— 只 stage package.json / package-lock.json,然后 git push + git push --tags
  7. 发布 —— npm publish --tag <dist-tag>
  8. 发布后校验 —— 轮询 registry 直到该版本可见,并检查 dist-tags 没被污染

常用参数

参数 说明
--dry-run 只打印计划。构建与 npm pack 仍会真跑,因为发布物自检必须基于真实产物
--skip-push 提交但不 push
--skip-publish 只到提交为止,不发 npm
--skip-build 跳过构建(自检会基于旧 dist,慎用)
--yes / -y 所有询问取默认答案
--version=0.1.0 指定版本号;不传则自动递增(0.1.0-beta.7 → 0.1.0-beta.8)。从预发布转正式版必须显式指定
--tag=next 覆盖 npm dist-tag

dist-tag 是自动的,这点很重要

0.1.0-beta.8 这类带预发布标识的版本会自动发到 beta 标签,而不是 latest。

npm 本身不会替你避开 latest——不加 --tag 就会把 beta 顶成 latest,让所有 npm i zen-ai-chat-ui 的消费方装到 beta 版。脚本会强制带上 --tag,并在第 8 步复查 latest 有没有被预发布版本污染。

发布物自检(第 4 步)为什么必须存在

package.json#files 是逐条列举的白名单,缺文件的问题在本地永远复现不了(本地有全部文件),只有把真实 tarball 清单拉出来比对才发现得了。脚本用三道互相独立的网卡住:

检查 拦住的问题
① dist/ 关键工件存在 vite.config.ts 的 lib.fileName / assetFileNames 被改动导致产物改名(消费方 import 'zen-ai-chat-ui/style.css' 会直接 404)
② 真实 npm pack --dry-run 清单 files 白名单里的东西被 .npmignore / .gitignore 反手排除
③ main / module / types / exports 指向的文件确实在包里 改了目录结构却漏改 exports,消费方 import 时模块解析失败

任一失败都会中止发布,不会产生"发出去才发现装不上"的包。

License

MIT

Related Packages

@smoothstream/vue

Deterministic, paced Markdown reveal for Vue 3

2,186 5
@ai-gui/vue

Vue 3 adapter for AIGUI — stream LLM output into live Vue components with cards...

11,789 0
@incremark/vue

High-performance streaming markdown renderer for Vue 3 ecosystem.

10,634 282
tax-markdown

Streaming-friendly, extensible Markdown renderer for Vue 3

1,474
@struggler/smart-ai-renderer

Vue renderer for Smart AI markdown and structured render blocks

1,078

Version History

Pre-releases (25)
Version Released License
0.1.0-beta.25 MIT
0.1.0-beta.24 MIT
0.1.0-beta.23 MIT
0.1.0-beta.22 MIT
0.1.0-beta.21 MIT