从零构建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容量限制,我们采用以下优化方案:

  1. 数据压缩:对消息内容进行LZMA压缩
  2. 自动清理:LRU(最近最少使用)算法管理历史记录
  3. 分片存储:超过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响应慢的问题:

  1. 分块上传:将大文件分割为1MB的chunk
  2. 流式响应:通过SSE(Server-Sent Events)实现实时输出
  3. 请求取消: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
Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐