首页 / 资讯中心 / 文章详情

前端AI对话Demo搭建:五分钟跑通浏览器聊天应用

前端AI对话Demo搭建:五分钟跑通浏览器聊天应用 ★ FEATURED ARTICLE
最近好几个做前端的朋友都在问同一个问题怎么让页面里那个聊天框真正“开口说话”问的人多了我就把反复演示过的一套最小方案整理成了这篇文章。你可以把它理解成前端版的 Hello World只不过这次要打招呼的对象是 AI——一个五分钟就能在浏览器里跑起来的 AI 对话 Demo。这个 Demo 要解决的问题非常具体用最少的代码做一个有对话框、输入框、发送按钮并且能根据用户输入给出智能回复的网页应用。它适合三类人看一是刚接触前端、想看看 AI 能力怎么落到页面上的人二是后端同学想快速搭一个前端验证工具三是产品经理、独立开发者想做技术可行性调研。读完你至少能收获三样东西一个能直接跑的页面、一套可复用的对话逻辑思路、一批我在实操中踩过的坑。我先把话说在前面这个 Demo 的重点不是“模型能力多强”而是“链路有多完整”。前端界面怎么组织、请求怎么封装、数据怎么展示、错误怎么兜底这些才是你以后做任何 AI 应用都要反复面对的问题。下面按从易到难的顺序一步步把整个搭建过程拆开讲。1. 五分钟背后的设计逻辑先把边界划清楚为什么敢说五分钟因为这套方案从一开始就做了减法。很多人在做 Demo 时会忍不住把工程化那套全搬上来——脚手架、路由、状态管理、UI 组件库全上一遍结果光初始化项目就花掉半小时。我觉得可以换一个思路目标只是“跑通一个最小闭环”那越朴素的技术栈越接近这个目标。1.1 最小可运行方案的边界划定“五分钟”不是说从零开始到接入真实 AI 服务只要五分钟而是指“从空目录到看到一个能对话的页面”只需要五分钟。这里有一个很关键的心态调整第一版 Demo 的对话逻辑可以先用本地模拟数据兜底把前端界面和交互链路完全跑通之后再去接真实的大模型服务。这样你就把整个任务拆成了两个互不阻塞的阶段设计界面的时候不用等待后端连接后端的时候也不用反复改界面。我见过不少人一开始就纠结“用哪个大模型”“要不要做流式输出”“要不要支持多轮上下文”结果纠结完发现页面还没搭出来。Demo 阶段的核心指标只有一个用户发一句话页面能回一句话。先把这条主链路打通其他都是锦上添花。1.2 技术选型原生三件套为什么够用这个 Demo 我建议直接用 HTML、CSS、JavaScript 三个原生文件实现不引入任何框架和构建工具。原因很简单浏览器原生支持 ES Module一个 HTML 文件加两个 JS 文件就能组织逻辑CSS 写个聊天界面样式完全够用不需要组件库没有构建步骤双击 HTML 就能看到效果排除环境问题。有人可能会说React/Vue 才是主流学原生是不是走弯路我并不反对框架但你要清楚框架解决的是工程化、组件复用、状态同步这些规模化问题。在“一个页面、一段逻辑、一次对话”的 Demo 场景里框架带来的收益几乎为零反而会引入编译步骤和依赖安装这些额外变量。等你把原生版本跑通了再迁移到框架上是顺理成章的事。1.3 Demo 的三种运行模式与适用场景我在实际演示时会建议准备三种模式对应三个不同的使用场景模式实现方式适用场景本地模拟模式前端内置预设回复规则开发前期、演示 UI、离线调试直连服务模式前端直接请求大模型接口个人验证、内网工具、快速原型后端代理模式前端请求后端后端转发大模型请求生产环境、涉及密钥保护、多端复用三种模式并不冲突你可以把它理解成一条递进路线先用模拟模式跑通界面和交互再切到直连模式体验真实效果最后为了安全和稳定加上后端代理。每一层切换只需要改动一个小小的函数体这也是我为什么强调要把逻辑封装好——封装得越干净切换越无痛。2. 动手搭建对话界面从零到可运行的前端骨架有句话说得好先有界面才有感觉。一个空荡荡的页面会让人无从下手但一旦聊天窗口出现在屏幕上接下来的开发动力会完全不一样。所以第一步我建议先专心把界面做出来哪怕它暂时是“哑巴”。2.1 目录结构与基础页面框架先在任意目录下创建三个文件命名建议尽量直白hello-ai/ ├── index.html ├── style.css └── app.jsindex.html是骨架style.css负责外观app.js处理对话逻辑。先写 HTML 部分核心是三个区域顶部标题栏、中间消息列表、底部输入区。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleHello AI World/title link relstylesheet hrefstyle.css /head body div classchat-container header classchat-header h1AI 对话 Demo/h1 span idmode-badge classmode-badge本地模拟模式/span /header main idchat-messages classchat-messages/main footer classchat-input-area textarea iduser-input rows1 placeholder输入你的问题按 Enter 发送ShiftEnter 换行/textarea button idsend-button发送/button /footer /div script srcapp.js/script /body /html注意这里我把textarea而不是input作为用户输入组件原因是textarea可以支持换行后续做多行输入时体验更好。同时我在标题栏预留了一个mode-badge元素用来实时显示当前处于哪种对话模式调试的时候非常直观。2.2 布局、样式与交互的基础细节聊天界面的样式核心是让消息列表撑满中间区域并且底部输入区固定。这里有一个小小的布局技巧使用 Flex 容器从上到下排列消息列表设置flex: 1让它自动占据剩余高度同时设置overflow-y: auto处理长对话滚动。* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Microsoft YaHei, sans-serif; background: #f0f2f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; } .chat-container { width: 100%; max-width: 720px; height: 90vh; display: flex; flex-direction: column; background: #fff; border-radius: 16px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08); overflow: hidden; } .chat-header { padding: 16px 24px; background: #fff; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; font-size: 18px; } .mode-badge { font-size: 13px; padding: 4px 12px; border-radius: 20px; background: #f0f0f0; color: #666; } .chat-messages { flex: 1; overflow-y: auto; padding: 24px; background: #fafafa; display: flex; flex-direction: column; gap: 16px; } .message { max-width: 75%; padding: 12px 16px; border-radius: 14px; line-height: 1.6; font-size: 15px; word-break: break-word; white-space: pre-wrap; } .message.user { align-self: flex-end; background: #1989fa; color: #fff; border-bottom-right-radius: 4px; } .message.ai { align-self: flex-start; background: #fff; color: #333; border: 1px solid #eee; border-bottom-left-radius: 4px; } .chat-input-area { display: flex; gap: 12px; padding: 16px 24px; background: #fff; border-top: 1px solid #eee; } .chat-input-area textarea { flex: 1; resize: none; border: 1px solid #ddd; border-radius: 8px; padding: 10px 14px; font-size: 15px; line-height: 1.5; min-height: 42px; max-height: 120px; outline: none; transition: border-color 0.2s; } .chat-input-area textarea:focus { border-color: #1989fa; } .chat-input-area button { padding: 0 24px; border: none; border-radius: 8px; background: #1989fa; color: #fff; font-size: 15px; cursor: pointer; transition: background 0.2s; } .chat-input-area button:disabled { background: #ccc; cursor: not-allowed; }样式上我特意把用户消息和 AI 消息放在左右两边视觉区分明显后续调试时一眼就能认出消息流向。还有一种做法是统一在 AI 消息前加一个“AI”小头像但对于 Demo 阶段左右分栏已经足够清晰。2.3 消息渲染与事件绑定的核心逻辑页面骨架有了样式接下来写app.js的基本结构。先处理两件事渲染消息和绑定发送事件。const chatMessages document.getElementById(chat-messages); const userInput document.getElementById(user-input); const sendButton document.getElementById(send-button); const modeBadge document.getElementById(mode-badge); // 当前使用的对话模式 // 可选值local / direct / proxy let currentMode local; let conversationHistory []; function setMode(mode) { currentMode mode; const modeNames { local: 本地模拟模式, direct: 直连服务模式, proxy: 后端代理模式 }; modeBadge.textContent modeNames[mode]; } function appendMessage(content, sender) { const messageDiv document.createElement(div); messageDiv.className message ${sender}; messageDiv.textContent content; chatMessages.appendChild(messageDiv); chatMessages.scrollTop chatMessages.scrollHeight; return messageDiv; } function addToHistory(role, content) { conversationHistory.push({ role: role, content: content }); } async function handleSend() { const message userInput.value.trim(); if (!message) return; if (sendButton.disabled) return; // 清空输入框并把高度重置 userInput.value ; userInput.style.height auto; // 渲染用户消息并记录到历史 appendMessage(message, user); addToHistory(user, message); // 禁用发送按钮防止重复提交 sendButton.disabled true; // 等待 AI 回复 await getAIResponse(message); sendButton.disabled false; userInput.focus(); } sendButton.addEventListener(click, handleSend); userInput.addEventListener(keydown, (event) { if (event.key Enter !event.shiftKey) { event.preventDefault(); handleSend(); } }); // 输入框高度自适应内容增多时自动撑高 userInput.addEventListener(input, () { userInput.style.height auto; userInput.style.height userInput.scrollHeight px; }); // 初始加载时打印一条提示消息 appendMessage(你好我是你的 AI 对话 Demo。试试在下方输入一句话吧。, ai);这里有个细节值得说textarea的 Enter 键默认是换行但聊天应用的用户习惯是按 Enter 发送。所以我拦截了 Enter 键同时保留ShiftEnter作为换行这算是一个行业默认的交互标准。另一个细节是在发送前就禁用按钮避免用户在等待回复期间连续点击导致消息顺序错乱。2.4 界面验证在不接入任何服务的情况下先跑通完成上述代码后直接用浏览器打开index.html。这时候你会发现页面已经可以手动发送消息但点发送后会报错因为getAIResponse还没定义。不用担心这个错误恰恰说明前端链路已经通了——事件绑定、消息渲染、历史记录都已经正常运转。我通常会在这里做一个很简单的验证在控制台里手动调用appendMessage函数塞几条假消息进去确认滚动条能跟着内容自动滚到底部输入框高度能自适应变化。这些交互细节如果等接上真实服务之后再排查会混进太多变量现在一次性确认清楚要省很多事。3. 让 AI“开口应答”对话逻辑的三层演进方案界面跑通了现在进入最核心的部分getAIResponse函数怎么实现。我把它拆成三个演进版本每个版本解决不同阶段的问题你完全可以按自己的需求跳到对应版本。3.1 模式一本地兜底模拟零依赖验证完整链路第一种实现方式最简单不需要任何外部服务前端内置一套关键词和回复规则。虽然它不会产生真正的智能但完整保留了“用户输入—处理—回复”的链路而且在没有网络的环境里也能演示 UI 效果。async function getAIResponse(userMessage) { // 模拟网络延迟让交互更接近真实 await new Promise(resolve setTimeout(resolve, 500)); let reply ; if (userMessage.includes(你好) || userMessage.includes(hello)) { reply 你好呀我可以帮你回答一些简单的问题。; } else if (userMessage.includes(时间)) { reply 现在是 new Date().toLocaleTimeString(zh-CN); } else if (userMessage.includes(名字)) { reply 你可以叫我 AI Demo一个很初级的聊天机器人。; } else { reply 这个问题我还在学习中推荐你试试问“时间”或者“名字”。; } appendMessage(reply, ai); addToHistory(assistant, reply); }你可能会担心这个模拟逻辑会不会和真实 AI 差距太大导致后续切换麻烦其实我在这里用了一个技巧getAIResponse函数体内的实现对于上层调用方来说是黑盒。不管里面是关键词匹配、调用接口还是走代理handleSend里面那句await getAIResponse(message)永远不需要改动。这就是封装的价值。在本地模式里我还刻意做了两件小事一是用setTimeout延迟 500 毫秒让界面有一个“思考”的过程否则回复太快反而不像一个真实对话工具二是把回复策略写成关键词命中这样你可以快速理解对话逻辑是如何分流的后续接真实模型时同样需要维护一层回复逻辑跳转。3.2 模式二接入大模型 API 服务体验真实 AI 对话本地模式跑通之后第二步建议切到真实的大模型服务。不同服务商的接口格式大同小异核心思路都是把用户消息组装成请求体发出去再把返回的文本解析出来渲染到页面上。async function getAIResponse(userMessage) { const API_ENDPOINT https://your-api-endpoint.example.com/v1/chat/completions; // 注意生产环境永远不要在前端暴露密钥这里仅为本地调试 const API_KEY your-api-key; try { const response await fetch(API_ENDPOINT, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer API_KEY }, body: JSON.stringify({ model: your-model-name, messages: [ { role: system, content: 你是一个友好的中文对话助手。 }, ...conversationHistory.slice(-10), { role: user, content: userMessage } ] }) }); if (!response.ok) { throw new Error(请求失败 response.status); } const data await response.json(); const reply data.choices[0].message.content; appendMessage(reply, ai); addToHistory(assistant, reply); } catch (error) { console.error(AI 请求出错了, error); appendMessage(抱歉我遇到了一些问题请稍后再试。, ai); } }这段代码里有几个需要注意的地方。一个是conversationHistory.slice(-10)会话历史最多保留最近 10 条避免请求体无限膨胀。另一个是choices[0].message.content这个结构——不同服务商返回的数据结构可能略有差异我建议第一次接入时先在浏览器控制台把data对象完整打印出来确认字段名后再写解析逻辑省得猜。这里要特别提醒一个安全红线直接在前端写 API Key 只适合本地调试一旦部署到公网任何人打开浏览器控制台都能看到你的密钥。真实的项目一定要用后端代理模式下一节会细说。3.3 模式三自建后端代理业务安全的正确姿势直连模式能做 Demo但离生产还差一层。核心问题有三个密钥暴露、跨域限制、请求封转不够灵活。后端代理就是把这层补上前端只跟自己的后端通信由后端负责携带密钥访问大模型服务。先给前端写一个代理模式的请求函数其实逻辑比直连更简单因为它只需要请求自己的服务async function getAIResponse(userMessage) { try { const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages: [ ...conversationHistory.slice(-10), { role: user, content: userMessage } ] }) }); if (!response.ok) { throw new Error(代理请求失败 response.status); } const data await response.json(); const reply data.reply; appendMessage(reply, ai); addToHistory(assistant, reply); } catch (error) { console.error(AI 请求出错了, error); appendMessage(抱歉我遇到了一些问题请稍后再试。, ai); } }后端的实现我以 Node.js 为例核心是从请求体里读出消息数组补上系统的提示语再以服务器身份向大模型服务发起请求const express require(express); const fetch require(node-fetch); const app express(); app.use(express.json()); app.post(/api/chat, async (req, res) { const messages req.body.messages || []; const finalMessages [ { role: system, content: 你是一个友好的中文对话助手。 }, ...messages.slice(-10) ]; try { const upstreamRes await fetch(https://your-api-endpoint.example.com/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer process.env.API_KEY }, body: JSON.stringify({ model: your-model-name, messages: finalMessages }) }); const data await upstreamRes.json(); res.json({ reply: data.choices[0].message.content }); } catch (error) { console.error(服务端请求失败, error); res.status(502).json({ error: AI 服务暂时不可用 }); } }); app.listen(3000, () { console.log(代理服务已启动http://localhost:3000); });到这里你可能发现了关键区别代理模式下密钥从app.js挪到了环境变量前端看不到前端与后端同源部署没有跨域问题后端可以统一做限流、缓存、日志这些都是生产环境必不可少的能力。3.4 三种模式的选型对照与切换思路三种模式我总结成一张对照表方便你理解什么时候该用哪种维度本地模拟直连服务后端代理接入成本零低中密钥安全无密钥暴露风险高安全网络依赖无依赖外部接口依赖后端服务真实效果差好好生产可用性否否是适用阶段原型设计功能验证上线发布我在切换模式时有一个实用的技巧把三种实现都留在app.js里用一个变量控制当前走哪套逻辑而不是删掉旧代码。这样在开发时可以随时通过setMode(local)切回模拟模式不受网络波动影响。等你要提交代码或打包上线时再把多余的模式清理掉做到一次切换、随时回滚。4. 跑通流程与调试复盘从五步启动到问题定位我把从零开始运行整个 Demo 的标准流程拆成五步按这个顺序操作可以最大限度避免“看着教程会自己动手废”的尴尬。4.1 五分钟启动清单第一步创建目录和三个文件代码先用本地模拟版本。第二步浏览器打开index.html确认页面渲染正常消息列表底部能看到 AI 的欢迎语。第三步输入一句话点发送确认用户消息出现在右侧500 毫秒后 AI 回复出现在左侧。第四步在控制台输入setMode(direct)把模式切到直连替换真实接口地址和密钥再发送消息验证真实回复。第五步如果你要测试代理模式启动 Node 服务把setMode(proxy)并且保证页面是通过http://localhost:3000访问的。每一步都要在验证通过后再进行下一步不要跳。很多朋友直接跳到第五步结果前端控制台报 CORS 错误或者 404其实问题往往出在前面的模式切换变量没有定义。严格按顺序走你可以准确知道问题出在哪个层。4.2 流式输出的处理思路如果你的服务商支持流式输出也就是一个字一个字蹦出来的那种效果处理思路会稍微不同。直连模式下可以用fetch配合ReadableStream读取数据流核心逻辑是边接收边把文本追加到消息节点里。我在 Demo 中不强制要求流式输出但它确实是提升体验的重要一环。一个比较简单的实现思路先创建一个空的 AI 消息节点然后监听数据流事件每收到一个片段就把它追加到节点的textContent后面。数据流的格式通常是服务端定义的特定行协议解析规则建议以服务商文档为准不要自己猜。需要留意的是流式模式下消息历史不能等全部流完再记录而是在流结束后一次性把完整文本写入历史否则中途用户刷新页面会把半截内容发到下一个请求里。4.3 调试三板斧控制台日志、断点、网络面板前端 AI 对话类应用的调试本质上是在回答三个问题请求发出去了没有请求到达服务端了没有返回的数据被正确解析了没有三个问题对应三种手段。第一板斧是控制台日志。在handleSend里打印用户消息在getAIResponse里打印即将发送的消息数组在解析处打印服务端返回的原始数据。三步日志打上去链路断在哪一目了然。第二板斧是浏览器开发者工具的 Network 面板。点击发送后看有没有新的网络请求产生请求的 Payload 对不对响应体是什么状态码是多少。我见过最多的问题就是 Payload 里的messages字段拼错了位置或者 Authorization 头没带对。第三板斧才是断点。断点适合在逻辑复杂、日志看不出来问题的时候用。建议在getAIResponse入口、response.json()之后打两个断点逐步看前端调试停留在看到正确答案的前一步。5. 高频问题速查与破解实录这部分整理的是我在实施这套方案时遇到的高频问题每一条都附了排查思路。为了避免你踩同样的坑我把它们做成了一张速查表。现象可能原因排查与解决点击发送没任何反应JS 报错、按钮被禁用、输入为空打开控制台看报错确认getAIResponse存在检查输入框 trim 后是否为空页面样式完全错乱CSS 文件路径错误或未引入在控制台 Network 面板确认style.css加载成功请求发出但收到 CORS 报错直连模式下前端跨域调用外部接口切到代理模式让后端转发请求控制台返回 400请求体格式不符合服务商要求查看响应错误详情比对messages数组结构和 model 名AI 回复出现“undefined”解析的 JSON 字段名不对打印完整data对象核对choices[0].message.content流式输出无法实时显示未正确处理分块事件或错误地使用了response.json()改用response.body.getReader()逐块读取刷新页面后聊天记录消失未持久化历史数据用localStorage临时存储会话历史或由后端持久化请求对方很慢且超时网络不稳定或服务端响应慢给fetch添加超时控制或考虑切换到更适合生产环境的连接方案其中有三个案例我印象最深值得展开说一说。第一个是密钥泄露的问题。有个同学把直连模式的页面部署到测试服务器后顺手把密钥地址也提交到代码仓库结果第二天账户就被盗刷了。任何一句“我只是内网跑一下”都不值得信任密钥不进前端代码是硬规则后端代理是唯一推荐的做法。第二个是历史上下文导致的异常。上一轮对话被循环叠加进下一轮的请求请求体越来越大最后超出服务商的单次请求大小限制。后来我在每次构建messages时都调用slice(-10)做截断问题直接消失。第三个是空输入提交。textarea的trim()只能去除两端空白但如果在中间输入空格、或者直接按 Enter 发送可能还是会把空消息发出去。我在handleSend入口加了一次空值校验并在发送按钮禁用状态下再次拦截这才确保不会出现空白气泡。6. 往产品级走功能增强、安全加固与经验沉淀Demo 跑通了功能能对话了接下来怎么把一个玩具变成真正能用的工具这里我给出三条我能明确验证的路径。6.1 交互体验的增强打字机效果与输入状态真实的大模型服务响应时间通常在 1 到 3 秒这期间如果页面毫无反馈用户会很焦虑。一个很小的改动是用“正在输入”的状态提示常见做法是在 AI 回复位置显示三点闪烁动画。我在迭代时发现相比加载动画更有真实观感的是打字机效果——AI 消息节点边接收流式数据边渲染用户能直观感受到内容在逐步生成等待变得可接受。实现打字机效果在 Demo 阶段不复杂核心有两个一是用一个setInterval或流式事件逐步追加文本二是处理光标位置和滚动到底部的时机。如果你用的是代理模式可以考虑让后端以流式返回把完整计算过程也搬到服务端。6.2 架构演进与安全加固的优先级从 Demo 到上线我建议按这个优先级逐步加固先用后端代理藏住密钥再给后端加请求频率限制然后给对话内容做内容审核和敏感词过滤最后才考虑多租户隔离和用量记账。前面两步是安全底线后面是业务需要。我的经验是不要一口气全做因为每增加一层抽象排查问题的成本都会成倍上升。前端这边也要注意别只顾着加功能。页面在移动端的适配、消息气泡长文本的阅读体验、历史记录较多时的渲染性能这些都会在实际使用中暴露出来。有一个性价比极高的优化把消息列表改成虚拟滚动只渲染可视区域内的节点长对话翻查时基本不卡。6.3 给新人的三条实操经验最后分享三条我从反复踩坑里总结出来的经验对新入门的朋友尤其有参考价值第一条先把一个最小链路完整跑起来再谈优化。很多人永远在处理“再优化一步”的路上Demo 却始终停留在静态页面阶段。先让页面说出第一句话这个里程碑比任何优化都重要。第二条学会用浏览器开发者工具排查问题这是我看前端新手最容易忽略却又最该掌握的能力。断点、Network、Application 三个面板没摸透遇到问题就只能盲猜。第三条接口返回的数据格式一定要先打印看真实结构再写解析代码。很多 AI 应用刚开始接入时报“undefined”都是因为项目文档写了一个字段名实际返回又是另一个字段名。眼见为实永远是对的。说实话这个 Demo 的代码量并不大但它是我反复演示过很多次的一套骨架。无论你最后是用原生三件套、还是迁移到 React/Vue、还是加上后端代理和大模型服务最核心的那条链路——用户输入消息、前端组装请求、远端返回文本、页面渲染展示——始终是这个结构。把这条链路吃透你就有了自己搭 AI 应用的第一块基石。
阅读完成 · 觉得有帮助?
咨询建站