初级专栏 SEP 05, 2025
AI 聊天应用全栈实战(下):前端 UI + 流式渲染
#Vue#前端#AI#流式渲染#Docker
AI 聊天应用全栈实战(下):前端 UI + 流式渲染
本文是【前端转 AI 全栈实战】系列第 14 篇。 上一篇:AI 聊天应用全栈实战(上):FastAPI 后端 + 对话管理 | 下一篇:RAG 入门:让 AI 基于你的文档回答问题
这篇文章你会得到什么
后端搞定了,今天做前端。
目标是一个生产级的 AI 聊天 UI——不是那种只有一个输入框的 Demo,而是接近 ChatGPT 体验的完整界面:
- 会话管理:左侧会话列表,新建/切换/删除
- 流式渲染:打字机效果 + Typewriter Buffer 平滑输出
- Markdown 渲染:AI 回复支持代码高亮、表格、列表
- 交互细节:自动滚动、中断生成、消息复制、快捷键
- 响应式布局:桌面端侧边栏 + 移动端抽屉
技术栈用 Vue 3(你用 React 也一样,核心逻辑通用)。
项目结构
frontend/
├── src/
│ ├── App.vue
│ ├── components/
│ │ ├── ChatSidebar.vue # 左侧会话列表
│ │ ├── ChatWindow.vue # 聊天主区域
│ │ ├── MessageList.vue # 消息列表
│ │ ├── MessageItem.vue # 单条消息
│ │ ├── ChatInput.vue # 输入框
│ │ └── MarkdownRenderer.vue # Markdown 渲染
│ ├── composables/
│ │ ├── useChat.ts # 聊天核心逻辑
│ │ └── useTypewriter.ts # Typewriter Buffer
│ ├── api/
│ │ └── chat.ts # API 调用
│ └── types/
│ └── chat.ts # 类型定义
├── package.json
└── vite.config.ts
类型定义
// types/chat.ts
export interface Message {
id: string
role: 'user' | 'assistant' | 'system'
content: string
timestamp: number
streaming?: boolean
}
export interface Session {
session_id: string
title: string
message_count: number
updated_at: number
}
API 调用层
// api/chat.ts
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
export async function sendMessage(
message: string,
sessionId?: string,
onChunk?: (content: string) => void,
onDone?: (fullContent: string, sessionId: string) => void,
onError?: (error: string) => void,
signal?: AbortSignal,
) {
const response = await fetch(`${BASE_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
session_id: sessionId,
stream: true,
}),
signal,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const jsonStr = line.slice(6).trim()
if (!jsonStr) continue
try {
const data = JSON.parse(jsonStr)
if (data.type === 'content') {
onChunk?.(data.content)
} else if (data.type === 'done') {
onDone?.(data.content, data.session_id || sessionId || '')
} else if (data.type === 'session') {
// 新会话的 session_id
sessionId = data.session_id
} else if (data.type === 'error') {
onError?.(data.message)
}
} catch {}
}
}
}
export async function fetchSessions(): Promise<Session[]> {
const res = await fetch(`${BASE_URL}/api/sessions`)
return res.json()
}
export async function fetchSessionMessages(sessionId: string) {
const res = await fetch(`${BASE_URL}/api/sessions/${sessionId}/messages`)
return res.json()
}
export async function deleteSession(sessionId: string) {
await fetch(`${BASE_URL}/api/sessions/${sessionId}`, { method: 'DELETE' })
}
关键设计
onChunk回调:每收到一个文本片段就触发,驱动流式渲染AbortSignal:支持用户中断生成- SSE 手动解析:用
fetch+ReadableStream而不是EventSource,因为需要 POST 方法
Typewriter Buffer:平滑流式输出
直接把每个 chunk 追加到界面上会导致视觉抖动。用第 5 篇讲的 Typewriter Buffer 模式:
// composables/useTypewriter.ts
import { ref, onUnmounted } from 'vue'
export function useTypewriter(tickMs = 24, charsPerTick = 2) {
const displayText = ref('')
const isTyping = ref(false)
let buffer = ''
let streamEnded = false
let timer: number | null = null
function startTyping() {
if (timer) return
isTyping.value = true
timer = window.setInterval(() => {
if (buffer.length === 0) {
if (streamEnded) {
stopTyping()
}
return
}
const take = Math.min(charsPerTick, buffer.length)
displayText.value += buffer.slice(0, take)
buffer = buffer.slice(take)
}, tickMs)
}
function appendToBuffer(text: string) {
buffer += text
if (!timer) startTyping()
}
function endStream() {
streamEnded = true
}
function stopTyping() {
if (timer) {
clearInterval(timer)
timer = null
}
// 把缓冲区剩余内容全部输出
if (buffer.length > 0) {
displayText.value += buffer
buffer = ''
}
isTyping.value = false
streamEnded = false
}
function reset() {
stopTyping()
displayText.value = ''
buffer = ''
streamEnded = false
}
onUnmounted(stopTyping)
return {
displayText,
isTyping,
appendToBuffer,
endStream,
stopTyping,
reset,
}
}
使用方式:
const { displayText, appendToBuffer, endStream, reset } = useTypewriter()
// 收到 chunk 时
onChunk(content) {
appendToBuffer(content)
}
// 流结束时
onDone() {
endStream()
}
每 24ms 从缓冲区取 2 个字符输出——约 42fps,视觉上流畅自然。
核心聊天逻辑
// composables/useChat.ts
import { ref, computed } from 'vue'
import { sendMessage, fetchSessions, fetchSessionMessages, deleteSession } from '../api/chat'
import type { Message, Session } from '../types/chat'
export function useChat() {
const messages = ref<Message[]>([])
const sessions = ref<Session[]>([])
const currentSessionId = ref<string | null>(null)
const streaming = ref(false)
let abortController: AbortController | null = null
async function send(userInput: string) {
if (!userInput.trim() || streaming.value) return
// 添加用户消息
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
content: userInput,
timestamp: Date.now(),
}
messages.value.push(userMsg)
// 添加空的 AI 消息(占位)
const aiMsg: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: '',
timestamp: Date.now(),
streaming: true,
}
messages.value.push(aiMsg)
streaming.value = true
abortController = new AbortController()
try {
await sendMessage(
userInput,
currentSessionId.value || undefined,
// onChunk
(content) => {
aiMsg.content += content
},
// onDone
(fullContent, sessionId) => {
aiMsg.content = fullContent
aiMsg.streaming = false
streaming.value = false
currentSessionId.value = sessionId
loadSessions()
},
// onError
(error) => {
aiMsg.content = `出错了:${error}`
aiMsg.streaming = false
streaming.value = false
},
abortController.signal,
)
} catch (err: any) {
if (err.name === 'AbortError') {
aiMsg.streaming = false
streaming.value = false
} else {
aiMsg.content = `请求失败:${err.message}`
aiMsg.streaming = false
streaming.value = false
}
}
}
function stopGenerating() {
abortController?.abort()
abortController = null
}
async function loadSessions() {
sessions.value = await fetchSessions()
}
async function switchSession(sessionId: string) {
currentSessionId.value = sessionId
const data = await fetchSessionMessages(sessionId)
messages.value = data.messages.map((m: any, i: number) => ({
id: `${sessionId}-${i}`,
role: m.role,
content: m.content,
timestamp: Date.now(),
}))
}
function newSession() {
currentSessionId.value = null
messages.value = []
}
async function removeSession(sessionId: string) {
await deleteSession(sessionId)
if (currentSessionId.value === sessionId) {
newSession()
}
await loadSessions()
}
return {
messages,
sessions,
currentSessionId,
streaming,
send,
stopGenerating,
loadSessions,
switchSession,
newSession,
removeSession,
}
}
Markdown 渲染 + 代码高亮
AI 回复通常包含 Markdown——代码块、列表、表格。需要渲染成 HTML。
npm install marked highlight.js
<!-- components/MarkdownRenderer.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import hljs from 'highlight.js'
const props = defineProps<{ content: string }>()
marked.setOptions({
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
})
const html = computed(() => {
try {
return marked.parse(props.content)
} catch {
return props.content
}
})
</script>
<template>
<div class="markdown-body" v-html="html" />
</template>
<style>
@import 'highlight.js/styles/github-dark.css';
.markdown-body {
line-height: 1.6;
word-break: break-word;
}
.markdown-body pre {
background: #1e1e2e;
border-radius: 8px;
padding: 16px;
overflow-x: auto;
position: relative;
}
.markdown-body code {
font-family: 'Fira Code', 'JetBrains Mono', monospace;
font-size: 14px;
}
.markdown-body :not(pre) > code {
background: rgba(255, 255, 255, 0.1);
padding: 2px 6px;
border-radius: 4px;
}
</style>
代码块复制按钮
// 给所有代码块添加复制按钮
function addCopyButtons() {
document.querySelectorAll('.markdown-body pre').forEach(pre => {
if (pre.querySelector('.copy-btn')) return
const btn = document.createElement('button')
btn.className = 'copy-btn'
btn.textContent = '复制'
btn.onclick = async () => {
const code = pre.querySelector('code')?.textContent || ''
await navigator.clipboard.writeText(code)
btn.textContent = '已复制'
setTimeout(() => { btn.textContent = '复制' }, 2000)
}
pre.appendChild(btn)
})
}
聊天输入框
<!-- components/ChatInput.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{ streaming: boolean }>()
const emit = defineEmits<{
send: [message: string]
stop: []
}>()
const input = ref('')
function handleSend() {
if (!input.value.trim() || props.streaming) return
emit('send', input.value)
input.value = ''
}
function handleKeydown(e: KeyboardEvent) {
// Enter 发送,Shift+Enter 换行
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
</script>
<template>
<div class="chat-input">
<textarea
v-model="input"
@keydown="handleKeydown"
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
:disabled="streaming"
rows="1"
/>
<button v-if="streaming" @click="emit('stop')" class="stop-btn">
停止生成
</button>
<button v-else @click="handleSend" :disabled="!input.trim()" class="send-btn">
发送
</button>
</div>
</template>
<style scoped>
.chat-input {
display: flex;
gap: 8px;
padding: 16px;
border-top: 1px solid #2a2a3a;
background: #1a1a2e;
}
textarea {
flex: 1;
resize: none;
border: 1px solid #3a3a4a;
border-radius: 8px;
padding: 10px 14px;
background: #0d0d1a;
color: #e0e0e0;
font-size: 14px;
min-height: 42px;
max-height: 200px;
}
.send-btn {
padding: 10px 20px;
border-radius: 8px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border: none;
cursor: pointer;
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.stop-btn {
padding: 10px 20px;
border-radius: 8px;
background: #e74c3c;
color: white;
border: none;
cursor: pointer;
}
</style>
关键交互细节
- Enter 发送 / Shift+Enter 换行——ChatGPT 同款体验
- 流式输出时禁用输入框——防止重复发送
- “停止生成”按钮——调用
AbortController.abort()中断请求 - textarea 自动高度——内容多时自动撑高,最大 200px
自动滚动
聊天界面需要在新消息出现时自动滚动到底部,但用户手动上滚时不应该打断。
// 智能滚动:只在用户没有手动上滚时自动滚动
function useAutoScroll(containerRef: Ref<HTMLElement | null>) {
let userScrolled = false
function onScroll() {
const el = containerRef.value
if (!el) return
const threshold = 100
userScrolled = el.scrollHeight - el.scrollTop - el.clientHeight > threshold
}
function scrollToBottom(smooth = true) {
if (userScrolled) return
const el = containerRef.value
if (!el) return
el.scrollTo({
top: el.scrollHeight,
behavior: smooth ? 'smooth' : 'instant',
})
}
return { onScroll, scrollToBottom }
}
在流式输出时,每收到一个 chunk 就调用 scrollToBottom()——如果用户没有上滚就自动跟随,如果用户在翻看历史就不打断。
Docker Compose 一把部署
# docker-compose.yml
version: '3.8'
services:
backend:
build: ./backend
ports:
- "8000:8000"
env_file:
- ./backend/.env
volumes:
- ./backend/data:/app/data
frontend:
build: ./frontend
ports:
- "3000:80"
depends_on:
- backend
后端 Dockerfile:
# backend/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
前端 Dockerfile:
# frontend/Dockerfile
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
docker compose up -d
# 前端: http://localhost:3000
# 后端: http://localhost:8000
总结
- 流式渲染用 Typewriter Buffer——每 24ms 从缓冲区取字符,视觉上平滑自然。
fetch+ReadableStream消费 SSE——比 EventSource 更灵活,支持 POST + AbortSignal。- Markdown 渲染 + 代码高亮——
marked+highlight.js,加复制按钮提升体验。 - 智能自动滚动——用户没上滚就跟随,手动上滚不打断。
- Enter 发送 / Shift+Enter 换行——符合 ChatGPT 用户习惯。
- Docker Compose 打包部署——前后端一行命令启动。
这两篇(13-14)完成了一个完整的 AI 聊天应用全栈开发。接下来进入更高级的领域——RAG,让 AI 基于你的文档回答问题。
讨论话题:你做过 AI 聊天前端吗?流式渲染有没有遇到性能问题?Markdown 渲染用的什么方案?评论区聊聊。