第 14 节:构建聊天交互界面

第 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(/&lt;/g, '<')
          .replace(/&gt;/g, '>')
          .replace(/&quot;/g, '"')
          .replace(/&#39;/g, "'")
          .replace(/&amp;/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))

本节小结

本节我们完成了聊天交互界面的构建:

  1. 界面设计:创建了美观的聊天界面,包含消息列表和输入区
  2. 状态管理:使用 Composition API 管理消息和加载状态
  3. SSE 客户端:实现了流式数据接收和增量解析
  4. Markdown 渲染:集成 marked 和 highlight.js 实现富文本展示
  5. 自动滚动:实现了消息列表的自动滚动和防抖优化
  6. 用户体验:添加了加载状态、错误提示等交互细节
  7. 响应式更新:使用 triggerRef 确保 UI 实时更新

现在我们有了一个功能完整的聊天界面。

思考与练习

思考题

  1. 为什么需要使用 triggerRef?什么情况下 Vue 3 不能自动检测变化?
  2. 防抖滚动的延迟时间如何确定?过长或过短会有什么问题?
  3. 如何优化大量消息时的渲染性能?
  4. 如果要支持消息编辑和删除,需要如何设计?

实践练习

  1. 功能扩展

    • 添加消息复制功能
    • 添加代码块复制按钮
    • 支持消息重新生成
  2. 性能优化

    • 实现虚拟滚动
    • 优化 Markdown 渲染性能
    • 减少不必要的重渲染
  3. 用户体验

    • 添加打字指示器
    • 添加消息发送动画
    • 支持快捷键操作
  4. 高级功能

    • 支持多轮对话上下文
    • 支持对话历史保存
    • 支持对话导出

上一节第 13 节:Vue 3 + TypeScript 项目初始化
下一节第 15 节:实现数据分析可视化

相关推荐
洛卡卡了3 小时前
Claude Code进阶:用Superpowers打造靠谱的AI开发工作流
aigc·ai编程·claude
李广坤3 小时前
Harness 工程:必要组件、核心工作与任务流转
ai编程
嵌入式小企鹅3 小时前
RISC-V爆发、AI编程变天、半导体涨价潮
物联网·学习·ai编程·开发工具·risc-v·芯片·工具链
dtsola3 小时前
小遥搜索v1.7.0版本更新【飞书文档+知识库支持】
程序员·飞书·dify·ai智能体·独立开发者·vibecoding·一人公司
非科班Java出身GISer4 小时前
国产 AI IDE(Agent) 颠覆传统开发方式:codebuddy 介绍,以及简单对比 trae、lingma、Comate
人工智能·ai编程·ai agent·ai ide·ai 开发工具·ai 开发软件
马丁玩编程4 小时前
历时半年,开源了一套企业级 Agentic RAG 系统!
aigc·openai·ai编程
IT 行者4 小时前
Web逆向工程AI工具:Integuru,YC W24孵化的API逆向神器
人工智能·ai编程·web逆向·mcp
github.com/starRTC4 小时前
AI写需求系列之PM Skills
ai编程
花千树-0104 小时前
IndexTTS2 在 macOS 性能最佳设置(M1/M2/M3/M4 全适用)
人工智能·深度学习·macos·ai·语音识别·ai编程