Llava-v1.6-7b多模态聊天机器人:Vue3前端界面开发
1. 引言
想象一下,你有一个能“看懂”图片的聊天机器人。你上传一张照片,问它“图片里有什么?”,它不仅能告诉你“有一只猫”,还能描述出“这是一只橘猫,正趴在窗台上晒太阳”。这就是Llava-v1.6-7b这类多模态模型的能力——同时理解图像和文字。
但光有强大的后端模型还不够,用户需要一个直观、好用的界面来和它对话。这就是前端开发的价值所在。今天,我们就来聊聊怎么用Vue3为Llava-v1.6-7b搭建一个现代化的聊天界面。
这个教程适合有一定Vue3基础的开发者。如果你用过Vue3写过一些组件,了解基本的响应式数据、组件通信,那就没问题。我们会从零开始,一步步构建一个完整的聊天机器人前端,涵盖消息流处理、图片上传、响应渲染这些核心功能。跟着做下来,你不仅能得到一个可用的项目,还能掌握处理实时数据流、文件上传等常见前端挑战的思路。
2. 项目初始化与环境准备
开始之前,我们先确保开发环境都准备好了。整个过程很简单,跟着步骤走就行。
2.1 创建Vue3项目
首先,打开你的终端,找个合适的位置,运行下面这行命令来创建一个新的Vue3项目:
npm create vue@latest llava-chat-frontend创建过程中,命令行会问你几个问题,主要是选择要安装哪些功能。我建议这样选:
- TypeScript:选“是”,用TypeScript能让代码更可靠,减少运行时错误。
- JSX:选“否”,我们用模板语法就够了。
- Vue Router:选“是”,虽然我们这个单页应用可能用不上路由,但留着以后扩展方便。
- Pinia:选“是”,这是Vue官方推荐的状态管理库,比Vuex更简单好用。
- ESLint:选“是”,保持代码风格一致。
- Prettier:选“是”,自动格式化代码,省心。
选完之后,等它安装完依赖。然后进入项目目录,安装额外的UI库和工具:
cd llava-chat-frontend npm install element-plus @element-plus/icons-vue axios npm install -D sass这里我们选择了Element Plus作为UI组件库,它设计得不错,组件也丰富。axios用来发HTTP请求。Sass让我们能写更灵活的样式。
2.2 项目结构规划
项目创建好后,我们先规划一下目录结构,这样代码组织起来更清晰:
llava-chat-frontend/ ├── src/ │ ├── components/ │ │ ├── ChatWindow.vue # 聊天主窗口 │ │ ├── MessageList.vue # 消息列表展示 │ │ ├── InputArea.vue # 输入区域(文本+图片) │ │ └── ImageUploader.vue # 图片上传组件 │ ├── stores/ │ │ └── chat.ts # Pinia状态管理 │ ├── api/ │ │ └── llava.ts # API接口封装 │ ├── types/ │ │ └── index.ts # TypeScript类型定义 │ └── App.vue这个结构比较清晰,每个组件职责明确。状态管理用Pinia,API请求单独封装,类型定义也抽出来,维护起来方便。
2.3 基础配置
先来配置一下Element Plus。打开src/main.ts,改成下面这样:
import { createApp } from 'vue' import { createPinia } from 'pinia' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import * as ElementPlusIconsVue from '@element-plus/icons-vue' import App from './App.vue' import router from './router' const app = createApp(App) // 注册所有Element Plus图标 for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) } app.use(createPinia()) app.use(router) app.use(ElementPlus) app.mount('#app')然后简单改一下src/App.vue,先把路由视图放进去:
<template> <div id="app"> <router-view /> </div> </template> <style> #app { height: 100vh; margin: 0; padding: 0; } </style>现在运行npm run dev,应该能看到Vue的默认页面。环境准备就绪,我们可以开始写核心功能了。
3. 核心功能实现:聊天界面搭建
聊天机器人的界面,核心就是三块:显示消息的列表、输入文字和图片的区域、处理图片上传的组件。我们一个一个来。
3.1 定义数据类型
在写组件之前,我们先定义好要用到的数据类型。在src/types/index.ts里:
// 消息类型 export interface ChatMessage { id: string role: 'user' | 'assistant' content: string timestamp: Date imageUrl?: string // 用户消息可能包含图片 isLoading?: boolean // 是否正在生成响应 } // 图片上传状态 export interface ImageFile { file: File previewUrl: string uploadProgress: number } // API请求参数 export interface LlavaRequest { message: string image?: File conversation_history?: Array<{ role: string content: string }> }定义好类型,后面写代码的时候TypeScript会帮你检查,减少错误。
3.2 状态管理(Pinia)
聊天应用通常有多个组件需要共享状态,比如当前对话、消息列表、设置等。用Pinia管理这些状态很方便。在src/stores/chat.ts里:
import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { ChatMessage } from '@/types' export const useChatStore = defineStore('chat', () => { // 状态 const messages = ref<ChatMessage[]>([]) const currentImage = ref<File | null>(null) const isGenerating = ref(false) const apiEndpoint = ref('http://localhost:8000/api/chat') // 你的后端地址 // 计算属性 const lastMessage = computed(() => { return messages.value.length > 0 ? messages.value[messages.value.length - 1] : null }) const hasImage = computed(() => currentImage.value !== null) // 动作 const addMessage = (message: ChatMessage) => { messages.value.push(message) } const updateMessage = (id: string, updates: Partial<ChatMessage>) => { const index = messages.value.findIndex(msg => msg.id === id) if (index !== -1) { messages.value[index] = { ...messages.value[index], ...updates } } } const setCurrentImage = (image: File | null) => { currentImage.value = image } const clearConversation = () => { messages.value = [] currentImage.value = null } return { // 状态 messages, currentImage, isGenerating, apiEndpoint, // 计算属性 lastMessage, hasImage, // 动作 addMessage, updateMessage, setCurrentImage, clearConversation, } })这个store管理了所有聊天相关的状态。messages存对话记录,currentImage存当前上传的图片,isGenerating标记是否正在生成回复。还提供了一些修改状态的方法。
3.3 消息列表组件
消息列表负责展示对话历史。创建src/components/MessageList.vue:
<template> <div class="message-list" ref="listRef"> <div v-for="message in messages" :key="message.id" :class="['message-item', `message-${message.role}`]" > <!-- 用户消息 --> <div v-if="message.role === 'user'" class="user-message"> <div class="message-header"> <el-avatar size="small" class="user-avatar">你</el-avatar> <span class="message-time"> {{ formatTime(message.timestamp) }} </span> </div> <div class="message-content"> <!-- 如果有图片,显示图片 --> <div v-if="message.imageUrl" class="message-image"> <el-image :src="message.imageUrl" :preview-src-list="[message.imageUrl]" fit="cover" class="uploaded-image" /> </div> <!-- 文本内容 --> <div class="message-text" v-if="message.content"> {{ message.content }} </div> </div> </div> <!-- 助手消息 --> <div v-else class="assistant-message"> <div class="message-header"> <el-avatar size="small" class="assistant-avatar" :icon="ChatLineRound" /> <span class="message-time"> {{ formatTime(message.timestamp) }} </span> </div> <div class="message-content"> <!-- 加载状态 --> <div v-if="message.isLoading" class="loading-indicator"> <el-icon class="is-loading"><Loading /></el-icon> <span>正在思考中...</span> </div> <!-- 正常内容 --> <div v-else class="message-text" v-html="formatAssistantResponse(message.content)"> </div> </div> </div> </div> </div> </template> <script setup lang="ts"> import { computed, ref, watch, nextTick } from 'vue' import { ChatLineRound, Loading } from '@element-plus/icons-vue' import type { ChatMessage } from '@/types' interface Props { messages: ChatMessage[] } const props = defineProps<Props>() const listRef = ref<HTMLElement>() // 格式化时间显示 const formatTime = (date: Date) => { return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) } // 格式化助手回复(简单处理换行) const formatAssistantResponse = (content: string) => { return content.replace(/\n/g, '<br>') } // 自动滚动到底部 watch(() => props.messages.length, async () => { await nextTick() if (listRef.value) { listRef.value.scrollTop = listRef.value.scrollHeight } }, { immediate: true }) </script> <style scoped lang="scss"> .message-list { flex: 1; overflow-y: auto; padding: 20px; background: #f5f5f5; .message-item { margin-bottom: 24px; &.message-user { text-align: right; } &.message-assistant { text-align: left; } } .message-header { display: flex; align-items: center; margin-bottom: 8px; .user-avatar { background: #409eff; margin-right: 8px; } .assistant-avatar { background: #67c23a; margin-right: 8px; } .message-time { font-size: 12px; color: #999; } } .message-content { .message-image { margin-bottom: 12px; .uploaded-image { max-width: 300px; max-height: 200px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } } .message-text { display: inline-block; max-width: 70%; padding: 12px 16px; border-radius: 12px; line-height: 1.5; word-break: break-word; .user-message & { background: #409eff; color: white; border-top-right-radius: 4px; } .assistant-message & { background: white; color: #333; border: 1px solid #e4e7ed; border-top-left-radius: 4px; } } .loading-indicator { display: flex; align-items: center; gap: 8px; color: #666; font-size: 14px; .el-icon { animation: rotating 2s linear infinite; } } } } @keyframes rotating { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } </style>这个组件有几个关键点:一是区分用户消息和助手消息的样式,二是支持显示图片,三是处理加载状态,四是自动滚动到最新消息。样式上用了Sass,写起来灵活一些。
3.4 图片上传组件
多模态聊天的核心功能之一就是上传图片。创建src/components/ImageUploader.vue:
<template> <div class="image-uploader"> <!-- 上传按钮 --> <el-upload v-if="!previewUrl" class="upload-area" action="#" :show-file-list="false" :before-upload="handleBeforeUpload" :http-request="handleUpload" accept="image/*" > <el-button type="primary" :icon="Picture"> 上传图片 </el-button> <div class="upload-tip"> 支持 JPG、PNG 格式,最大 5MB </div> </el-upload> <!-- 图片预览 --> <div v-else class="preview-area"> <div class="preview-image"> <el-image :src="previewUrl" fit="cover" class="preview-img" /> <div class="preview-actions"> <el-button type="danger" size="small" :icon="Delete" @click="handleRemove" > 移除 </el-button> <el-button type="primary" size="small" :icon="Refresh" @click="handleReplace" > 更换 </el-button> </div> </div> <!-- 上传进度 --> <div v-if="uploadProgress > 0 && uploadProgress < 100" class="upload-progress"> <el-progress :percentage="uploadProgress" :show-text="false" :stroke-width="4" /> <span class="progress-text">上传中... {{ uploadProgress }}%</span> </div> </div> </div> </template> <script setup lang="ts"> import { ref } from 'vue' import { Picture, Delete, Refresh } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus' interface Emits { (e: 'image-selected', file: File): void (e: 'image-removed'): void } const emit = defineEmits<Emits>() const previewUrl = ref<string>('') const uploadProgress = ref(0) // 上传前校验 const handleBeforeUpload = (file: File) => { const isImage = file.type.startsWith('image/') const isLt5M = file.size / 1024 / 1024 < 5 if (!isImage) { ElMessage.error('只能上传图片文件!') return false } if (!isLt5M) { ElMessage.error('图片大小不能超过 5MB!') return false } return true } // 处理上传(模拟上传过程) const handleUpload = async (options: any) => { const file = options.file // 生成预览URL previewUrl.value = URL.createObjectURL(file) // 模拟上传进度 uploadProgress.value = 0 const interval = setInterval(() => { uploadProgress.value += 10 if (uploadProgress.value >= 100) { clearInterval(interval) uploadProgress.value = 0 emit('image-selected', file) ElMessage.success('图片上传成功!') } }, 100) } // 移除图片 const handleRemove = () => { if (previewUrl.value) { URL.revokeObjectURL(previewUrl.value) previewUrl.value = '' } emit('image-removed') } // 更换图片 const handleReplace = () => { handleRemove() // 这里可以触发文件选择,简单实现就是移除后重新上传 } </script> <style scoped lang="scss"> .image-uploader { .upload-area { text-align: center; padding: 20px; border: 2px dashed #dcdfe6; border-radius: 8px; background: #fafafa; cursor: pointer; transition: border-color 0.3s; &:hover { border-color: #409eff; } .upload-tip { margin-top: 8px; font-size: 12px; color: #999; } } .preview-area { .preview-image { position: relative; .preview-img { width: 100%; max-height: 200px; border-radius: 8px; object-fit: cover; } .preview-actions { position: absolute; bottom: 10px; right: 10px; display: flex; gap: 8px; opacity: 0.8; &:hover { opacity: 1; } } } .upload-progress { margin-top: 12px; .progress-text { display: block; text-align: center; font-size: 12px; color: #666; margin-top: 4px; } } } } </style>这个组件实现了图片上传的基本流程:选择文件、预览、上传进度显示、移除和更换。用了Element Plus的上传组件,样式和交互都还不错。注意我们用了URL.createObjectURL来生成本地预览,实际项目中上传后应该用服务器返回的URL。
3.5 输入区域组件
输入区域把文本输入和图片上传组合在一起。创建src/components/InputArea.vue:
<template> <div class="input-area"> <!-- 图片上传区域 --> <div v-if="showImageUpload" class="image-section"> <ImageUploader @image-selected="handleImageSelected" @image-removed="handleImageRemoved" /> </div> <!-- 文本输入区域 --> <div class="text-input-section"> <div class="input-wrapper"> <el-input v-model="inputText" type="textarea" :rows="3" :maxlength="1000" placeholder="输入你的问题...(可以上传图片让AI分析)" resize="none" @keydown.enter.exact.prevent="handleSend" /> <div class="input-actions"> <!-- 图片上传按钮 --> <el-tooltip content="上传图片"> <el-button :type="hasImage ? 'primary' : 'default'" :icon="Picture" circle @click="toggleImageUpload" /> </el-tooltip> <!-- 发送按钮 --> <el-button type="primary" :icon="Promotion" :loading="isLoading" :disabled="!canSend" @click="handleSend" > 发送 </el-button> </div> </div> <!-- 操作提示 --> <div class="input-tips"> <span>按 Enter 发送,Shift + Enter 换行</span> <span v-if="hasImage" class="image-tip"> <el-icon><Picture /></el-icon> 已上传图片 </span> </div> </div> </div> </template> <script setup lang="ts"> import { ref, computed } from 'vue' import { Picture, Promotion } from '@element-plus/icons-vue' import ImageUploader from './ImageUploader.vue' interface Emits { (e: 'send', payload: { text: string; image?: File }): void } const emit = defineEmits<Emits>() // 状态 const inputText = ref('') const currentImage = ref<File | null>(null) const showImageUpload = ref(false) const isLoading = ref(false) // 计算属性 const hasImage = computed(() => currentImage.value !== null) const canSend = computed(() => { return inputText.value.trim().length > 0 || hasImage.value }) // 切换图片上传区域显示 const toggleImageUpload = () => { showImageUpload.value = !showImageUpload.value } // 处理图片选择 const handleImageSelected = (file: File) => { currentImage.value = file showImageUpload.value = false } // 处理图片移除 const handleImageRemoved = () => { currentImage.value = null } // 发送消息 const handleSend = async () => { if (!canSend.value) return isLoading.value = true try { await emit('send', { text: inputText.value.trim(), image: currentImage.value || undefined }) // 清空输入 inputText.value = '' currentImage.value = null } finally { isLoading.value = false } } </script> <style scoped lang="scss"> .input-area { border-top: 1px solid #e4e7ed; background: white; .image-section { padding: 16px; border-bottom: 1px solid #e4e7ed; } .text-input-section { padding: 16px; .input-wrapper { position: relative; .el-textarea { :deep(.el-textarea__inner) { padding-right: 120px; } } .input-actions { position: absolute; right: 8px; bottom: 8px; display: flex; gap: 8px; align-items: center; } } .input-tips { display: flex; justify-content: space-between; margin-top: 8px; font-size: 12px; color: #999; .image-tip { display: flex; align-items: center; gap: 4px; color: #409eff; } } } } </style>这个组件把前面的图片上传组件和文本输入框组合起来,提供了完整的输入体验。用户可以输入文字、上传图片,然后一键发送。界面布局考虑了各种状态,比如有图片时的提示、发送按钮的禁用状态等。
4. API集成与消息流处理
前端界面搭好了,接下来要让它能真正和后端对话。这涉及到API调用和消息流的处理。
4.1 API服务封装
首先,我们封装一个API服务。在src/api/llava.ts里:
import axios from 'axios' import type { LlavaRequest } from '@/types' // 创建axios实例 const api = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000', timeout: 30000, // 30秒超时 headers: { 'Content-Type': 'application/json' } }) // 请求拦截器 api.interceptors.request.use( (config) => { // 可以在这里添加token等 const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, (error) => { return Promise.reject(error) } ) // 响应拦截器 api.interceptors.response.use( (response) => response, (error) => { // 统一错误处理 if (error.response) { switch (error.response.status) { case 401: console.error('未授权,请重新登录') break case 403: console.error('权限不足') break case 500: console.error('服务器内部错误') break default: console.error('请求失败', error.response.data) } } else if (error.request) { console.error('网络错误,请检查网络连接') } else { console.error('请求配置错误', error.message) } return Promise.reject(error) } ) // Llava API export const llavaApi = { // 发送聊天消息 async sendMessage(request: LlavaRequest) { const formData = new FormData() formData.append('message', request.message) if (request.image) { formData.append('image', request.image) } if (request.conversation_history) { formData.append('conversation_history', JSON.stringify(request.conversation_history)) } return api.post('/api/chat', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) }, // 流式响应(如果后端支持) async sendMessageStream( request: LlavaRequest, onChunk: (chunk: string) => void, onComplete: () => void, onError: (error: any) => void ) { try { const formData = new FormData() formData.append('message', request.message) if (request.image) { formData.append('image', request.image) } if (request.conversation_history) { formData.append('conversation_history', JSON.stringify(request.conversation_history)) } const response = await fetch( `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'}/api/chat/stream`, { method: 'POST', body: formData, headers: { 'Accept': 'text/event-stream' } } ) if (!response.body) { throw new Error('响应体为空') } const reader = response.body.getReader() const decoder = new TextDecoder() while (true) { const { done, value } = await reader.read() if (done) { onComplete() break } const chunk = decoder.decode(value) onChunk(chunk) } } catch (error) { onError(error) } } } export default api这个API服务做了几件事:一是创建了axios实例并配置了基础设置,二是添加了请求和响应拦截器处理通用逻辑,三是封装了两种调用方式——普通请求和流式请求。流式请求用Fetch API实现,适合需要实时显示生成过程的场景。
4.2 聊天主窗口组件
现在把前面所有的组件组合起来,创建主聊天窗口。在src/components/ChatWindow.vue里:
<template> <div class="chat-window"> <!-- 头部 --> <div class="chat-header"> <div class="header-left"> <h2>Llava 多模态聊天助手</h2> <el-tag type="success" size="small">v1.6-7b</el-tag> </div> <div class="header-right"> <el-button type="info" :icon="Delete" size="small" @click="handleClear" :disabled="messages.length === 0" > 清空对话 </el-button> <el-button type="primary" :icon="Setting" size="small" @click="showSettings = true" > 设置 </el-button> </div> </div> <!-- 消息列表 --> <div class="chat-messages"> <MessageList :messages="messages" /> </div> <!-- 输入区域 --> <div class="chat-input"> <InputArea @send="handleSendMessage" :is-loading="isGenerating" /> </div> <!-- 设置对话框 --> <el-dialog v-model="showSettings" title="设置" width="500px" > <div class="settings-content"> <el-form label-width="100px"> <el-form-item label="API地址"> <el-input v-model="apiEndpoint" placeholder="http://localhost:8000" /> </el-form-item> <el-form-item label="响应模式"> <el-radio-group v-model="useStreaming"> <el-radio :label="true">流式响应</el-radio> <el-radio :label="false">完整响应</el-radio> </el-radio-group> </el-form-item> </el-form> </div> <template #footer> <el-button @click="showSettings = false">取消</el-button> <el-button type="primary" @click="saveSettings">保存</el-button> </template> </el-dialog> </div> </template> <script setup lang="ts"> import { ref, computed } from 'vue' import { Delete, Setting } from '@element-plus/icons-vue' import { ElMessage, ElMessageBox } from 'element-plus' import { useChatStore } from '@/stores/chat' import { llavaApi } from '@/api/llava' import MessageList from './MessageList.vue' import InputArea from './InputArea.vue' import type { ChatMessage } from '@/types' const chatStore = useChatStore() const showSettings = ref(false) const useStreaming = ref(true) // 计算属性 const messages = computed(() => chatStore.messages) const isGenerating = computed(() => chatStore.isGenerating) const apiEndpoint = computed({ get: () => chatStore.apiEndpoint, set: (value) => { chatStore.apiEndpoint = value } }) // 生成消息ID const generateMessageId = () => { return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` } // 发送消息 const handleSendMessage = async (payload: { text: string; image?: File }) => { if (!payload.text.trim() && !payload.image) return // 创建用户消息 const userMessage: ChatMessage = { id: generateMessageId(), role: 'user', content: payload.text, timestamp: new Date(), imageUrl: payload.image ? URL.createObjectURL(payload.image) : undefined } chatStore.addMessage(userMessage) // 创建助手消息(占位) const assistantMessage: ChatMessage = { id: generateMessageId(), role: 'assistant', content: '', timestamp: new Date(), isLoading: true } chatStore.addMessage(assistantMessage) chatStore.isGenerating = true try { // 准备请求数据 const request = { message: payload.text, image: payload.image, conversation_history: messages.value .filter(msg => !msg.isLoading) .map(msg => ({ role: msg.role, content: msg.content })) } if (useStreaming.value) { // 流式响应 let fullResponse = '' await llavaApi.sendMessageStream( request, (chunk) => { fullResponse += chunk chatStore.updateMessage(assistantMessage.id, { content: fullResponse, isLoading: false }) }, () => { chatStore.updateMessage(assistantMessage.id, { isLoading: false }) chatStore.isGenerating = false }, (error) => { console.error('流式请求失败:', error) chatStore.updateMessage(assistantMessage.id, { content: '抱歉,生成响应时出错了。', isLoading: false }) chatStore.isGenerating = false } ) } else { // 普通响应 const response = await llavaApi.sendMessage(request) chatStore.updateMessage(assistantMessage.id, { content: response.data.response, isLoading: false }) chatStore.isGenerating = false } } catch (error) { console.error('发送消息失败:', error) chatStore.updateMessage(assistantMessage.id, { content: '抱歉,请求失败,请检查网络连接或API设置。', isLoading: false }) chatStore.isGenerating = false } } // 清空对话 const handleClear = async () => { try { await ElMessageBox.confirm( '确定要清空所有对话记录吗?此操作不可撤销。', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' } ) chatStore.clearConversation() ElMessage.success('对话已清空') } catch { // 用户取消 } } // 保存设置 const saveSettings = () => { localStorage.setItem('llava_api_endpoint', apiEndpoint.value) localStorage.setItem('llava_use_streaming', useStreaming.value.toString()) showSettings.value = false ElMessage.success('设置已保存') } // 初始化设置 const initSettings = () => { const savedEndpoint = localStorage.getItem('llava_api_endpoint') const savedStreaming = localStorage.getItem('llava_use_streaming') if (savedEndpoint) { apiEndpoint.value = savedEndpoint } if (savedStreaming) { useStreaming.value = savedStreaming === 'true' } } // 初始化 initSettings() </script> <style scoped lang="scss"> .chat-window { display: flex; flex-direction: column; height: 100vh; background: white; .chat-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; border-bottom: 1px solid #e4e7ed; background: white; .header-left { display: flex; align-items: center; gap: 12px; h2 { margin: 0; font-size: 18px; font-weight: 600; color: #303133; } } .header-right { display: flex; gap: 8px; } } .chat-messages { flex: 1; overflow: hidden; min-height: 0; } .chat-input { border-top: 1px solid #e4e7ed; } .settings-content { .el-form-item { margin-bottom: 20px; } } } </style>这个组件是聊天应用的核心,它把消息列表、输入区域组合在一起,并处理了完整的聊天流程。关键功能包括:发送消息(支持流式和普通两种模式)、清空对话、保存设置等。界面布局采用经典的头部-消息区-输入区三栏结构。
4.3 路由配置
最后,配置一下路由,让应用能正常访问。修改src/router/index.ts:
import { createRouter, createWebHistory } from 'vue-router' import ChatWindow from '@/components/ChatWindow.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', name: 'chat', component: ChatWindow } ] }) export default router现在运行npm run dev,应该能看到完整的聊天界面了。
5. 优化与进阶功能
基础功能完成后,我们可以考虑一些优化和进阶功能,让应用更好用。
5.1 消息持久化
刷新页面后对话记录就没了,这体验不好。我们可以用浏览器的localStorage来保存对话。在src/stores/chat.ts里添加:
// 保存对话到本地存储 const saveToLocalStorage = () => { const data = { messages: messages.value.map(msg => ({ ...msg, timestamp: msg.timestamp.toISOString() })), lastUpdated: new Date().toISOString() } localStorage.setItem('llava_chat_history', JSON.stringify(data)) } // 从本地存储加载对话 const loadFromLocalStorage = () => { const saved = localStorage.getItem('llava_chat_history') if (saved) { try { const data = JSON.parse(saved) messages.value = data.messages.map((msg: any) => ({ ...msg, timestamp: new Date(msg.timestamp) })) } catch (error) { console.error('加载对话历史失败:', error) } } } // 在store返回时添加 return { // ... 其他状态和方法 saveToLocalStorage, loadFromLocalStorage }然后在ChatWindow.vue的onMounted里调用loadFromLocalStorage,在每次消息变化时调用saveToLocalStorage。
5.2 错误处理与重试
网络请求可能会失败,我们需要更好的错误处理和重试机制。在API服务里可以添加:
// 带重试的请求 async function requestWithRetry( requestFn: () => Promise<any>, maxRetries = 3, delay = 1000 ) { let lastError: any for (let i = 0; i < maxRetries; i++) { try { return await requestFn() } catch (error) { lastError = error console.log(`请求失败,第${i + 1}次重试...`) if (i < maxRetries - 1) { await new Promise(resolve => setTimeout(resolve, delay * (i + 1))) } } } throw lastError } // 使用示例 export const llavaApi = { async sendMessage(request: LlavaRequest) { return requestWithRetry(() => { const formData = new FormData() // ... 构建formData return api.post('/api/chat', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) }) } }5.3 性能优化
如果对话历史很长,渲染大量消息可能会影响性能。我们可以考虑虚拟滚动。虽然Element Plus没有直接提供虚拟滚动的列表组件,但我们可以用第三方库如vue-virtual-scroller:
npm install vue-virtual-scroller然后修改MessageList.vue:
<template> <RecycleScroller class="message-list" :items="messages" :item-size="100" key-field="id" > <template #default="{ item: message }"> <!-- 消息项内容 --> </template> </RecycleScroller> </template> <script setup> import { RecycleScroller } from 'vue-virtual-scroller' import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' // ... 其他代码 </script>5.4 主题切换
很多用户喜欢暗色主题。我们可以用CSS变量来实现主题切换:
/* 在全局样式中定义变量 */ :root { --bg-color: #ffffff; --text-color: #303133; --border-color: #e4e7ed; --primary-color: #409eff; } [data-theme="dark"] { --bg-color: #1a1a1a; --text-color: #e4e7ed; --border-color: #434343; --primary-color: #66b1ff; } /* 在组件中使用 */ .message-list { background: var(--bg-color); color: var(--text-color); border-color: var(--border-color); }然后在store里添加主题状态和切换方法。
6. 总结
到这里,一个功能完整的Llava-v1.6-7b多模态聊天机器人前端就搭建完成了。我们从项目初始化开始,一步步实现了消息列表、图片上传、输入交互、API集成等核心功能,还探讨了一些优化方向。
实际用下来,Vue3的组合式API配合TypeScript,开发体验确实不错。Element Plus的组件库也省了不少样式工作。整个项目结构清晰,组件职责明确,扩展起来也方便。
如果你要部署到生产环境,还有几点可以考虑:一是添加用户认证,二是优化图片上传(比如压缩、CDN),三是添加更多消息类型支持(比如文件、语音),四是实现多会话管理。不过对于大多数场景,现在这个版本已经够用了。
最关键的还是和后端的配合。确保你的Llava后端API能正确处理我们前端发送的请求格式,特别是多部分表单数据和流式响应。如果遇到问题,多看浏览器控制台的网络请求和响应,通常能找到线索。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。