第 14 节:构建聊天交互界面
阅读时间 :约 9 分钟
难度级别 :实战
前置知识:Vue 3 Composition API、TypeScript
本节概要
通过本节学习,你将掌握:
- 设计和实现聊天界面的 UI 组件
- 使用 Composition API 管理聊天状态
- 实现 SSE 客户端接收流式数据
- Markdown 渲染和代码高亮
- 消息列表的自动滚动
- 优化用户交互体验
引言
聊天界面是用户与 AI 交互的核心。本节将介绍如何使用 Vue 3 构建一个现代化的聊天界面,支持流式响应和 Markdown 渲染。
🎯 本章目标
完成后,你将拥有:
- ✅ 完整的聊天界面
- ✅ 消息列表展示
- ✅ 流式消息接收
- ✅ Markdown 渲染
- ✅ 代码高亮
- ✅ 自动滚动
🎨 界面设计
布局结构
css
┌─────────────────────────────┐
│ Header │ ← 固定头部
├─────────────────────────────┤
│ │
│ Messages Area │ ← 可滚动消息区
│ │
│ ┌─────────────────────┐ │
│ │ User Message │ │
│ └─────────────────────┘ │
│ │
│ ┌─────────────────────┐ │
│ │ AI Message │ │
│ └─────────────────────┘ │
│ │
├─────────────────────────────┤
│ ┌─────────┐ ┌────────┐ │
│ │ Input │ │ Send │ │ ← 固定输入区
│ └─────────┘ └────────┘ │
└─────────────────────────────┘
📝 创建聊天 API
src/api/chat.ts
typescript
import type { AxiosProgressEvent, GenericAbortSignal } from 'axios'
import { post } from './request'
export interface ChatRequest {
message: string
}
export interface ChatResponse {
data: any
message: string | null
status: string
}
/**
* 流式聊天 API - SSE 格式解析
*/
export function streamChat(
data: ChatRequest,
onProgress: (content: string) => void,
abortSignal?: GenericAbortSignal
) {
let previousLength = 0
return post<ChatResponse>({
url: '/chat/ask',
data,
signal: abortSignal,
responseType: 'text',
onDownloadProgress: (progressEvent: AxiosProgressEvent) => {
// 获取完整的响应数据
const rawData = progressEvent.event.target.response
if (!rawData || typeof rawData !== 'string') return
// 只处理新增的数据
const newData = rawData.slice(previousLength)
previousLength = rawData.length
if (!newData) return
// 解析 SSE 格式: data: {content}\n\n
const lines = newData.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
let dataContent = line.slice(6) // 去掉 "data: " 前缀
// 跳过 [DONE] 信号
if (dataContent === '[DONE]') {
continue
}
// 还原转义的换行符
dataContent = dataContent.replace(/\\n/g, '\n')
if (dataContent) {
// 立即回调每一块内容
onProgress(dataContent)
}
}
}
},
})
}
🎭 创建聊天组件
src/components/ChatPage.vue
模板部分:
vue
<template>
<div class="h-full flex flex-col" style="background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);">
<!-- Header -->
<div class="flex-none border-b bg-white/80 backdrop-blur-sm shadow-sm">
<div class="flex items-center justify-between p-5">
<div>
<h1 class="text-xl font-bold bg-linear-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
AI 聊天助手
</h1>
<p class="text-sm text-slate-600 mt-1">与 AI 进行实时对话</p>
</div>
<n-avatar
round
size="medium"
:style="{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.3)'
}"
>
<span style="font-weight: 600; font-size: 14px;">AI</span>
</n-avatar>
</div>
</div>
<!-- Chat Messages -->
<div class="flex-1 min-h-0 relative">
<n-scrollbar class="absolute inset-0" ref="scrollbarRef">
<div class="max-w-4xl mx-auto p-4">
<!-- Welcome Message -->
<div v-if="messages.length === 0" class="flex justify-center items-center min-h-[60vh]">
<n-empty description="开始与 AI 对话吧!" class="text-center">
<template #icon>
<n-icon size="48" style="color: #10b981;">
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-4l-4 4z"/>
</svg>
</n-icon>
</template>
</n-empty>
</div>
<!-- Messages -->
<div class="space-y-6">
<div
v-for="(message, index) in messages"
:key="index"
class="flex"
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
>
<!-- User Message -->
<div v-if="message.role === 'user'" class="flex items-end space-x-2 max-w-[70%]">
<n-card
size="small"
class="shadow-md"
:style="{
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
color: 'white',
border: 'none'
}"
>
<div class="text-sm whitespace-pre-wrap">{{ message.content }}</div>
</n-card>
<n-avatar
size="small"
class="shrink-0"
:style="{
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)'
}"
>
<n-icon>
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
</n-icon>
</n-avatar>
</div>
<!-- AI Message -->
<div v-else class="flex items-start space-x-2 max-w-[85%]">
<n-avatar
size="small"
class="shrink-0 mt-1"
:style="{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
fontWeight: '600',
fontSize: '12px'
}"
>
AI
</n-avatar>
<n-card
size="small"
class="shadow-md"
:style="{
backgroundColor: 'white',
border: '1px solid #e2e8f0'
}"
>
<div
class="text-sm text-slate-700 markdown-content"
v-html="renderMarkdown(message.content)"
></div>
<div v-if="message.isStreaming && !message.content" class="flex items-center mt-3 text-emerald-600">
<n-spin size="small" class="mr-2" :style="{ color: '#10b981' }" />
<span class="text-xs">AI 正在思考...</span>
</div>
</n-card>
</div>
</div>
</div>
<!-- 底部间距 -->
<div class="h-32"></div>
</div>
</n-scrollbar>
</div>
<!-- Input Area -->
<div class="flex-none border-t bg-white/80 backdrop-blur-sm shadow-lg">
<div class="p-5 pb-8">
<div class="max-w-4xl mx-auto">
<div class="flex space-x-3">
<n-input
v-model:value="inputMessage"
@keydown.enter="sendMessage"
:disabled="isLoading"
type="text"
placeholder="输入您的消息..."
size="large"
class="flex-1"
/>
<n-button
@click="sendMessage"
:disabled="isLoading || !inputMessage.trim()"
size="large"
:loading="isLoading"
class="px-6 shrink-0"
:style="{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
border: 'none',
color: 'white',
fontWeight: '600',
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.3)'
}"
>
<template #icon>
<n-icon v-if="!isLoading">
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
</n-icon>
</template>
<span class="hidden sm:inline">发送</span>
</n-button>
</div>
</div>
</div>
</div>
</div>
</template>
脚本部分:
vue
<script setup lang="ts">
import { ref, nextTick, onMounted, triggerRef } from 'vue'
import { useMessage } from 'naive-ui'
import { streamChat } from '../api/chat'
import { marked } from 'marked'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'
import '../styles/markdown.css'
// 配置 marked
marked.setOptions({
breaks: true,
gfm: true
})
interface Message {
role: 'user' | 'assistant'
content: string
isStreaming?: boolean
}
const messages = ref<Message[]>([])
const inputMessage = ref('')
const isLoading = ref(false)
const scrollbarRef = ref()
const message = useMessage()
let scrollTimer: number | null = null
// 渲染 Markdown 内容
const renderMarkdown = (content: string): string => {
if (!content) return ''
try {
let html = marked.parse(content) as string
// 手动处理代码块高亮
html = html.replace(/<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g,
(match, lang, code) => {
const decodedCode = code
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/&/g, '&')
if (lang && hljs.getLanguage(lang)) {
try {
const highlighted = hljs.highlight(decodedCode, { language: lang }).value
return `<pre><code class="hljs language-${lang}">${highlighted}</code></pre>`
} catch (err) {
// 高亮失败
}
}
return match
})
return html
} catch (err) {
return content
}
}
const sendMessage = async () => {
if (!inputMessage.value.trim() || isLoading.value) return
const userMessage = inputMessage.value.trim()
inputMessage.value = ''
// 添加用户消息
messages.value.push({
role: 'user',
content: userMessage
})
// 添加 AI 消息占位符
const aiMessage: Message = {
role: 'assistant',
content: '',
isStreaming: true
}
messages.value.push(aiMessage)
isLoading.value = true
await nextTick()
scrollToBottom()
try {
// 使用 streamChat 进行流式请求
await streamChat(
{ message: userMessage },
(content: string) => {
// 逐字追加内容,实现打字机效果
aiMessage.content += content
// 强制触发响应式更新
triggerRef(messages)
// 防抖滚动
debouncedScrollToBottom()
}
)
// 请求完成,停止 streaming 状态
aiMessage.isStreaming = false
triggerRef(messages)
} catch (error) {
aiMessage.content = '抱歉,发送消息时出现错误。请检查网络连接和后端服务是否正常运行。'
aiMessage.isStreaming = false
message.error('网络连接错误,请稍后重试')
} finally {
isLoading.value = false
await nextTick()
scrollToBottom()
}
}
const scrollToBottom = () => {
if (scrollbarRef.value) {
scrollbarRef.value.scrollTo({ position: 'bottom', behavior: 'smooth' })
}
}
const debouncedScrollToBottom = () => {
if (scrollTimer) {
clearTimeout(scrollTimer)
}
scrollTimer = setTimeout(() => {
scrollToBottom()
}, 50)
}
onMounted(() => {
nextTick(() => {
scrollToBottom()
})
})
</script>
🎨 关键功能实现
1. 流式消息接收
typescript
await streamChat(
{ message: userMessage },
(content: string) => {
// 逐字追加内容
aiMessage.content += content
// 强制触发响应式更新
triggerRef(messages)
// 滚动到底部
debouncedScrollToBottom()
}
)
2. Markdown 渲染
typescript
const renderMarkdown = (content: string): string => {
// 使用 marked 解析
let html = marked.parse(content) as string
// 使用 highlight.js 高亮代码
html = html.replace(/<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g,
(match, lang, code) => {
const highlighted = hljs.highlight(code, { language: lang }).value
return `<pre><code class="hljs language-${lang}">${highlighted}</code></pre>`
})
return html
}
3. 自动滚动
typescript
// 防抖滚动
const debouncedScrollToBottom = () => {
if (scrollTimer) {
clearTimeout(scrollTimer)
}
scrollTimer = setTimeout(() => {
scrollToBottom()
}, 50)
}
// 滚动到底部
const scrollToBottom = () => {
if (scrollbarRef.value) {
scrollbarRef.value.scrollTo({
position: 'bottom',
behavior: 'smooth'
})
}
}
4. 响应式更新
typescript
// 使用 triggerRef 强制触发更新
import { triggerRef } from 'vue'
aiMessage.content += content
triggerRef(messages) // 强制更新
💡 Vibe Coding 要点
1. 组件化思维
markdown
与 AI 对话:
"创建一个聊天组件,包含:
1. 消息列表(用户消息和 AI 消息)
2. 输入框和发送按钮
3. 支持流式消息接收
4. Markdown 渲染
5. 自动滚动"
2. 逐步实现
第1版:静态布局
第2版:消息列表
第3版:发送消息
第4版:流式接收
第5版:Markdown 渲染
第6版:样式优化
3. 测试每个功能
typescript
// 测试消息发送
console.log('发送消息:', userMessage)
// 测试流式接收
console.log('接收内容:', content)
// 测试 Markdown 渲染
console.log('渲染结果:', renderMarkdown(content))
本节小结
本节我们完成了聊天交互界面的构建:
- 界面设计:创建了美观的聊天界面,包含消息列表和输入区
- 状态管理:使用 Composition API 管理消息和加载状态
- SSE 客户端:实现了流式数据接收和增量解析
- Markdown 渲染:集成 marked 和 highlight.js 实现富文本展示
- 自动滚动:实现了消息列表的自动滚动和防抖优化
- 用户体验:添加了加载状态、错误提示等交互细节
- 响应式更新:使用 triggerRef 确保 UI 实时更新
现在我们有了一个功能完整的聊天界面。
思考与练习
思考题
- 为什么需要使用 triggerRef?什么情况下 Vue 3 不能自动检测变化?
- 防抖滚动的延迟时间如何确定?过长或过短会有什么问题?
- 如何优化大量消息时的渲染性能?
- 如果要支持消息编辑和删除,需要如何设计?
实践练习
-
功能扩展:
- 添加消息复制功能
- 添加代码块复制按钮
- 支持消息重新生成
-
性能优化:
- 实现虚拟滚动
- 优化 Markdown 渲染性能
- 减少不必要的重渲染
-
用户体验:
- 添加打字指示器
- 添加消息发送动画
- 支持快捷键操作
-
高级功能:
- 支持多轮对话上下文
- 支持对话历史保存
- 支持对话导出