【创新实训#07】大模型问答界面开发全流程 - Vue3+TypeScript实现
·
一、技术栈选择
前端核心
-
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";
}
}
魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。
更多推荐


所有评论(0)