Nanbeige 4.1-3B 实战:JavaScript前端实现实时对话交互界面
最近在折腾大模型应用,发现很多朋友把精力都放在了后端模型部署和调优上,前端界面往往就是简单套个模板,体验上总差点意思。其实,一个流畅、美观的交互界面,对于提升大模型产品的使用体验至关重要。今天,我就来分享一下如何用JavaScript,为像Nanbeige 4.1-3B这样的模型,亲手打造一个具备实时对话能力的Web前端界面。
这个前端界面不是简单的表单提交,而是基于WebSocket实现的双向实时通信。这意味着你输入问题后,模型的回答会像真正的聊天一样,一个字一个字地“流”出来,而不是等整个回答生成完再一次性显示。我们还会用上现代前端框架(比如Vue或React)来构建组件化的聊天界面,让代码更清晰、更好维护。最后,为了让模型生成的代码或格式文本更美观,我们还会集成Markdown渲染功能。
整个做下来,你会发现前端也能玩出很多花样,而且效果非常惊艳:对话响应延迟极低,聊天记录可以随时翻阅,甚至还能根据你的喜好切换界面主题。下面,我就带你一步步实现它。
1. 项目概览与技术选型
在动手写代码之前,我们先看看要做一个什么样的东西,以及用什么技术来做。
我们要构建的是一个单页面应用(SPA),核心是一个聊天窗口。你可以在底部的输入框打字,消息会立刻出现在聊天区域。当你按下发送,消息会通过WebSocket发送到后端(后端负责调用Nanbeige 4.1-3B模型),模型生成的回复会通过同一条WebSocket连接,以数据流的形式实时推送回来,并动态渲染在界面上。
为了达到这个效果,我们需要以下几项关键技术:
- 实时通信:WebSocket。这是实现双向、低延迟通信的基石,远超传统的HTTP轮询。
- 前端框架:Vue 3 或 React。它们能帮助我们以组件化的方式高效构建用户界面,管理应用状态。本文示例将主要使用Vue 3,因为其组合式API写起来非常直观。
- UI组件库:Element Plus (Vue) 或 Ant Design (React)。它们提供了丰富的预制组件(按钮、输入框、布局等),能让我们快速搭建出专业的界面,而不用从零开始写CSS。
- Markdown渲染:marked 和 highlight.js。
marked负责将模型返回的Markdown文本转换成HTML,highlight.js则为转换后的代码块提供语法高亮,让展示效果更佳。 - 样式与布局:Flexbox/Grid + CSS变量。用于实现自适应的聊天布局,并通过CSS变量轻松支持主题切换功能。
2. 构建核心聊天界面组件
界面是用户最直接感知的部分,我们先来搭建聊天窗口的骨架。这里以Vue 3为例,使用<script setup>语法。
首先,是聊天消息展示区域。我们需要一个容器来滚动展示所有历史消息和实时流入的新消息。
<!-- ChatWindow.vue --> <template> <div class="chat-container"> <div ref="messagesContainer" class="messages-area"> <!-- 遍历消息列表,渲染每条消息 --> <div v-for="(msg, index) in messages" :key="index" :class="['message-bubble', msg.role]"> <!-- 消息头像 --> <div class="avatar"> <span v-if="msg.role === 'user'">👤</span> <span v-else>🤖</span> </div> <!-- 消息内容 --> <div class="content"> <!-- 如果是AI回复,且内容为Markdown,则用v-html渲染转换后的HTML --> <div v-if="msg.role === 'assistant' && msg.isMarkdown" v-html="msg.content"></div> <!-- 否则直接以文本显示 --> <div v-else class="plain-text">{{ msg.content }}</div> <!-- 消息时间戳 --> <div class="timestamp">{{ msg.timestamp }}</div> </div> </div> <!-- 流式响应时的加载指示器 --> <div v-if="isStreaming" class="message-bubble assistant"> <div class="avatar">🤖</div> <div class="content"> <div class="streaming-placeholder"> <span class="dot"></span> <span class="dot"></span> <span class="dot"></span> </div> </div> </div> </div> </div> </template> <script setup> import { ref, watch, nextTick } from 'vue' // 消息列表,每条消息包含角色(user/assistant)、内容、时间戳和是否为Markdown的标记 const messages = ref([]) // 是否正在接收流式响应 const isStreaming = ref(false) // 消息容器的DOM引用,用于自动滚动到底部 const messagesContainer = ref(null) // 当消息列表变化时,自动滚动到底部以查看最新消息 watch(messages, () => { nextTick(() => { if (messagesContainer.value) { messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight } }) }, { deep: true }) </script> <style scoped> .chat-container { height: 70vh; display: flex; flex-direction: column; } .messages-area { flex: 1; overflow-y: auto; padding: 20px; background-color: var(--bg-color-secondary); } .message-bubble { display: flex; margin-bottom: 16px; max-width: 80%; } .message-bubble.user { margin-left: auto; flex-direction: row-reverse; } .avatar { width: 36px; height: 36px; border-radius: 50%; background-color: var(--bg-color-primary); display: flex; align-items: center; justify-content: center; margin: 0 12px; flex-shrink: 0; } .content { padding: 12px 16px; border-radius: 18px; background-color: var(--message-bg-user); color: var(--text-color-primary); } .message-bubble.assistant .content { background-color: var(--message-bg-assistant); } .timestamp { font-size: 0.75rem; color: var(--text-color-secondary); margin-top: 4px; text-align: right; } .streaming-placeholder { padding: 8px 0; } .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background-color: var(--text-color-secondary); margin: 0 2px; animation: pulse 1.5s infinite ease-in-out; } .dot:nth-child(2) { animation-delay: 0.2s; } .dot:nth-child(3) { animation-delay: 0.4s; } @keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } </style>接下来,是底部的输入区域组件,包含输入框和发送按钮。
<!-- InputArea.vue --> <template> <div class="input-container"> <div class="input-wrapper"> <textarea ref="textareaRef" v-model="inputText" placeholder="输入您的问题..." @keydown.enter.exact.prevent="handleSend" @input="autoResize" rows="1" /> <button :disabled="!inputText.trim() || isLoading" @click="handleSend"> {{ isLoading ? '发送中...' : '发送' }} </button> </div> <div class="hint">按 Enter 发送,Shift + Enter 换行</div> </div> </template> <script setup> import { ref } from 'vue' const inputText = ref('') const isLoading = ref(false) const textareaRef = ref(null) const emit = defineEmits(['send-message']) const handleSend = () => { if (!inputText.value.trim() || isLoading.value) return const textToSend = inputText.value.trim() emit('send-message', textToSend) inputText.value = '' resetTextareaHeight() } const autoResize = () => { const textarea = textareaRef.value if (textarea) { textarea.style.height = 'auto' textarea.style.height = textarea.scrollHeight + 'px' } } const resetTextareaHeight = () => { const textarea = textareaRef.value if (textarea) { textarea.style.height = 'auto' } } </script> <style scoped> .input-container { padding: 20px; border-top: 1px solid var(--border-color); } .input-wrapper { display: flex; gap: 12px; } textarea { flex: 1; padding: 12px 16px; border: 1px solid var(--border-color); border-radius: 8px; resize: none; font-family: inherit; font-size: 1rem; background-color: var(--bg-color-primary); color: var(--text-color-primary); outline: none; transition: border-color 0.3s; max-height: 120px; overflow-y: auto; } textarea:focus { border-color: var(--primary-color); } button { padding: 0 24px; background-color: var(--primary-color); color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; transition: background-color 0.3s; align-self: flex-end; height: 44px; } button:disabled { background-color: var(--bg-color-disabled); cursor: not-allowed; } .hint { font-size: 0.75rem; color: var(--text-color-secondary); margin-top: 8px; text-align: center; } </style>3. 实现WebSocket实时通信与流式响应
界面有了,现在我们来建立前后端通信的“桥梁”。WebSocket连接的管理和数据的处理是核心。
我们创建一个独立的Composable(Vue 3)或Hook(React)来管理WebSocket逻辑,使其与UI组件解耦。
// useWebSocket.js (Vue 3 Composable) import { ref, onUnmounted } from 'vue' export function useWebSocket(url) { const socket = ref(null) const isConnected = ref(false) const messageHistory = ref([]) // 可选,用于在Composable内存储消息 const connect = () => { if (socket.value?.readyState === WebSocket.OPEN) { console.log('WebSocket 已连接') return } const ws = new WebSocket(url) socket.value = ws ws.onopen = () => { console.log('WebSocket 连接成功') isConnected.value = true } ws.onmessage = (event) => { try { const data = JSON.parse(event.data) // 假设后端返回的数据结构为 { type: 'chunk' | 'end', content: string } handleIncomingData(data) } catch (error) { console.error('解析消息失败:', error, event.data) } } ws.onerror = (error) => { console.error('WebSocket 错误:', error) isConnected.value = false } ws.onclose = () => { console.log('WebSocket 连接关闭') isConnected.value = false } } const sendMessage = (message) => { if (socket.value?.readyState === WebSocket.OPEN) { // 发送消息给后端,格式需与后端API约定一致 socket.value.send(JSON.stringify({ message: message, // 可以附加其他参数,如流式控制 stream: true })) } else { console.error('WebSocket 未连接,无法发送消息') // 可以在这里触发重连或提示用户 } } const disconnect = () => { if (socket.value) { socket.value.close() socket.value = null } } // 清理函数 onUnmounted(() => { disconnect() }) return { socket, isConnected, messageHistory, connect, sendMessage, disconnect } } // 处理流入数据的函数(需要在父组件或Composable中实现具体逻辑) function handleIncomingData(data) { // 这个函数的具体实现取决于你的应用状态管理 // 例如,它可能更新一个全局的响应内容ref,或者触发一个事件 // console.log('收到数据:', data) }在父组件(如App.vue)中,我们整合状态和逻辑。这里的关键是处理流式响应:我们需要一个缓冲区来累积从WebSocket接收到的文本块,并实时更新UI。
<!-- App.vue --> <template> <div class="app" :class="currentTheme"> <ChatWindow :messages="chatMessages" :is-streaming="isWaitingForStream" /> <InputArea @send-message="onSendMessage" :is-loading="isWaitingForStream" /> <!-- 可以在这里放置连接状态指示器、主题切换按钮等 --> </div> </template> <script setup> import { ref, onMounted } from 'vue' import ChatWindow from './components/ChatWindow.vue' import InputArea from './components/InputArea.vue' import { useWebSocket } from './composables/useWebSocket' import { renderMarkdown } from './utils/markdownRenderer' // 假设的Markdown渲染工具 const { isConnected, connect, sendMessage } = useWebSocket('ws://your-backend-endpoint/chat') // 聊天消息列表 const chatMessages = ref([]) // 是否正在等待/接收流式响应 const isWaitingForStream = ref(false) // 当前流式响应的缓冲区 let currentStreamBuffer = '' // 当前流式响应对应的消息在chatMessages中的索引 let currentStreamingMessageIndex = -1 onMounted(() => { connect() }) const onSendMessage = async (text) => { if (!text.trim() || !isConnected.value) return // 1. 将用户消息添加到列表 const userMessage = { role: 'user', content: text, timestamp: new Date().toLocaleTimeString(), isMarkdown: false } chatMessages.value.push(userMessage) // 2. 添加一个初始的、空的AI消息占位符 const assistantMessage = { role: 'assistant', content: '', timestamp: '', isMarkdown: true // 假设AI回复默认用Markdown渲染 } chatMessages.value.push(assistantMessage) currentStreamingMessageIndex = chatMessages.value.length - 1 isWaitingForStream.value = true currentStreamBuffer = '' // 3. 通过WebSocket发送消息 sendMessage(text) // 注意:实际的流式数据接收处理在WebSocket的onmessage回调中 // 我们需要修改useWebSocket或在此处监听一个自定义事件/回调 } // 假设我们修改useWebSocket,使其能接受一个处理数据的回调 // 或者在App.vue中通过事件总线、provide/inject等方式接收数据 // 这里为了演示,我们模拟一个处理函数 const handleStreamChunk = (chunkData) => { if (chunkData.type === 'chunk') { // 累积文本块 currentStreamBuffer += chunkData.content // 更新对应的AI消息内容(这里可以简单更新,也可以做Markdown的渐进式渲染) chatMessages.value[currentStreamingMessageIndex].content = currentStreamBuffer // 更新时间戳为最后更新时间 chatMessages.value[currentStreamingMessageIndex].timestamp = new Date().toLocaleTimeString() } else if (chunkData.type === 'end') { // 流式响应结束 isWaitingForStream.value = false currentStreamingMessageIndex = -1 // 可以对最终内容进行一次完整的Markdown渲染(如果之前是渐进式渲染) if (currentStreamBuffer) { chatMessages.value[chatMessages.value.length - 1].content = renderMarkdown(currentStreamBuffer) } currentStreamBuffer = '' } } </script> <style> :root { --primary-color: #007bff; --bg-color-primary: #ffffff; --bg-color-secondary: #f8f9fa; --text-color-primary: #212529; --text-color-secondary: #6c757d; --border-color: #dee2e6; --message-bg-user: #007bff; --message-bg-assistant: #e9ecef; --bg-color-disabled: #cccccc; } .app.dark { --primary-color: #0d6efd; --bg-color-primary: #212529; --bg-color-secondary: #343a40; --text-color-primary: #f8f9fa; --text-color-secondary: #adb5bd; --border-color: #495057; --message-bg-user: #0d6efd; --message-bg-assistant: #495057; --bg-color-disabled: #6c757d; } .app { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: var(--bg-color-primary); color: var(--text-color-primary); min-height: 100vh; transition: background-color 0.3s, color 0.3s; } </style>4. 集成Markdown渲染与主题切换
模型回复常常包含代码块、列表、加粗等格式,用Markdown展示会友好得多。我们使用marked库进行解析,并用highlight.js高亮代码。
// utils/markdownRenderer.js import { marked } from 'marked' import hljs from 'highlight.js' import 'highlight.js/styles/github-dark.css' // 引入一个代码高亮样式 // 配置marked使用highlight.js进行代码高亮 marked.setOptions({ highlight: function(code, lang) { const language = hljs.getLanguage(lang) ? lang : 'plaintext' return hljs.highlight(code, { language }).value }, langPrefix: 'hljs language-', // 为代码块添加CSS类前缀 breaks: true, // 将换行符转换为<br> gfm: true, // 使用GitHub风格的Markdown }) // 自定义渲染器(可选),例如让链接在新标签页打开 const renderer = new marked.Renderer() renderer.link = function(href, title, text) { return `<a target="_blank" rel="noopener noreferrer" href="${href}" title="${title || ''}">${text}</a>` } export function renderMarkdown(markdownText) { if (!markdownText) return '' // 注意:直接将用户或模型生成的Markdown转换为HTML存在XSS风险。 // 在生产环境中,必须对输入进行严格的清理(sanitize)。 // 可以使用DOMPurify等库。 return marked.parse(markdownText, { renderer }) } // 在Vue组件中使用(需注意v-html的安全性) // <div v-if="msg.isMarkdown" v-html="renderMarkdown(msg.content)"></div> // 建议:将msg.content通过DOMPurify.sanitize(renderMarkdown(msg.content))处理后再用v-html绑定。主题切换功能则依赖于CSS变量。我们在根元素(:root)和主题类(如.dark)上定义两套变量,通过切换父容器(如.app)的类名来改变所有子元素的样式。
<!-- ThemeToggle.vue --> <template> <button class="theme-toggle" @click="toggleTheme"> {{ currentTheme === 'light' ? '🌙 深色模式' : '☀️ 浅色模式' }} </button> </template> <script setup> import { ref, onMounted } from 'vue' const currentTheme = ref('light') const toggleTheme = () => { currentTheme.value = currentTheme.value === 'light' ? 'dark' : 'light' document.documentElement.setAttribute('data-theme', currentTheme.value) // 同时更新App组件的主题类,以切换局部变量 // 这里需要通过事件或状态管理通知App组件,简单起见可以直接操作body或根元素类 document.body.className = `app ${currentTheme.value}` // 将主题偏好保存到localStorage localStorage.setItem('preferred-theme', currentTheme.value) } onMounted(() => { const savedTheme = localStorage.getItem('preferred-theme') || 'light' currentTheme.value = savedTheme document.documentElement.setAttribute('data-theme', savedTheme) document.body.className = `app ${savedTheme}` }) </script> <style scoped> .theme-toggle { position: fixed; top: 20px; right: 20px; padding: 8px 16px; border: 1px solid var(--border-color); background-color: var(--bg-color-primary); color: var(--text-color-primary); border-radius: 20px; cursor: pointer; font-size: 0.9rem; } </style>5. 效果展示与体验优化
把上面所有的部分组合起来,一个功能完整的实时对话前端就初具雏形了。实际运行起来,效果应该是这样的:
当你输入“用Python写一个快速排序函数”并发送后,聊天窗口会立刻显示你的问题。紧接着,AI的回复会开始逐字逐句地出现,而不是等待良久后一次性蹦出整段代码。在这个过程中,你会看到三个小圆点在有节奏地跳动,提示你模型正在“思考”和生成。最终,一段语法高亮、格式清晰的Python代码块会呈现在你面前。
为了获得更好的体验,我们还可以做一些优化:
- 历史记录持久化:使用
localStorage或IndexedDB在浏览器端保存对话记录,即使刷新页面也不会丢失。 - 连接稳定性:实现WebSocket的自动重连机制,在网络波动时尝试重新连接。
- 响应中断:在流式响应过程中,提供一个“停止生成”按钮,允许用户中断冗长的回复。
- 消息操作:为每条消息添加复制到剪贴板、重新生成等操作按钮。
- 移动端适配:通过响应式CSS确保在手机和平板上也有良好的操作体验。
整个项目搭建下来,你会发现用现代前端技术栈来包装大模型能力,不仅能极大提升用户体验,其本身也是一个非常有趣且富有挑战的过程。从WebSocket的实时流式推送到Markdown的动态渲染,从前端状态管理到UI/UX细节打磨,每一步都值得深入探索。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。