从零封装一个ChatGPT UI:Vue3+TS实现会话历史本地存储的完整方案
·
从零构建ChatGPT风格UI:Vue3+TypeScript实现会话持久化与PDF预览的工程实践
1. 项目架构设计与技术选型
在构建类ChatGPT的Web应用时,Vue3与TypeScript的组合已成为现代前端开发的首选方案。这套技术栈提供了以下核心优势:
- 响应式系统升级:Vue3的Composition API使状态管理更灵活
- 类型安全保障:TypeScript显著降低运行时错误概率
- 开发体验优化:Vite提供的极速HMR(热模块替换)
典型项目结构应包含以下模块:
src/
├── assets/ # 静态资源
├── components/ # 通用组件
├── composables/ # 组合式函数
├── router/ # 路由配置
├── stores/ # 状态管理
├── types/ # 类型定义
├── utils/ # 工具函数
└── views/ # 页面组件
2. 会话管理的核心实现
2.1 数据模型设计
首先需要定义清晰的类型接口:
interface Message {
role: 'user' | 'assistant'
content: string
timestamp: number
attachments?: Attachment[]
}
interface Conversation {
id: string
title: string
createdAt: number
updatedAt: number
messages: Message[]
isActive: boolean
}
type Attachment = {
type: 'pdf' | 'image'
name: string
size: number
url: string
}
2.2 本地存储策略优化
localStorage虽然方便但有5MB容量限制,我们采用以下优化方案:
- 数据压缩:对消息内容进行LZMA压缩
- 自动清理:LRU(最近最少使用)算法管理历史记录
- 分片存储:超过1MB的会话自动分片
const compressMessage = async (message: string): Promise<string> => {
const encoder = new TextEncoder()
const data = encoder.encode(message)
const compressed = await lzma.compress(data)
return btoa(String.fromCharCode(...new Uint8Array(compressed)))
}
const decompressMessage = async (compressed: string): Promise<string> => {
const binaryString = atob(compressed)
const bytes = new Uint8Array([...binaryString].map(char => char.charCodeAt(0)))
const decompressed = await lzma.decompress(bytes)
return new TextDecoder().decode(decompressed)
}
3. PDF预览功能的深度集成
3.1 技术方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| pdf.js | 开源免费,高度定制 | 需要手动集成 | 需要深度定制时 |
| vue-pdf-embed | 简单易用,Vue专用 | 功能有限 | 快速实现基础功能 |
| commercial库 | 功能全面 | 需要授权费用 | 企业级复杂需求 |
3.2 实现PDF缩略图导航
<template>
<div class="pdf-viewer-container">
<div class="thumbnail-sidebar">
<div
v-for="page in pageCount"
:key="page"
@click="goToPage(page)"
:class="{ active: currentPage === page }"
>
<vue-pdf-embed
:source="pdfSource"
:page="page"
width="60"
/>
</div>
</div>
<div class="main-view">
<vue-pdf-embed
ref="pdfRef"
:source="pdfSource"
:page="currentPage"
@rendered="onPageRender"
/>
<div class="page-controls">
<button @click="prevPage">上一页</button>
<span>{{ currentPage }} / {{ pageCount }}</span>
<button @click="nextPage">下一页</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import VuePdfEmbed from 'vue-pdf-embed'
const pdfSource = ref('')
const currentPage = ref(1)
const pageCount = ref(0)
const pdfRef = ref()
const onPageRender = (e: { numPages: number }) => {
pageCount.value = e.numPages
}
const goToPage = (page: number) => {
currentPage.value = page
}
const nextPage = () => {
if (currentPage.value < pageCount.value) {
currentPage.value++
}
}
const prevPage = () => {
if (currentPage.value > 1) {
currentPage.value--
}
}
</script>
4. 性能优化关键策略
4.1 虚拟滚动实现
对于长对话列表,必须实现虚拟滚动:
import { useVirtualList } from '@vueuse/core'
const { list, containerProps, wrapperProps } = useVirtualList(
messages,
{
itemHeight: 72,
overscan: 5
}
)
4.2 请求优化方案
针对大文件上传和AI响应慢的问题:
- 分块上传:将大文件分割为1MB的chunk
- 流式响应:通过SSE(Server-Sent Events)实现实时输出
- 请求取消:AbortController中断无效请求
const uploadFile = async (file: File) => {
const CHUNK_SIZE = 1024 * 1024 // 1MB
const chunks = Math.ceil(file.size / CHUNK_SIZE)
const uploadId = uuidv4()
for (let i = 0; i < chunks; i++) {
const start = i * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, file.size)
const chunk = file.slice(start, end)
const formData = new FormData()
formData.append('file', chunk)
formData.append('uploadId', uploadId)
formData.append('chunkIndex', i.toString())
formData.append('totalChunks', chunks.toString())
await axios.post('/api/upload', formData, {
onUploadProgress: (progress) => {
const percent = Math.round(
((i * CHUNK_SIZE) + progress.loaded) / file.size * 100
)
updateProgress(uploadId, percent)
}
})
}
return completeUpload(uploadId)
}
5. 安全与异常处理
5.1 敏感数据防护
const sanitizeMessage = (message: string) => {
const patterns = [
// 移除API密钥
/sk-[a-zA-Z0-9]{32,}/g,
// 移除敏感文件路径
/(\/|\\)(home|users|root)(\/|\\)[^\\/:*?"<>|\r\n]+/g
]
return patterns.reduce(
(msg, pattern) => msg.replace(pattern, '[REDACTED]'),
message
)
}
5.2 错误边界处理
<template>
<ErrorBoundary>
<ChatWindow />
<template #fallback="{ error }">
<div class="error-container">
<h3>对话加载失败</h3>
<pre>{{ error.message }}</pre>
<button @click="retry">重试</button>
</div>
</template>
</ErrorBoundary>
</template>
<script setup lang="ts">
import { ErrorBoundary } from 'vue-error-boundary'
const retry = () => {
window.location.reload()
}
</script>
6. 高级功能扩展
6.1 实时协同编辑
const setupCollaboration = (conversationId: string) => {
const socket = new WebSocket(`wss://api.example.com/collab/${conversationId}`)
socket.onmessage = (event) => {
const { type, payload } = JSON.parse(event.data)
switch (type) {
case 'TEXT_UPDATE':
applyRemoteUpdate(payload)
break
case 'PARTICIPANT_JOIN':
addParticipant(payload)
break
case 'PARTICIPANT_LEAVE':
removeParticipant(payload)
break
}
}
const sendUpdate = (update: TextUpdate) => {
socket.send(JSON.stringify({
type: 'TEXT_UPDATE',
payload: update
}))
}
return {
sendUpdate,
disconnect: () => socket.close()
}
}
6.2 消息版本控制
interface MessageVersion {
id: string
content: string
createdAt: number
author: string
}
const messageVersions = ref<Record<string, MessageVersion[]>>({})
const trackEdit = (messageId: string, content: string) => {
if (!messageVersions.value[messageId]) {
messageVersions.value[messageId] = []
}
messageVersions.value[messageId].push({
id: uuidv4(),
content,
createdAt: Date.now(),
author: currentUser.value.id
})
// 只保留最近10个版本
if (messageVersions.value[messageId].length > 10) {
messageVersions.value[messageId].shift()
}
}
7. 测试策略与质量保障
7.1 单元测试重点
describe('会话管理', () => {
it('应该正确压缩和解压消息', async () => {
const original = '这是一条测试消息'.repeat(100)
const compressed = await compressMessage(original)
const decompressed = await decompressMessage(compressed)
expect(decompressed).toEqual(original)
expect(compressed.length).toBeLessThan(original.length)
})
it('应该自动清理旧会话', () => {
const MAX_CONVERSATIONS = 20
const conversations = Array.from({ length: 25 }, (_, i) => ({
id: `conv-${i}`,
updatedAt: Date.now() - i * 3600000 // 按小时递减
}))
const cleaned = cleanupOldConversations(conversations, MAX_CONVERSATIONS)
expect(cleaned.length).toBe(MAX_CONVERSATIONS)
expect(cleaned[0].id).toBe('conv-0') // 保留最新的
})
})
7.2 E2E测试场景
describe('完整聊天流程', () => {
it('应该完成从创建会话到获取响应的完整流程', () => {
cy.visit('/')
cy.get('[data-testid="new-chat-button"]').click()
cy.get('[data-testid="message-input"]').type('你好,世界')
cy.get('[data-testid="send-button"]').click()
cy.get('[data-testid="message-list"]').should('contain', '你好,世界')
cy.get('[data-testid="assistant-message"]', { timeout: 30000 })
.should('exist')
})
})
8. 部署与持续集成
8.1 Docker化部署
# 前端构建阶段
FROM node:18 as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 生产环境
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
8.2 CI/CD流水线配置
name: CI/CD Pipeline
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm ci
- run: npm run test:unit
- run: npm run test:e2e
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v3
with:
name: dist
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v3
with:
name: dist
- uses: docker/setup-buildx-action@v2
- uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- run: docker build -t your-registry/chat-ui:latest .
- run: docker push your-registry/chat-ui:latest
- uses: appleboy/ssh-action@v0.1.10
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
docker pull your-registry/chat-ui:latest
docker stop chat-ui || true
docker rm chat-ui || true
docker run -d --name chat-ui -p 80:80 your-registry/chat-ui:latest
更多推荐


所有评论(0)