前端模拟大模型对话:流式输出与思考状态实现详解

发布时间:2026/10/16 10:57:18
前端模拟大模型对话:流式输出与思考状态实现详解 我做前端这些年接触过不少项目其中一类特别有意思在不接真实大模型接口的前提下把与大模型对话的交互界面先完整做出来。这个活儿的核心关键词不是模型本身而是“前端模拟”——你要在浏览器里还原出那种“流式输出、逐字蹦字、AI思考中”的感觉让用户、产品、甚至自己都觉得这个对话系统已经能跑了。这个场景常见得很。比赛演示、产品Demo、界面评审、教学课件、给客户的POC概念验证甚至是在真实接口还没到位的时候先定UI交互稿全都需要这么一层“假”的交互层来兜底。但这个“假”比你想象中要难做得真难做得像。很多人的第一版模拟界面最后做成了“点击一下弹出一整段废话”的静态页面那完蛋了感觉完全不对产品看完会直接说这不是我想要的。真实的对话感藏在细节里思考状态的延迟、逐字输出的节奏感、滚动跟随的平滑度、多轮对话上下文的连续性。这篇博文我就拆一个我实际打磨过的“简易版”方案完全平铺直叙地讲清楚怎么做代码可以直接抄偏往前端领域但没接触过前端的朋友也能看懂个七七八八。1. 为什么你极度需要“模拟”而不是直接接接口先聊点务虚的想清楚再动手可以少走很多弯路。1.1 真实业务场景里的三大痛点很多人会问不是有大模型API吗为什么还要模拟直接把前端代码接到模型接口上它流式返回打字机效果不就是现成的理论上是这样但实际开发中会遇到三个很扎心的问题。第一接口不稳定烧钱快。无论是国内还是国外的模型服务大都按token计费。咱们前端同学一天要反复刷新页面几十次每次刷新都要重新对话、重新发请求如果是做界面调试那不是在开发是在烧钱。尤其是测试多轮对话和超长上下文时一次请求可能就是几万个token一天下来账单数字真不好看。第二环境依赖太强。开发环境、测试环境、演示现场这三个环境的网络情况完全不同。现场演示最怕翻车你要是接的是真实模型一旦现场网络抖动、API限流或者模型服务商临时抽风你精心准备的演示环节就毁了。而模拟方案没有任何外部依赖只要浏览器能打开HTML文件它就能跑还跑得稳稳当当。第三前后端进度脱节。在很多项目里后端的智能体编排、知识库检索还没做完前端这边界面已经排期到了。这时候你如果不做模拟层前端只能干等项目周期被拉长。有了模拟接口之后前端可以完全独立开发等后端就绪了再把模拟数据源换成真实请求就行前端的主体交互逻辑基本不用动。我做过的几个项目里有做企业知识库问答界面的有做AI客服工作台的有做智能体配置平台的无一例外都走了“先模拟、后接入”的路子。这根本不是取巧而是严谨的开发流程。模拟层不是为了骗人而是为了把“交互体验”和“后端逻辑”解耦让两边的开发可以并行。1.2 模拟方案要模拟到什么程度不是随便加个假接口就完事了。既然要“以假乱真”你就得模拟出这几个核心环节多轮对话管理用户发一条模型回一条再发再回上下文要像真实对话一样累积。思考状态的呈现现在几乎所有主流的大模型对话产品在模型返回之前都会有一段“思考中”或“正在组织回复”的过渡。这个必须有而且时间要随机不能每句话都一样长。流式输出的视觉表现这是灵魂。真实的大模型接口基本都是流式返回前端拿到一块就渲染一块所以用户看到的是文字一个字一个字蹦出来的效果。模拟的时候咱们把一整段文字切分成小片段用定时器一个个释放配合光标闪烁视觉上完全一致。交互状态管理发送过程中按钮要禁用防止重复提交流式输出时不能允许用户插入新的问题打断节奏对话消息区要自动滚动到最新消息。这些状态模拟做得越细后面切换真实接口时就越省事。想清楚模拟到哪个层次再动手写写出来的东西才不是“玩具”。2. 整体设计与技术选型2.1 技术栈原生三件套居然最合适做这种一体化交互页面可选的技术栈太多了。Vue全家桶、ReactState管理、甚至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 自动滚动不丝滑等于白做聊天界面最影响体验的点就是自动滚动。用户发出消息后视角会停在输入框附近这时候回答开始逐步输出如果页面没有平滑地跟着滚动用户就得手动往下拖体验直接碎了。自动滚动的核心是每一次更新消息内容后都要把滚动容器滚动到底部。但这里有个细节如果每次都瞬间跳转scrollTopscrollHeight连续蹦字时看起来就是页面一抖一抖的。平滑的做法是在每次更新后延迟一帧用requestAnimationFrame再设置scrollTop并且配合CSS的scroll-behavior: smooth让动画有过渡感。另外如果用户自己向上滚动浏览之前的对话内容自动滚动应该暂停等用户回到底部再恢复。简易版里我加了个判断如果用户滚动位置距离底部超过120px则暂不强制滚到底。4. 完整实现与代码详解光说不练假把式下面直接上一版能跑的完整代码。这是一个单文件的HTML页面复制到本地就能运行。4.1 核心代码单文件模拟对话界面!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.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 classchat-container div classchat-header spanAI 助手模拟版/span button idclearBtn stylebackground:none;border:none;color:#888;cursor:pointer;font-size:14px;清空/button /div div classchat-main idchatMain div classmsg-row assistant div classmsg-bubble你好我是AI助手。今天有什么可以帮你/div /div /div div classchat-input-area input typetext iduserInput placeholder输入你的问题... autocompleteoff button idsendBtn发送/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\npython\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 classthinkingspan classdot/spanspan classdot/spanspan classdot/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 classcursor/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后再设置scrollTop5.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对象就行了。我在实际项目里确实遇到过不少次“先把界面做好后端接口还没影”的情况。早期我也不爱做模拟层总觉得是“搞虚的”后来被现实教育过几次才明白前端交互的打磨本身就极其依赖一个稳定的、可控的、可重复测试的数据源。真实接口太不确定你没法在上面稳定复现一个布局问题或者滚动问题。反而是模拟数据源让你能精确控制每一轮对话的内容长度、延迟时长、输出节奏这样才能把界面调到真的顺手。这版简易模拟界面的代码我后来又加了支持多轮上下文、代码高亮、会话管理的升级版但核心的骨架和状态机逻辑一直还是这版的样子。说句实话想清楚状态剩下的活儿都是体力活。希望这版拆解能让你少踩几个坑直接把交互体验做到位。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询