Vue组件开发:聊天界面五件套
上一节我们实现了
@ai-chat/core的 useChat composable,现在有了状态管理的"引擎"。这节我们开始写 Vue 组件——把引擎的状态渲染成用户可以看到的聊天界面。
@ai-chat/vue 的设计哲学
五个组件,各司其职:
| 组件 | 职责 | slot |
|---|---|---|
| ChatWindow | 布局容器(body + footer) | default、footer |
| MessageList | 消息列表循环渲染 | message(作用域slot) |
| MessageBubble | 单条消息气泡(头像 + 内容 + 操作) | avatar、default、actions |
| StreamText | 流式文本 + 闪烁光标 | 无 |
| InputArea | 输入框 + 发送/中断按钮 | prepend |
设计原则是组合优于配置。组件不试图做所有事情,而是通过 slot 把定制权交给使用者。比如 MessageBubble 不知道内容怎么渲染——它只是个"框",里面放纯文本还是 Markdown 渲染器由你决定。
ChatWindow:布局容器
ChatWindow 是最外层容器,负责两件事:纵向布局(消息区 + 输入区)和主题样式容器。
<script setup lang="ts">
defineProps<{
height?: string
}>()
</script>
<template>
<div class="ai-chat-window" :style="{ height: height || '100%' }">
<div class="ai-chat-window__body">
<slot />
</div>
<div class="ai-chat-window__footer">
<slot name="footer" />
</div>
</div>
</template>
vue
三个要点:
- body 用
flex: 1占满剩余空间,overflow-y: auto自动滚动 - footer 固定在底部,通过 border-top 分隔
- 只有一个 prop:height,默认 100%
样式全部使用 CSS Variables(--ai-chat-* 前缀),使用者可以通过覆盖变量自定义主题,不需要写一行组件内部样式。
MessageList:消息列表
MessageList 的核心是一个 v-for 循环,但它的特别之处在于使用了作用域 slot。
<template>
<div class="ai-chat-message-list">
<div v-for="message in messages" :key="message.id"
class="ai-chat-message-list__item">
<slot :message="message" />
</div>
</div>
</template>
vue
:message="message" 把每条消息暴露给外部 slot,使用者可以这样写:
<MessageList :messages="messages" v-slot="{ message }">
<MessageBubble :message="message">
<MarkdownRenderer :content="message.content" />
</MessageBubble>
</MessageList>
vue
MessageList 不知道也不关心每条消息怎么展示,它只负责循环。这种模式在组件库中非常常见——数据层和展示层彻底分离。
MessageBubble:消息气泡
MessageBubble 是使用频率最高的组件,它处理三个视觉区域:头像、内容、操作按钮。
<template>
<div class="ai-chat-bubble" :class="[`ai-chat-bubble--${message.role}`]">
<div class="ai-chat-bubble__avatar">
<slot name="avatar" />
</div>
<div class="ai-chat-bubble__content">
<slot />
</div>
<div class="ai-chat-bubble__actions">
<slot name="actions" />
</div>
</div>
</template>
vue
两个关键设计决策:
1. role 驱动的布局方向
用户消息(user)用 flex-direction: row-reverse 反转排列,头像在右边。助手消息(assistant)正常排列,头像在左边。这是聊天界面的标准布局。
2. actions 的 hover 显示
操作按钮默认 opacity: 0,hover 时才显示。这避免了界面过于拥挤。常见的操作包括复制、重新生成、删除等。
CSS Variables 支持的主题定制:
/* 用户气泡 */
--ai-chat-user-bg: #2563eb;
--ai-chat-user-color: #ffffff;
/* 助手气泡 */
--ai-chat-assistant-bg: #f3f4f6;
--ai-chat-assistant-color: #1f2937;
css
StreamText:流式渲染的视觉反馈
StreamText 是一个"小而美"的组件——它只做一件事:在流式输出时显示闪烁的光标。
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
stream?: string
}>()
const displayText = ref('')
const cursorVisible = ref(true)
watch(() => props.stream, (val) => {
if (val !== undefined) {
displayText.value = val
cursorVisible.value = true
}
})
watch(() => props.stream, (val) => {
if (val === undefined) {
cursorVisible.value = false
}
})
</script>
<template>
<span class="ai-chat-stream-text">
{{ displayText }}
<span v-if="cursorVisible" class="ai-chat-stream-text__cursor">▉</span>
</span>
</template>
vue
stream 属性绑定到助手消息的 content。流式过程中 content 不断变化,watch 监听到变化就更新显示文本。stream 为 undefined 时隐藏光标,表示流式结束。
光标用 Unicode 字符 █(U+2589),配合 CSS 动画实现闪烁效果:
@keyframes ai-chat-blink {
50% { opacity: 0; }
}
css
为什么不用 <blink> 或者 JS 动画?CSS keyframes 性能最好,不占用主线程。
InputArea:输入与控制
InputArea 是用户交互的入口,处理两个事件:发送消息和中断生成。
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
disabled?: boolean
placeholder?: string
}>()
const emit = defineEmits<{
send: [content: string]
abort: []
}>()
const input = ref('')
</script>
vue
disabled 状态驱动按钮切换:disabled 为 true 时(正在流式),按钮显示 "Stop" 并触发 abort 事件;disabled 为 false 时,按钮显示 "Send" 并触发 send 事件。
Enter 键提交:@keydown.enter.exact.prevent 监听 Enter 键,.exact 修饰符确保 Shift+Enter 可以换行而不触发提交。
prepend slot:预留的扩展点,常见的用途是添加文件上传按钮或快捷指令选择器。
组件组装:完整用法
五个组件组装起来就是一个完整的聊天界面:
<script setup>
import { useChat } from '@ai-chat/core'
import { ChatWindow, MessageList, MessageBubble, InputArea, StreamText } from '@ai-chat/vue'
import { MarkdownRenderer } from '@ai-chat/markdown'
import { myAdapter } from './adapter'
const { messages, isStreaming, send, abort } = useChat(myAdapter)
</script>
<template>
<ChatWindow height="600px">
<MessageList :messages="messages" v-slot="{ message }">
<MessageBubble :message="message">
<template #avatar>
<img :src="message.role === 'user' ? '/user.png' : '/bot.png'" />
</template>
<StreamText v-if="isStreaming && message.role === 'assistant'"
:stream="message.content" />
<MarkdownRenderer v-else :content="message.content" />
</MessageBubble>
</MessageList>
<template #footer>
<InputArea :disabled="isStreaming" @send="send" @abort="abort" />
</template>
</ChatWindow>
</template>
vue
注意 StreamText 和 MarkdownRenderer 的条件切换:流式过程中用 StreamText 显示带光标的实时文本,流式结束后切换为 MarkdownRenderer 渲染完整的 markdown 内容。
CSS Variables 主题定制
所有组件的视觉样式都通过 CSS Variables 控制。使用者只需在根元素覆盖变量值:
:root {
--ai-chat-primary: #8b5cf6; /* 主色调 */
--ai-chat-border-color: #e2e8f0; /* 边框色 */
--ai-chat-radius: 12px; /* 圆角 */
--ai-chat-user-bg: #8b5cf6; /* 用户气泡背景 */
--ai-chat-assistant-bg: #f1f5f9; /* 助手气泡背景 */
}
css
零 CSS 依赖,不需要安装 Tailwind 或任何 CSS 框架。这也是组件库能跨平台使用的基础——CSS Variables 在所有现代浏览器和 Electron 中都支持。
从五件套到组件族:P0 级重构
五件套是 MVP 的平铺结构(vue 包 6 个组件文件)。要长成真正的组件库,需要一次"组件族化"重构。怎么知道该拆哪些组件?去猜,猜不准就看行业里的人怎么拆。
两种参考坐标系
- 通用 UI 库的分法(如 Element Plus):总览页把组件按"基础组件 / 表单组件 / 导航 / 数据展示 / 业务"分区块——上层是基础,下层与业务关联
- AI UI 库的分法(如 Vercel AI Elements):直接按 AI 场景分五类——Chatbot(会话)、Code(代码)、Voice(语音)、Workflow(工作流)、Utilities(工具),共 47 个组件:Attachments、Chain of Thought、Checkpoint、Confirmation、Context、Conversation、Message、Model Selector、Prompt Input、Reasoning、Shimmer、Tool……后者和 ai-chat-ui 的场景完全对位,"毫不客气地借鉴"
用 local-search 出调研报告
把 AI Elements 的链接交给 local-search 做调研,产出按 P0/P1/P2 分级的设计方案(完整报告见 notes/10/resources/P0-COMPONENT-DESIGN.md)。分级原则:
- P0 核心对话:构成最简可用聊天界面的必需组件,缺一不可
- P1 增强体验:显著提升体验,第二阶段实现
- P2 高级功能:特定场景才需要,按需实现
与现有五件套的映射关系:
| 现有组件(MVP) | 重构后组件族 | 优先级 |
|---|---|---|
| ChatWindow + MessageList | Conversation 系列(Conversation / Content / Empty / ScrollBtn) | P0 |
| MessageBubble | Message 系列(Message / Content / Actions / Attachments / Feedback) | P0 |
| InputArea | PromptInput 系列(Textarea / Submit / Tools / Header / Footer) | P0 |
| -(新增) | Attachment 系列 | P0 |
| -(新增) | ToolCall 系列 | P0 |
| -(新增) | Shimmer(加载态微光) | P0 |
| - | Suggestion / Source / Reasoning / ModelSelector | P1 |
重构的关键约束:样式不能重新设计
playground.html 里那套经过多轮调优的视觉(暗色/亮色双主题、侧边栏、消息区、输入区)是已验证资产,重构是把样式提取到 Vue 组件系统,不是重新设计视觉。落成三件事:
- playground.html 的 CSS Variables 提取为
packages/vue/src/styles/tokens.css(暗色默认 +[data-theme="light"]覆盖),重命名为--ai-chat-{category}-{property}规范 - 关键 CSS 模式原样迁移:hover 显隐用
visibility(防跳动)、中性rgba(128,128,128,0.15)hover 背景、消息fadeInUp入场动画、输入框聚焦box-shadow: 0 0 0 2px var(--accent-dim) - 布局模式固定:侧边栏 260px + 768px 断点移动端抽屉、消息区 max-width 768px 居中、avatar 30px + gap 16px
AI 生成的重构计划(新建 24 个文件,见 notes/10/resources/REFACTORING-PLAN.md)要先人工确认一件事:保留 playground 已有样式设计。不给这条约束,AI 会按自己的审美重写视觉,多轮调优的成果就丢了。执行落地后:今日 8 个提交、累计 29 个新组件、1 个 composable、6 个文档页、4 个包构建成功——从 6 个平铺组件变成按 conversation / message / prompt-input / attachment / tool-call / shared 目录分组的组件族架构。
重构完成后文档站组件页出现了 Message、PromptInput、Attachment、ToolCall、Shimmer 等新文档,样式有出入的部分(比如 Shimmer 闪烁效果)继续用多轮会话修。
多窗口并行开发工作流
P0 重构落地后,剩余功能(中间 Markdown 渲染、左侧会话管理、底部消息发送)可以多个 Claude Code 窗口并行开发——这是比单窗口逐个做更接近真实的使用方式。
先清理遗留:静态 HTML 与真实组件的混淆
重构初期 playground 有两个几乎一样的页面:playground 和 playground.html——后者是 ui-ux-pro-max 生成时留下的写死 HTML 原型。两页面对得上但静态原型不该留在组件库项目里,指令:
删除 playground.html,并让这些技术组件全面化,让 playground 全屏展示,占满整个屏宽、屏高。
text
怎么确认页面渲染的是真实 Vue 组件而不是遗留 HTML:Chrome 安装 Vue Devtools(官方商店直接搜;无代理环境在 GitHub 搜 devtools 仓库按文档离线安装,browser extension 形态比装依赖包更方便)。Devtools 的组件树里能看到 PlaygroundPage 下的 Conversation、Message、PromptInput 等组件节点,选中哪块界面哪块高亮——这是真实组件的铁证。
多窗口分工示例
同一个页面按区域拆给不同窗口,各自纯业务描述:
- 窗口 A(中间区域):"Playground 中间的部分使用 mock 数据进行渲染,里面需要有 Markdown 的语法展示"
- 窗口 B(左侧列表):"左侧消息列表可以对历史的消息进行删除或重命名;新建会话时,清空中间的消息历史"
- 窗口 C(底部输入):"当在底部发送消息时,向消息列表中推送一条用户消息,全部使用 mock 接口数据"
代码冲突不可怕,功能反复才可怕。多个窗口改同一文件会有 git 冲突,AI 能解决冲突,但要防"一个窗口改好的功能被另一个窗口覆盖回去"。解法是给多AI协作立一套 git flow 规则,让 AI 把它固化成 .claude/rules 里的工作流规则:
遵循 git flow 工作流,形成一份 rules 规则,写入 .claude/rules 目录:
- 所有的任务一律开 worktree(工作目录隔离)
- feature 一律从 dev 切出,做完合并回 dev,dev 领先 main 后自动合并 main
- 在 main 上单独进行 bump 发版(新特性升第二位,重大更新升第一位)
- hotfix 直接从 main 切分支,修复后直接合回 main
- 每次完成 dev 分支上的代码后,要进行本地测试,跑通所有测试之后再进行提交
text
落地后 AI 会主动发现遗漏项并补全(比如"文件锁互斥"——main 发版或 dev 合并 main 时需要加标记防并发)。形成的规则核心是几条铁律:所有任务开 worktree 隔离、主目录只做合并和发版、feature 从 dev 开做完合 dev、hotfix 从 master 开直合 master。
与团队模式(Team)的区别
多窗口模式和第3章讲的 Team 模式不是一回事:
- Team 模式:一个窗口里主 agent 启动多个子 agent 干活——全部阻塞在一个会话里,长任务会触发会话历史自动压缩,可能丢关键信息
- 多窗口模式:每个窗口独立会话、独立上下文,还可以各自再起子 agent——机动性强,人为把页面功能做拆分后各窗口各管一块
新规则写入 .claude/rules 后,要重开窗口才生效——新会话才会加载更新后的规则文件。
三个待固化的开发问题
这轮并行开发暴露出三个流程问题,值得固化成工作流而不是每次重新对话:
- 计划完成应自动更新文档:packages 里除 docs 外的代码/文档变化后,应通过 hooks 自动触发更新 docs 站的组件文档;README 什么情况下更新,也可以在 hooks 里写对应提示词
- 调试进程交给谁跑:熟悉前端流程就自己跑
pnpm dev;交给 Claude Code 跑也行——好处是进程挂了或行为不符预期时直接问它,它会自动重启 - 整套流程怎么固化:每次开发都重复一轮完整会话太麻烦——先走通流程,再封装成 skill(第8章的 loop 类思路),或直接用 create-skill 生成
用 AI 写 Vue 组件的提示词技巧
到了编码阶段,提示词反而不需要加角色、定格式这些技巧——AI 会读取 CLAUDE.md 和项目里配置的 rules,上下文已经足够。组件级别的提示词只需要把 API 三维度列清楚。
Vue 组件有固定的结构(script setup + template + style),非常适合 AI 生成。提示词模板:
写一个 Vue 3 组件 <ComponentName>:
- Props:[列出 props 和类型]
- Events:[列出 emit 事件]
- Slots:[列出 slot 名称和用途]
- 样式要求:用 CSS Variables(--ai-chat-* 前缀),BEM 命名
- 不使用 Tailwind,纯 CSS
text
关键是把 Props、Events、Slots 三个维度都列清楚——这三个维度定义了组件的完整 API。
本章小结
- 五个组件通过组合模式(slot)实现灵活定制,不试图做所有事情
- MessageList 的作用域 slot 把数据层和展示层彻底分离
- StreamText 用 watch + CSS animation 实现流式光标效果
- InputArea 的 disabled 状态驱动 Send/Stop 按钮切换
- 所有样式通过 CSS Variables 控制,零 CSS 框架依赖
- 组件 API 三个维度:Props(数据流入)、Events(事件流出)、Slots(内容定制)
- 组件族化重构:参考 AI Elements 分级拆 P0/P1/P2,样式从 playground.html 提取而非重新设计,6 个平铺组件长成 29 个按目录分组的组件族
- 多窗口并行开发:按页面区域拆给不同窗口,git flow 规则(worktree 隔离 + 分支模型)写进 .claude/rules 防功能覆盖
下一节我们实现 @ai-chat/markdown 包:Markdown 解析、Shiki 代码高亮和 KaTeX 数学公式渲染。
↑