一、技术栈选择

前端核心

  • Vue3 + Composition API + TypeScript

  • Pinia状态管理

  • TailwindCSS + HeadlessUI

  • Markdown渲染(marked + highlight.js)

  • 流式传输(WebSocket/SSE)

后端模拟

  • FastAPI(Python)

  • WebSocket实时通信

辅助工具

  • 代码高亮:Prism.js

  • 图标库:Lucide

  • 动画库:Animate.css


二、项目结构

src/
├─ assets/
├─ components/
│  ├─ ChatMessage.vue   # 单条消息组件
│  ├─ MessageInput.vue  # 输入框组件
│  └─ Sidebar.vue       # 对话历史侧边栏
├─ stores/
│  └─ chat.ts           # Pinia存储
├─ types/
│  └─ chat.d.ts         # TypeScript类型定义
├─ utils/
│  ├─ markdown.ts       # Markdown处理器
│  └─ stream.ts         # 流式数据处理
├─ App.vue
└─ main.ts

三、核心实现代码

1. 类型定义(types/chat.d.ts)

interface Message {
  id: string
  content: string
  role: 'user' | 'assistant'
  timestamp: number
  status: 'pending' | 'complete' | 'error'
  isCode?: boolean
}

interface Conversation {
  id: string
  title: string
  messages: Message[]
  lastUpdated: number
}

2. Pinia存储(stores/chat.ts)

import { defineStore } from 'pinia'
import type { Conversation, Message } from '@/types/chat'

export const useChatStore = defineStore('chat', () => {
  const currentConversation = ref<Conversation | null>(null)
  const conversations = ref<Conversation[]>([])
  const apiEndpoint = import.meta.env.VITE_API_URL
  
  // 创建新对话
  const createNewConversation = () => {
    const newConv: Conversation = {
      id: crypto.randomUUID(),
      title: 'New Chat',
      messages: [],
      lastUpdated: Date.now()
    }
    conversations.value.unshift(newConv)
    currentConversation.value = newConv
  }

  // 发送消息(带流式处理)
  const sendMessage = async (content: string) => {
    if (!currentConversation.value) return
    
    const userMessage: Message = {
      id: crypto.randomUUID(),
      content,
      role: 'user',
      timestamp: Date.now(),
      status: 'complete'
    }
    
    const botMessage: Message = {
      id: crypto.randomUUID(),
      content: '',
      role: 'assistant',
      timestamp: Date.now(),
      status: 'pending'
    }
    
    currentConversation.value.messages.push(userMessage, botMessage)
    
    try {
      const response = await fetch(`${apiEndpoint}/chat`, {
        method: 'POST',
        body: JSON.stringify({
          message: content,
          conversation_id: currentConversation.value.id
        })
      })
      
      const reader = response.body?.getReader()
      const decoder = new TextDecoder()
      
      while (true) {
        const { done, value } = await reader!.read()
        if (done) break
        
        const chunk = decoder.decode(value)
        botMessage.content += chunk
        botMessage.content = parseMarkdown(botMessage.content) // Markdown处理
      }
      
      botMessage.status = 'complete'
    } catch (error) {
      botMessage.content = '请求失败,请重试'
      botMessage.status = 'error'
    }
  }

  return { 
    currentConversation,
    conversations,
    createNewConversation,
    sendMessage
  }
})

3. 消息组件(components/ChatMessage.vue)

<script setup lang="ts">
import { computed } from 'vue'
import type { Message } from '@/types/chat'

const props = defineProps<{
  message: Message
}>()

const messageClass = computed(() => ({
  'bg-blue-50': props.message.role === 'user',
  'bg-gray-50': props.message.role === 'assistant',
  'border-red-200': props.message.status === 'error'
}))
</script>

<template>
  <div 
    class="p-4 rounded-lg mb-4 border transition-all"
    :class="messageClass"
  >
    <div class="flex gap-3">
      <div class="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
        <User v-if="message.role === 'user'" class="w-5 h-5" />
        <Bot v-else class="w-5 h-5" />
      </div>
      
      <div class="flex-1 overflow-x-auto">
        <div 
          v-if="message.isCode"
          class="code-block"
          v-html="message.content"
        />
        <div 
          v-else
          class="prose max-w-none"
          v-html="message.content"
        />
        
        <div 
          v-if="message.status === 'pending'"
          class="mt-2 flex items-center text-gray-500"
        >
          <div class="animate-pulse">思考中...</div>
          <div class="dot-flashing ml-2"></div>
        </div>
      </div>
    </div>
  </div>
</template>

<style>
/* 代码块样式 */
.code-block {
  @apply bg-gray-800 text-gray-100 p-4 rounded overflow-x-auto;
}

/* 加载动画 */
.dot-flashing {
  @apply relative w-2 h-2 rounded-full bg-current;
  animation: dotFlashing 1s infinite linear;
}

@keyframes dotFlashing {
  0% { opacity: 0.2; }
  50% { opacity: 1; }
  100% { opacity: 0.2; }
}
</style>

4. 流式数据处理(utils/stream.ts)

export async function handleStreamResponse(
  response: Response,
  callback: (chunk: string) => void
) {
  const reader = response.body?.getReader()
  const decoder = new TextDecoder('utf-8')
  
  while (reader) {
    const { done, value } = await reader.read()
    if (done) break
    
    const chunk = decoder.decode(value, { stream: true })
    callback(chunk)
  }
}

5. Markdown处理(utils/markdown.ts)

import { marked } from 'marked'
import hljs from 'highlight.js'

// 配置Markdown解析器
marked.setOptions({
  highlight: (code, lang) => {
    const language = hljs.getLanguage(lang) ? lang : 'plaintext'
    return hljs.highlight(code, { language }).value
  },
  breaks: true,
  gfm: true
})

// 安全过滤
const sanitizeHtml = (html: string) => {
  return html.replace(/<script.*?>.*?<\/script>/gmi, '')
}

export const parseMarkdown = (content: string): string => {
  return sanitizeHtml(marked.parse(content))
}

四、关键功能实现

1. 流式响应处理

// 修改sendMessage方法中的响应处理
const response = await fetch(`${apiEndpoint}/chat`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    message: content,
    conversation_id: currentConversation.value.id
  })
})

if (!response.body) return

await handleStreamResponse(response, (chunk) => {
  botMessage.content += chunk
  botMessage.content = parseMarkdown(botMessage.content)
})

2. 代码块复制功能

<script setup lang="ts">
import { ClipboardDocumentIcon } from '@heroicons/vue/24/outline'

const copyToClipboard = async (code: string) => {
  try {
    await navigator.clipboard.writeText(code)
    // 显示成功反馈
  } catch (err) {
    console.error('复制失败:', err)
  }
}
</script>

<template>
  <div class="relative code-block">
    <button
      @click="copyToClipboard(rawCode)"
      class="absolute top-2 right-2 p-1 hover:bg-gray-700 rounded"
    >
      <ClipboardDocumentIcon class="w-4 h-4 text-gray-400" />
    </button>
    <div v-html="highlightedCode" />
  </div>
</template>

3. 对话历史管理

// 在Pinia存储中添加
const loadConversations = async () => {
  const saved = localStorage.getItem('conversations')
  if (saved) {
    conversations.value = JSON.parse(saved)
  }
}

const saveConversations = () => {
  localStorage.setItem(
    'conversations',
    JSON.stringify(conversations.value)
  )
}

// 自动保存
watch(conversations, saveConversations, { deep: true })

五、界面优化技巧

1. 自动滚动到底部

<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'

const chatContainer = ref<HTMLElement>()

watch(
  () => store.currentConversation?.messages.length,
  async () => {
    await nextTick()
    chatContainer.value?.scrollTo({
      top: chatContainer.value.scrollHeight,
      behavior: 'smooth'
    })
  },
  { deep: true }
)
</script>

<template>
  <div 
    ref="chatContainer"
    class="flex-1 overflow-y-auto p-4 space-y-4"
  >
    <!-- 消息列表 -->
  </div>
</template>

2. 智能输入框(带历史记录)

<script setup lang="ts">
const inputHistory = ref<string[]>([])
const historyIndex = ref(-1)

const handleKeyDown = (e: KeyboardEvent) => {
  if (e.key === 'ArrowUp' && inputHistory.value.length > 0) {
    e.preventDefault()
    historyIndex.value = Math.min(
      historyIndex.value + 1,
      inputHistory.value.length - 1
    )
    message.value = inputHistory.value[historyIndex.value]
  }
  
  if (e.key === 'ArrowDown') {
    e.preventDefault()
    historyIndex.value = Math.max(historyIndex.value - 1, -1)
    message.value = historyIndex.value === -1 
      ? '' 
      : inputHistory.value[historyIndex.value]
  }
}

const saveToHistory = (content: string) => {
  inputHistory.value = [content, ...inputHistory.value].slice(0, 50)
}
</script>

六、部署方案

1. Docker部署配置

# 前端Dockerfile
FROM node:18-alpine as builder
WORK /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

2. Nginx配置

server {
    listen 80;
    server_name your-domain.com;
    
    location / {
        root /usr/share/nginx/html;
        try_files $uri $uri/ /index.html;
        
        # 流式响应需要关闭缓冲
        proxy_buffering off;
    }
    
    location /api {
        proxy_pass http://backend:8000;
        proxy_set_header Host $host;
    }
    
    location /websocket {
        proxy_pass http://backend:8000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
    }
}

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐