我做前端这些年,接触过不少项目,其中一类特别有意思:在不接真实大模型接口的前提下,把与大模型对话的交互界面先完整做出来。这个活儿的核心关键词不是模型本身,而是“前端模拟”——你要在浏览器里还原出那种“流式输出、逐字蹦字、AI思考中”的感觉,让用户、产品、甚至自己都觉得这个对话系统已经能跑了。
这个场景常见得很。比赛演示、产品Demo、界面评审、教学课件、给客户的POC(概念验证),甚至是在真实接口还没到位的时候先定UI交互稿,全都需要这么一层“假”的交互层来兜底。但这个“假”比你想象中要难做得真,难做得像。很多人的第一版模拟界面,最后做成了“点击一下,弹出一整段废话”的静态页面,那完蛋了,感觉完全不对,产品看完会直接说这不是我想要的。真实的对话感,藏在细节里:思考状态的延迟、逐字输出的节奏感、滚动跟随的平滑度、多轮对话上下文的连续性。
这篇博文,我就拆一个我实际打磨过的“简易版”方案,完全平铺直叙地讲清楚怎么做,代码可以直接抄,偏往前端领域,但没接触过前端的朋友也能看懂个七七八八。
1. 为什么你极度需要“模拟”而不是直接接接口
先聊点务虚的,想清楚再动手,可以少走很多弯路。
1.1 真实业务场景里的三大痛点
很多人会问,不是有大模型API吗,为什么还要模拟?直接把前端代码接到模型接口上,它流式返回,打字机效果不就是现成的?理论上是这样,但实际开发中会遇到三个很扎心的问题。
第一,接口不稳定烧钱快。无论是国内还是国外的模型服务,大都按token计费。咱们前端同学一天要反复刷新页面几十次,每次刷新都要重新对话、重新发请求,如果是做界面调试,那不是在开发,是在烧钱。尤其是测试多轮对话和超长上下文时,一次请求可能就是几万个token,一天下来账单数字真不好看。
第二,环境依赖太强。开发环境、测试环境、演示现场,这三个环境的网络情况完全不同。现场演示最怕翻车,你要是接的是真实模型,一旦现场网络抖动、API限流,或者模型服务商临时抽风,你精心准备的演示环节就毁了。而模拟方案没有任何外部依赖,只要浏览器能打开HTML文件,它就能跑,还跑得稳稳当当。
第三,前后端进度脱节。在很多项目里,后端的智能体编排、知识库检索还没做完,前端这边界面已经排期到了。这时候你如果不做模拟层,前端只能干等,项目周期被拉长。有了模拟接口之后,前端可以完全独立开发,等后端就绪了,再把模拟数据源换成真实请求就行,前端的主体交互逻辑基本不用动。
我做过的几个项目里,有做企业知识库问答界面的,有做AI客服工作台的,有做智能体配置平台的,无一例外都走了“先模拟、后接入”的路子。这根本不是取巧,而是严谨的开发流程。模拟层不是为了骗人,而是为了把“交互体验”和“后端逻辑”解耦,让两边的开发可以并行。
1.2 模拟方案要模拟到什么程度
不是随便加个假接口就完事了。既然要“以假乱真”,你就得模拟出这几个核心环节:
- 多轮对话管理:用户发一条,模型回一条,再发再回,上下文要像真实对话一样累积。
- 思考状态的呈现:现在几乎所有主流的大模型对话产品,在模型返回之前,都会有一段“思考中”或“正在组织回复”的过渡。这个必须有,而且时间要随机,不能每句话都一样长。
- 流式输出的视觉表现:这是灵魂。真实的大模型接口基本都是流式返回,前端拿到一块就渲染一块,所以用户看到的是文字一个字一个字蹦出来的效果。模拟的时候,咱们把一整段文字切分成小片段,用定时器一个个释放,配合光标闪烁,视觉上完全一致。
- 交互状态管理:发送过程中按钮要禁用,防止重复提交;流式输出时不能允许用户插入新的问题打断节奏;对话消息区要自动滚动到最新消息。这些状态模拟做得越细,后面切换真实接口时就越省事。
想清楚模拟到哪个层次,再动手写,写出来的东西才不是“玩具”。
2. 整体设计与技术选型
2.1 技术栈:原生三件套居然最合适
做这种一体化交互页面,可选的技术栈太多了。Vue全家桶、React+State管理、甚至Next.js的流式渲染,都能做。但既然是“简易版”,我最后选了最朴素的原生HTML + CSS + JavaScript,直接一个单文件搞定。
原因有三。其一,零依赖意味着零构建成本,拿到代码就能跑,双击HTML文件就出效果,不需要npm install,不需要dev server。很多场地演示的时候,现场压根没网络,你总不能让观众等你安装依赖吧。其二,单文件方便分发。直接扔给同事,或者塞进PPT里,或者放在U盘里带走,都非常方便。其三,原生的用requestAnimationFrame和setInterval已经足够处理打字机效果了,杀鸡不必用牛刀。
当然了,如果你打算把这个模拟层做进正式项目里,后续要接真实API,那建议还是用框架封装成独立Service。我这篇文章先把最简版本讲透,最后再给出一条平滑迁移路径。
2.2 数据模型设计:消息列表是绝对核心
前端对话界面的本质,是维护一个消息列表,然后根据列表更新渲染。只要这个数据模型设计得好,后面怎么玩都顺手。
我采用的结构是这样:
const state = { messages: [], isStreaming: false, isWaiting: false }; // 单条消息 { id: 'msg_1720000000000_1', role: 'user' | 'assistant', content: '你好,帮我写一份周报', status: 'done' | 'waiting' | 'streaming', rawContent: '' }role用来区分左右气泡,content是最终展示的文字,status是这一条消息当前处在什么阶段,rawContent在消息还没结束时存的是已经输出部分的原始内容。
简单吗?非常简单。但就是这个简单的结构,支持了后面所有玩法:思考中状态、流式输出、清空对话、多轮上下文。
2.3 消息流状态机
把对话过程抽象成状态机,思路就清晰了。用户点击发送后,整个流程是这样的:
- 用户消息入列表,状态置为done,立即渲染出左侧气泡。
- 创建一条空的assistant消息,入列表,状态置为waiting,渲染右侧气泡并显示“思考中...”动画。
- 经过一个随机延迟(例如800到1500ms),将assistant消息状态改为streaming,启动定时器逐字追加内容。
- 每追加一小段内容,就更新DOM并滚动到底部。
- 全部追加完毕,状态改为done,收起打字光标,恢复输入框可用。
这个状态机想清楚之后,代码写起来就是顺着流程往下走,一点都不容易乱。这也是整个模拟层的骨架。
3. 核心环节拆解:模拟里最难的三块
这一整个模拟界面里,真正有技术含量并且需要抠细节的,就三块:流式输出、思考状态、滚动跟随。把这三块拿捏住了,产品经理在评审会上的表情就会从皱眉变成点头。
3.1 打字机流式输出,细节全在这
打字机效果的实现本身不复杂,就是定时器里不断往文本片段上追加字符。但要做到“像”,有几个点必须处理好。
第一,每帧追加的长度要适中且随机。如果你每个定时周期只追加一个字符,那就太慢了,一句几百字的话要蹦几十秒,用户早不耐烦了。最好的做法是,每次追加1到5个字符,偶尔追加一个标点符号带上后面的内容,模拟出那种“中文按词组蹦”的节奏。我的做法是维护一个队列,从剩余文本里随机截取一段长度,然后塞进DOM。
第二,光标要闪烁,并且跟文字自然衔接。真实产品在流式输出时,会有一个方块光标或者I型光标在文字末尾跳动。前端实现的核心是,用CSS动画处理光标的闪烁,并把光标元素紧跟在文本节点的后面。
第三,换行和代码块的保留。如果你模拟的回答里带了换行\u0016n或者Markdown符号,直接往innerHTML里塞是会被浏览器吞掉的。稳妥的做法是先把文本按换行拆成多个段落,每个段落独立渲染,段落之间用间距隔开。如果要支持简单的斜体、加粗,可以挂一个轻量的Markdown解析,简易版里我用白名单方式处理了加粗和换行两种语法。
第四,也是最容易被忽略的:流式追加时要批量操作DOM。如果你每追加一个字符就往DOM里插一次节点,性能会非常恐怖,尤其是长回答时页面会肉眼可见地卡顿。我的做法是计时器每100ms到200ms触发一次,每次把这一段时间攒下来的文本一次性写入DOM节点,用textContent更新,不做逐字符的createElement。
3.2 思考中状态,随机延迟才有灵魂
思考状态的视觉呈现,“思考中”这三个字配合呼吸灯式的动画就够用了。但要注意,暂停时长绝对不能固定,固定了就会显得非常假。真实大模型的响应时间跟问题难度强相关,简单问题一两秒就回来了,复杂问题可能思考小半分钟。
我的策略是分级随机延时:
- 简单问题(比如“在吗”“你好”),600ms到900ms。
- 普通问题(比如“写一段Python排序代码”),1000ms到1800ms。
- 复杂问题(比如“帮我分析这份数据并给出建议”),2000ms到3500ms。
前端怎么判断“问题难度”?关键词匹配。命中“分析”“总结”“对比”“方案”这些词,走长等待分支;命中“你好”“谢谢”“在吗”走短等待分支;其他情况走中间档。虽然这本质上是个粗略的映射,但观感上非常接近真实模型的行为。
3.3 自动滚动,不丝滑等于白做
聊天界面最影响体验的点,就是自动滚动。用户发出消息后,视角会停在输入框附近,这时候回答开始逐步输出,如果页面没有平滑地跟着滚动,用户就得手动往下拖,体验直接碎了。
自动滚动的核心是:每一次更新消息内容后,都要把滚动容器滚动到底部。但这里有个细节,如果每次都瞬间跳转(scrollTop=scrollHeight),连续蹦字时看起来就是页面一抖一抖的。平滑的做法是,在每次更新后延迟一帧(用requestAnimationFrame)再设置scrollTop,并且配合CSS的scroll-behavior: smooth,让动画有过渡感。
另外,如果用户自己向上滚动浏览之前的对话内容,自动滚动应该暂停,等用户回到底部再恢复。简易版里我加了个判断:如果用户滚动位置距离底部超过120px,则暂不强制滚到底。
4. 完整实现与代码详解
光说不练假把式,下面直接上一版能跑的完整代码。这是一个单文件的HTML页面,复制到本地就能运行。
4.1 核心代码:单文件模拟对话界面
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>大模型对话交互模拟-简易版</title> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: #f3f4f6; height: 100vh; display: flex; justify-content: center; align-items: center; } .chat-container { width: 480px; height: 720px; background: white; border-radius: 16px; box-shadow: 0 12px 40px rgba(0,0,0,0.08); display: flex; flex-direction: column; overflow: hidden; } .chat-header { padding: 16px 20px; background: white; border-bottom: 1px solid #e7e7e7; display: flex; justify-content: space-between; align-items: center; font-weight: 600; } .chat-main { flex: 1; padding: 20px; overflow-y: auto; background: #fafafa; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; } .msg-row { display: flex; max-width: 85%; } .msg-row.user { align-self: flex-end; } .msg-row.assistant { align-self: flex-start; } .msg-bubble { padding: 12px 14px; border-radius: 12px; line-height: 1.6; font-size: 14px; word-break: break-word; } .msg-row.user .msg-bubble { background: #2563eb; color: white; border-bottom-right-radius: 4px; } .msg-row.assistant .msg-bubble { background: white; border: 1px solid #e7e7e7; border-bottom-left-radius: 4px; } .thinking { display: inline-flex; align-items: center; gap: 4px; color: #888; font-size: 14px; } .dot { width: 6px; height: 6px; border-radius: 50%; background: #aaa; animation: breathe 1.2s infinite ease-in-out; } .dot:nth-child(2) { animation-delay: 0.2s; } .dot:nth-child(3) { animation-delay: 0.4s; } @keyframes breathe { 0%, 100% { opacity: 0.3; transform: translateY(0); } 50% { opacity: 1; transform: translateY(-3px); } } .cursor { display: inline-block; width: 2px; height: 16px; background: #2563eb; margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; } @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } .chat-input-area { padding: 14px 16px; border-top: 1px solid #e7e7e7; background: white; display: flex; gap: 10px; } #userInput { flex: 1; border: 1px solid #dcdcdc; border-radius: 20px; padding: 10px 16px; font-size: 14px; outline: none; transition: border-color 0.2s; } #userInput:focus { border-color: #2563eb; } #sendBtn { border: none; border-radius: 20px; padding: 10px 20px; background: #2563eb; color: white; font-size: 14px; cursor: pointer; transition: background 0.2s; white-space: nowrap; } #sendBtn:disabled, #sendBtn.disabled { background: #a5b8e8; cursor: not-allowed; } </style> </head> <body> <div class="chat-container"> <div class="chat-header"> <span>AI 助手(模拟版)</span> <button id="clearBtn" style="background:none;border:none;color:#888;cursor:pointer;font-size:14px;">清空</button> </div> <div class="chat-main" id="chatMain"> <div class="msg-row assistant"> <div class="msg-bubble">你好,我是AI助手。今天有什么可以帮你?</div> </div> </div> <div class="chat-input-area"> <input type="text" id="userInput" placeholder="输入你的问题..." autocomplete="off"> <button id="sendBtn">发送</button> </div> </div> <script> (function() { const chatMain = document.getElementById('chatMain'); const userInput = document.getElementById('userInput'); const sendBtn = document.getElementById('sendBtn'); const clearBtn = document.getElementById('clearBtn'); const state = { messages: [], isStreaming: false, isWaiting: false }; // ------------------- 模拟回答池 ------------------- const REPLY_DATASET = [ { keywords: ['你好', '在吗', 'hi', 'hello'], delayRange: [600, 900], reply: '你好呀!我在的,有什么想聊的吗?\n\n可以是技术问题、生活小事,也可以只是随便聊聊天。' }, { keywords: ['排序', '代码', '写个', '实现'], delayRange: [1000, 1800], reply: '给你一个简单的Python快速排序示例:\n\n```python\ndef quick_sort(arr):\n if len(arr) <= 1:\n return arr\n pivot = arr[len(arr) // 2]\n left = [x for x in arr if x < pivot]\n middle = [x for x in arr if x == pivot]\n right = [x for x in arr if x > pivot]\n return quick_sort(left) + middle + quick_sort(right)\n```\n\n这段代码的时间复杂度是 O(n log n),比较适合入门学习。' }, { keywords: ['分析', '总结', '对比', '方案', '建议'], delayRange: [2000, 3500], reply: '这个问题需要从多个维度来看。\n\n**第一,背景梳理**:任何建议都离不开具体场景,我的理解是你希望得到一套可落地的方案。\n\n**第二,关键因素**:建议重点关注可维护性和扩展性,优先选择社区成熟度高的载体。\n\n**第三,实操建议**:先做最小可用版本,验证核心路径,再逐步叠加后续能力。\n\n如果你能补充更多细节,我可以进一步细化。' }, { keywords: [], delayRange: [1000, 1800], reply: '收到你的问题,我理解了一下,大概是这么回事:\n\n核心点包括执行路径、预期效果和潜在风险。结合你已有的情况来看,当前最关键的是选择一条阻力最小的执行路径,先把主流程跑通,再在不断迭代中一点点修正。\n\n我给我的方案打分的话,能拿到75分,剩下的25分需要你补充更多上下文。' } ]; // 简单分词 + 关键词匹配 function pickReply(content) { const lower = content.toLowerCase(); for (let i = 0; i < REPLY_DATASET.length; i++) { const item = REPLY_DATASET[i]; if (item.keywords.some(kw => lower.includes(kw.toLowerCase()))) { return item; } } return REPLY_DATASET[REPLY_DATASET.length - 1]; } // 随机工具:闭区间 function getRandomRange(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } // ------------------- 渲染 ------------------- function createMsgNode(message) { const row = document.createElement('div'); row.className = 'msg-row ' + message.role; const bubble = document.createElement('div'); bubble.className = 'msg-bubble'; row.appendChild(bubble); return { row, bubble }; } // 把普通文本转成带加粗的HTML function formatContent(text) { return text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') .replace(/\n/g, '<br>'); } function appendUserMessage(content) { const message = { id: 'u_' + Date.now(), role: 'user', content, status: 'done' }; state.messages.push(message); const { row, bubble } = createMsgNode(message); bubble.innerHTML = formatContent(content); chatMain.appendChild(row); scrollToBottom(); return message; } function appendAssistantMessage() { const message = { id: 'a_' + Date.now(), role: 'assistant', content: '', status: 'waiting' }; state.messages.push(message); const { row, bubble } = createMsgNode(message); message.domRef = { row, bubble }; message.domRef.bubble.innerHTML = '<span class="thinking"><span class="dot"></span><span class="dot"></span><span class="dot"></span></span>'; chatMain.appendChild(row); scrollToBottom(); return message; } // ------------------- 流式输出 ------------------- function streamAssistantMessage(message, fullText, onDone) { message.raw = fullText; message.status = 'streaming'; const { bubble } = message.domRef; let index = 0; // 清空思考状态,准备打字机输出 bubble.innerHTML = ''; const timer = setInterval(() => { // 每次追加1-5个字符 const step = getRandomRange(1, 5); const nextIndex = Math.min(index + step, fullText.length); message.content = fullText.slice(0, nextIndex); index = nextIndex; // 更新DOM:支持加粗,光标放在最后 bubble.innerHTML = formatContent(message.content) + '<span class="cursor"></span>'; scrollToBottom(); if (index >= fullText.length) { clearInterval(timer); // 移除光标 bubble.innerHTML = formatContent(fullText); message.status = 'done'; if (onDone) onDone(); } }, 80); } // 滚动到底部 function scrollToBottom() { requestAnimationFrame(() => { chatMain.scrollTop = chatMain.scrollHeight; }); } // ------------------- 发送流程 ------------------- function sendMessage() { const content = userInput.value.trim(); if (!content || state.isStreaming || state.isWaiting) return; userInput.value = ''; appendUserMessage(content); // 思考等待期 const assistantMsg = appendAssistantMessage(); state.isWaiting = true; setSendBtnDisabled(true); const poolItem = pickReply(content); const waitTime = getRandomRange(poolItem.delayRange[0], poolItem.delayRange[1]); setTimeout(() => { state.isWaiting = false; state.isStreaming = true; streamAssistantMessage(assistantMsg, poolItem.reply, () => { state.isStreaming = false; setSendBtnDisabled(false); }); }, waitTime); } function setSendBtnDisabled(flag) { sendBtn.disabled = flag; if (flag) { sendBtn.classList.add('disabled'); } else { sendBtn.classList.remove('disabled'); } } function clearConversation() { state.messages = []; state.isStreaming = false; state.isWaiting = false; setSendBtnDisabled(false); chatMain.innerHTML = ''; appendAssistantMessage(); // 简单置为已回复 const firstMsg = state.messages[0]; firstMsg.status = 'done'; firstMsg.content = '你好,我是AI助手。今天有什么可以帮你?'; firstMsg.domRef.bubble.innerHTML = formatContent(firstMsg.content); } // ------------------- 事件绑定 ------------------- sendBtn.addEventListener('click', sendMessage); userInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); sendMessage(); } }); clearBtn.addEventListener('click', clearConversation); // 初始化欢迎语 appendAssistantMessage(); const fm = state.messages[0]; fm.status = 'done'; fm.content = '你好,我是AI助手。今天有什么可以帮你?'; fm.domRef.bubble.innerHTML = formatContent(fm.content); })(); </script> </body> </html>上面这段代码,直接复制成html文件用浏览器打开就能用。整体流程走下来,交互感受和市面上常见的大模型对话产品非常接近。
4.2 关键参数说明:打字机节奏和思考延迟为什么这么定
核心参数我在代码里都有注释,这里单独抽出来讲一下我调试多次后的心得。
第一,打字机定时器间隔设为80ms。这个值我试过30ms、50ms、80ms、120ms。30ms太急躁,字符是一个一个飙出来的,完全没有“正在打字”的感觉;120ms又太拖沓,一句话要憋很久。80ms配合每次随机追加1-5个字符,平均每个周期输出3个字符左右,也就是每秒差不多37个字符,中文场景下这个阅读节奏刚刚好,不快不慢。
第二,每次追加的字符长度随机,而不是固定。如果每次都固定追加3个字,再看文本输出,你会察觉出一种机械的规律感。随机1-5个字符之后,输出节奏有快有慢,像人在敲键盘一样,气口自然很多。随机算法我用的是Math.random()取整,虽然简单,但效果足够。
第三,思考等待时间设置了分级。这个细节很关键。如果你不管用户问什么都是固定等1.5秒,那短问题就会显得“反应迟钝”,复杂问题又显得“过于敷衍”。分级之后,感觉完全不同:你问“你好”,几乎秒回;你问“写个代码”,肉眼可见地停一下再输出;你问“分析一下数据”,停下思考的时间明显更多。真实模型也就是这个行为模式。
第四,requestAnimationFrame里做滚动。为什么不能用setTimeout?因为setTimeout的延迟不可控,尤其页面掉帧时会一顿一顿的。requestAnimationFrame跟浏览器渲染同步,保证每次内容更新后滚动都发生在渲染完成之前,视觉上非常丝滑。
4.3 带代码块的回答怎么处理
很多前端初学者模拟对话时,会遇到一个尴尬场景:回答内容里有代码块,结果渲染出来要么是代码消失,要么是三个反引号被原样显示出来,丑得没法看。
简易版里我做了非常克制的处理:只识别换行和加粗,代码块用pre标签包裹,但代码块内部的样式保持等宽字体和背景色。如果你把上面代码里的formatContent函数升级成真正支持Markdown的版本,完全也行。
但这里我有一个忠告:简易版里不要过早引入重型Markdown解析库。先用白名单方式渲染加粗和代码块,保留原始文本的能力,等真实接入大模型时再决定要不要上完整解析。否则模拟阶段的Markdown渲染逻辑和真实环境不一致,到头来还得返工。
5. 常见问题与排坑实录
做这个界面的时候,我遇到的坑大概有七八个,有些是新手很容易踩进去的,有些是我觉得简单但实际折腾了好一阵的,都列一下。
5.1 点击发送后按钮没反应
这个是最高频的问题。很多人的实现是:点击发送,立刻append用户消息,然后马上开始模拟回复。但因为append和发送之间没有做状态锁,用户在思考等待期里连点好几下,就append出了好几条用户消息和好几个assistant气泡,乱七八糟。
我处理的方式就是代码里的开锁逻辑:
- 发送前先检查state.isStreaming和state.isWaiting。
- 只要有一个状态是true,直接return。
- 思考等待期(isWaiting)和流式输出期(isStreaming)结束后,才把锁释放。
这样一个简单状态判断,就把重复提交的问题彻底掐死了。以后你接真实接口,这个锁同样适用,因为真实接口的流式返回期间,同样不允许用户插话。
5.2 打字机输出时页面卡顿
如果你按最初始的思路,每追加一个字符就用createElement创建一个新的文本节点,并且把这个节点append到bubble里,跑一句300字的回复,页面会创建几百个DOM节点,卡到飞起。
解决思路就是“批量更新”。我的做法是,每个定时器周期内一次性更新bubble.innerHTML,用字符串拼接,而不是循环创建节点。这种简单粗暴的innerHTML方式,性能比DOM节点逐个操作强很多。简易版完全够用。
如果你非要追求极限性能,可以用DocumentFragment,但innerHTML已经能让这个模拟界面跑得丝滑了。
5.3 流式打字中光标跑到了奇怪的位置
这个问题不常见,但一旦出现就很烦。原因是,当文本内容更新时,光标span如果紧挨着文字,没问题。但如果你把光标放在了别的容器里,或者bubble内部还有其他display:block的子元素,光标就会掉到下一行。
我的建议是,光标span保持inline-block样式,并且放在文本的最后,不要给它加任何外边距或宽度的极端值。我用的是width: 2px、height: 16px、vertical-align: text-bottom,实战下来没出过乱子。
5.4 用户输入框的回车与中文输入法冲突
当你绑定keydown事件时,在中文输入法下按回车是“确认选词”,而不是“发送消息”。如果你直接监听keydown后就调用sendMessage,会出现一个很尴尬的情况:用户还在选词,字还没上屏,消息就发出去了。
解法有两个。一是监听keydown时判断e.isComposing,如果为true则直接return。这是最直观的写法。二是监听keyup事件再判断。简易版里可以用第一种方法,但原生input的isComposing属性,部分浏览器需要兼容性处理。我这里为了演示简略,直接preventDefault了keydown,在正式项目里一定要加上isComposing判断。
5.5 自动滚动与用户手动滚动打架
如果你完全不做判断,用户往上翻历史消息时,只要新消息还在打字机输出,页面就会强行把滚动条拉回底部,用户根本没办法浏览上面的内容,体验极差。
我引入了一个简单策略:跟踪用户是否“靠近底部”。如果当前scrollTop + clientHeight >= scrollHeight - 120,才执行自动滚动;否则不滚动。这个120px的阈值是我试出来比较舒适的区间,太近了会导致用户轻微上滑就被拽回,太远了会导致用户明明没在底端,内容却一直卡住不动。
5.6 清空对话后的状态残留
清空对话如果只是把chatMain.innerHTML清空,很容易遗漏的是:state里isStreaming还锁着,下次发送直接被return掉,用户会以为程序坏了。
所以清空时的完整操作为:
- state.messages重置为空数组。
- isStreaming和isWaiting都置为false。
- sendBtn解除禁用。
- DOM里清空。
- 重新插入一条欢迎语。
状态重置和DOM清空缺一不可,而且顺序不能乱。上面代码里clearConversation函数就是标准写法。
5.7 浏览器本地直接打开时的跨域警告
很多场景下你会直接双击本地HTML文件来预览,如果你在这个文件里通过fetch请求外部媒体资源,比如图片、字体,控制台会报跨域错误。这个是浏览器安全策略,跟你的代码无关。
简易版模拟界面不存在这个问题,因为所有资源都在页面内部。但如果你参考这个demo,往里面加远程图标、远程字体,就需要考虑本地预览时是否会有CORS限制。我的建议是:演示用页面,所有静态资源一律本地化或者base64内置,省得出岔子。
为了让你排查时更快定位,我把几个常见问题的速查整理成表了:
| 症状 | 大概率原因 | 排查顺序 |
|---|---|---|
| 点击发送毫无反应 | 状态锁未释放 | 看isWaiting和isStreaming是否为true,检查是否卡在某个分支 |
| 光标不显示 | CSS动画未触发或光标被覆盖 | 检查.cursor样式,确认其为inline-block且z-index正常 |
| 打字机速度太快/太慢 | 定时器间隔不合适 | 调整setInterval的间隔值,从80ms起步微调 |
| 中文输入回车误发送 | 未判断isComposing | 在keydown里加上e.isComposing判断 |
| 页面很卡 | 大量DOM节点或频繁重排 | 优先改成innerHTML批量更新,减少createElement |
| 清空后不能聊天 | 状态未重置 | 重置isStreaming、isWaiting、messages,并恢复按钮 |
| 滚动不跟随 | 滚动调用时机太早 | 改为requestAnimationFrame后再设置scrollTop |
5.8 现场演示翻车自救指南
既然你花力气做了模拟界面,大概率是要拿去演示的。根据我的教训,给你三条现场保命建议。
第一,提前把页面用无痕模式打开。无痕模式下不会有浏览器缓存、扩展插件干扰,样式和布局最干净。某些浏览器的翻译插件、去广告插件会把页面搞得面目全非。
第二,展示前先把字体调大。如果现场是投屏,建议把浏览器缩放级别提高到120%,并把聊天界面的默认字号从14px调大到15或16px。远距离观看时,小字号会让人下意识觉得这界面很“廉价”。
第三,准备一个备用页面。如果现场不幸出现白屏、卡死、浏览器崩溃,你不用重新来,按一下热键直接打开一个纯静态的备用页面,只需要保留最基本的聊天UI和欢迎语就行。这个备用页我一般叫做“降级预案”,做一次能用很久。
6. 从模拟走向真实接入的平滑过渡方案
标题里写的是“简易版”,但我知道你迟早会往真实接口上切。这里给一个迁移路径,保证模拟阶段写好的交互代码不会白费。
6.1 把模拟数据源封装成独立Service
最理想的做法,是代码里所有的数据来源都走一个service层。比如你定义一个getAssistantReply(message)函数,模拟阶段它返回一个Promise,内部走定时器和打字机;真实阶段它换成fetch调用真实接口,返回ReadableStream,用await循环读取chunk并逐步渲染。
模拟阶段示例:
async function getAssistantReply(content, onChunk) { const poolItem = pickReply(content); const waitTime = getRandomRange(poolItem.delayRange[0], poolItem.delayRange[1]); await sleep(waitTime); // 逐段输出 const segs = splitTextToSegments(poolItem.reply); for (const seg of segs) { await sleep(80); onChunk(seg); } }真实阶段示例(伪代码):
async function getAssistantReply(content, onChunk) { const resp = await fetch('/api/chat', { method: 'POST', body: JSON.stringify({ messages: buildMessages(content) }) }); const reader = resp.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; onChunk(decoder.decode(value, { stream: true })); } }可以看到,整个页面的渲染逻辑、状态锁、滚动逻辑完全不变,只是数据源实现从“模拟延时”换成“真实流式”。这就是你花时间设计数据模型的最大收益。
6.2 简易版的边界要心里有数
这个简易版能覆盖90%的静态演示场景,但有几件事它做不到,需要你心里有数:
- 它不认识上下文。虽然UI上有历史消息,但模拟回答没有真正引用前文,不会根据上下文自动变化。真实大模型的上下文关联能力它自然是没有的。
- 它没有记忆和人格一致性。你问同一个问题两次,得到的回答完全一样,不会因为聊过之后改了风格而换说法。
- 它没有工具调用和知识检索。如果你的产品重点在“AI能查数据库”“AI能调用API”,那模拟层就得做得更厚,需要伪造一套工具调用的UI回显。
清楚边界,你就不会拿模拟版去骗人,也不会拿模拟版去应付真正的需求评审。模拟版就是模拟版,它是你的开发脚手架,不是最终交付物。这一点,做的时候要拎得清。
6.3 后续还能扩展的三个方向
顺着目前的代码往下走,值得立刻尝试的扩展有三个,难度递增,但每个都能让演示效果再上一个台阶。
一是多套角色语气包。模拟回答池里目前是“通用助手”的口吻,你可以再维护一份“毒舌”“严谨”“温柔”等语气模板,演示时切换角色设定,效果会很惊艳。
二是Markdown渲染升级。把formatContent函数换成按行解析,支持标题、列表、引用、表格,代码块高亮。接入真实接口后,这部分就能直接复用。
三是会话管理。支持多个会话的创建、切换、删除,每个会话独立维护messages数组。这是从“单页Demo”走向“正式产品”的关键一步,数据模型层面只需在state里加一个sessions对象就行了。
我在实际项目里,确实遇到过不少次“先把界面做好,后端接口还没影”的情况。早期我也不爱做模拟层,总觉得是“搞虚的”,后来被现实教育过几次才明白,前端交互的打磨本身就极其依赖一个稳定的、可控的、可重复测试的数据源。真实接口太不确定,你没法在上面稳定复现一个布局问题或者滚动问题。反而是模拟数据源,让你能精确控制每一轮对话的内容长度、延迟时长、输出节奏,这样才能把界面调到真的顺手。
这版简易模拟界面的代码,我后来又加了支持多轮上下文、代码高亮、会话管理的升级版,但核心的骨架和状态机逻辑,一直还是这版的样子。说句实话,想清楚状态,剩下的活儿都是体力活。希望这版拆解能让你少踩几个坑,直接把交互体验做到位。