恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
移动端AI聊天引擎实战:流式输出与界面适配全解析
首页
资讯中心
/
移动端AI聊天引擎实战:流式输出与界面适配全解析
移动端AI聊天引擎实战:流式输出与界面适配全解析
发布时间:2026/9/8 4:16:03
之前一直有朋友在问AI 聊天工具能不能做成手机端毕竟现在大家用手机的时间远远超过电脑想在通勤路上、午休时间随手打开一个页面就能和 AI 对话而不是非要坐到电脑前。最近我把这套免费 AI 聊天引擎的移动端版本完整做出来了聊天、流式输出、历史记录、手机自适应全部安排到位。这篇文章不废话直接把整个实现思路和核心代码分享出来想自己搭一个移动端 AI 聊天页面的朋友可以直接参考照着改就能用。1. 移动端 AI 聊天引擎它解决什么问题1.1 什么是 AI 聊天引擎AI 聊天引擎简单来说就是负责接收用户消息、调用大模型接口、再把模型返回内容展示给用户的整套逻辑。它不只是一个聊天框而是包含请求封装、流式数据处理、消息管理、异常重试、界面渲染等完整环节。我们平时看到的各类 AI 对话页面背后基本都有这样一个引擎在支撑。PC 端和手机端的聊天引擎在核心逻辑上是一致的但手机端有自己特有的约束屏幕宽度有限界面布局要重新设计。用户可能处于弱网环境请求超时和断线要处理。移动端浏览器对 SSEServer-Sent Events服务器推送事件的支持需要额外验证。输入法弹起、软键盘遮挡、底部安全区域等问题在 PC 端根本不存在。所以把 PC 端聊天页面直接压缩到手机屏幕上是行不通的必须单独做移动端适配。1.2 手机端 AI 聊天的常见应用场景个人知识问答工具把自己常用的提示词和知识库接进来随手查。团队内部 AI 助手企业微信、钉钉之外提供一个网页版移动入口。独立开发者的小产品把 AI 能力封装成 H5 页面嵌入到公众号或 App 的 WebView 中。学习与演示项目用开源大模型接口搭建一个移动端 Demo展示流式输出效果。不管哪种场景移动端 AI 聊天的核心链路是相同的用户输入 → 请求大模型 → 流式返回 → 渲染消息 → 维护会话上下文。1.3 为什么选择“免费引擎 自行开发界面”这条路线市面上的 AI 聊天 App 很多但如果想拥有完全可控的聊天界面、自定义提示词、私有化部署或者只是想学习整个聊天交互的实现原理自己开发仍然是性价比最高的路径。免费 AI 聊天引擎负责模型推理我们自己负责产品化界面、交互、数据存储、异常处理全部掌握在自己手里。本文的示例会围绕一个通用的移动端聊天页面展开核心代码使用原生 HTML、CSS、JavaScript 实现不需要安装任何前端框架复制下来就能跑。如果你后续要接入 Vue 或 React思路完全一致。2. 整体技术方案与功能规划2.1 技术栈选择移动端 AI 聊天引擎的技术栈可以很轻量。本文示例采用以下组合模块选型说明页面结构HTML5语义化标签移动端兼容性好样式CSS3 媒体查询实现响应式布局和深色模式交互逻辑原生 JavaScript避免引入框架降低上手门槛后端接口任意大模型 API需要支持流式输出SSE 或 WebSocket数据存储localStorage / IndexedDB保存聊天记录和会话列表部署方式Nginx / 静态托管页面本身是纯静态可以部署到任意服务器如果你的项目已经使用了 Vue 或 React完全可以把这套逻辑迁移过去。本文为了突出核心实现选择最直接的原生写法。2.2 功能清单聊天对话支持发送消息、接收 AI 回复。流式输出AI 回复逐字显示体验更接近真实对话。多会话管理可以新建会话、切换历史会话、删除会话。历史记录持久化刷新页面后聊天记录不丢失。移动端适配适配刘海屏、底部安全区、不同屏幕宽度。加载与异常状态请求中显示 Loading失败后支持重试。清空上下文一键清空当前会话上下文开始全新对话。2.3 消息流动过程整个聊天引擎的核心流程可以拆成下面几步用户在输入框输入内容点击发送。前端把用户消息添加到消息列表中渲染到页面。前端把当前会话的历史消息数组发送给后端接口。后端调用大模型通过流式方式逐步返回内容。前端接收流式数据每拿到一段内容就更新 AI 消息的显示区域。AI 回复结束后把完整内容保存到本地历史记录中。这里的第 4 步是关键。如果普通接口一次性返回用户需要等 5 到 10 秒才能看到完整回复体验很差。流式输出则可以在 1 秒内看到第一个字感知上会快很多。3. 移动端页面适配基础3.1 设置 Viewport移动端页面的第一步是设置 viewport 元信息否则页面会按照 PC 端宽度渲染出现横向滚动条。meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno, viewport-fitcover /几个关键参数widthdevice-width页面宽度等于设备宽度。initial-scale1.0初始缩放比例为 1。maximum-scale1.0和user-scalableno禁止用户缩放避免输入框聚焦时页面意外缩放。注意iOS 上user-scalableno在某些版本不一定生效所以还需要配合下面的 CSS。viewport-fitcover让页面覆盖到刘海屏的整个屏幕区域配合安全区域使用。3.2 禁止 iOS 输入缩放在 iOS Safari 中如果输入框字号小于 16px聚焦时页面会自动放大影响体验。解决办法有两个第一是输入框字号设置到 16px 或以上第二是使用如下 CSShtml, body { touch-action: manipulation; }设置touch-action: manipulation可以移除双击缩放和点击延迟是移动端页面很实用的一个样式。3.3 安全区域适配iPhone 的刘海屏和底部 Home Indicator 会遮挡页面内容。使用env(safe-area-inset-*)可以获取安全区域的尺寸.chat-footer { padding-bottom: env(safe-area-inset-bottom, 20px); }上面的写法表示如果浏览器支持安全区域环境变量就使用底部安全距离如果不支持则回退到 20px。这样输入框就不会被 iPhone 底部的横条遮挡了。3.4 100vh 的坑移动端开发中100vh并不等于浏览器可视区域高度。在 iOS Safari 上100vh会包含地址栏和工具栏的高度导致页面底部被截断。推荐使用100dvh动态视口高度它是当前浏览器可视区域的实际高度.chat-container { height: 100dvh; }如果浏览器不支持100dvh可以做一层回退.chat-container { height: 100vh; height: 100dvh; }这样旧浏览器使用100vh新浏览器使用更准确的100dvh体验会好很多。4. 对接免费 AI 聊天引擎4.1 接口选择原则要接一个聊天引擎首先得有可用的大模型 API。选择接口时重点关注以下几点是否支持流式输出。如果不支持流式体验会大打折扣。接口的兼容方式。尽量选择 OpenAI 兼容格式方便未来切换模型。免费额度和速率限制。免费接口通常有 RPM每分钟请求数限制要做并发控制。是否需要代理地址。部分模型需要配置代理才能访问部署时要考虑网络连通性。4.2 流式输出的基本原理流式输出最常见的方式是 SSE。客户端发起请求后服务端不关闭连接而是持续把数据以特定格式推送给客户端。数据格式大致如下data: {choices: [{delta: {content: 你}}]} data: {choices: [{delta: {content: 好}}]} data: [DONE]每一条消息以data:开头以空行结束。最后一条是data: [DONE]表示流式响应结束。在浏览器中可以使用fetch配合ReadableStream来读取流式数据。下面是核心代码片段这段代码需要放到前端项目的请求模块中async function chatStream(messages, onMessage, onDone, onError) { try { const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages: messages }) }); if (!response.ok) { throw new Error(HTTP response.status); } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // 按行解析 SSE 数据 const lines buffer.split(\n); buffer lines.pop(); // 最后一段可能不完整保留到下一轮 for (const line of lines) { const trimmed line.trim(); if (!trimmed.startsWith(data:)) continue; const data trimmed.substring(5).trim(); if (data [DONE]) { onDone(); return; } try { const json JSON.parse(data); const delta json.choices json.choices[0] json.choices[0].delta; const content delta delta.content ? delta.content : ; if (content) { onMessage(content); } } catch (e) { console.warn(解析数据失败:, data); } } } onDone(); } catch (err) { console.error(请求异常:, err); onError(err); } }代码说明response.body.getReader()获取响应体的读取器。TextDecoder负责把二进制流解码成文本{ stream: true }表示分块解码避免中文被截断产生乱码。buffer用来缓存不完整的行。网络分包可能导致一条data:数据被拆成两段必须等换行符出现后再解析。每解析出一段content就调用onMessage回调由外层更新页面。注意不同聊天引擎的 SSE 数据格式可能略有差异。有的直接返回纯文本有的返回 JSON 数组。接入时先在后端打印原始返回确认格式后再写解析逻辑。4.3 请求后端封装示例上面的代码调用的是/api/chat接口这里涉及一个问题浏览器直接请求大模型 API 会暴露 API Key存在安全风险。生产环境必须经过自己的后端转发。后端接口的职责包括接收前端传来的消息数组。把消息转发给大模型接口。隐藏 API Key 等敏感配置。控制访问频率和用户权限。后端可以自己实现也可以使用已有的开源网关项目。核心是浏览器永远只和后端通信不直接触碰模型密钥。5. 移动端聊天界面完整实战下面开始搭一个完整的移动端 AI 聊天页面。为了演示方便整体使用原生三件套文件结构如下ai-chat-mobile/ ├── index.html ├── css/ │ └── style.css ├── js/ │ ├── api.js # 请求与流式解析 │ ├── storage.js # 本地存储管理 │ └── app.js # 页面交互逻辑 └── server/ └── proxy.js # 后端转发代理Node.js 示例5.1 创建页面骨架新建index.html这是整个移动端聊天页面的骨架。页面从上到下分为三个区域头部标题栏、中间消息列表、底部输入栏。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno, viewport-fitcover / titleAI 聊天助手/title link relstylesheet hrefcss/style.css / /head body div classapp !-- 顶部标题栏 -- header classchat-header div classheader-left button idbtnNewChat classicon-btn title新建会话/button /div div classheader-titleAI 聊天助手/div div classheader-right button idbtnClear classicon-btn title清空上下文清/button /div /header !-- 消息列表 -- main classchat-body idchatBody div classempty-tip idemptyTip p输入消息开始对话/p /div /main !-- 底部输入栏 -- footer classchat-footer div classinput-wrapper textarea iduserInput rows1 placeholder请输入内容Enter 发送/textarea button idbtnSend classsend-btn发送/button /div /footer /div script srcjs/api.js/script script srcjs/storage.js/script script srcjs/app.js/script /body /html几点说明底部输入栏的rows1让 textarea 初始只有一行高度内容增多时再自动扩展。头部左侧的新建会话按钮和右侧的清空上下文按钮在移动端上比 PC 端更容易误触所以按钮尺寸要适当加大。5.2 编写移动端样式新建css/style.css核心是弹性布局、消息气泡、安全区域适配和深色模式。/* 全局重置 */ * { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; touch-action: manipulation; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; background-color: #f5f5f5; } .app { display: flex; flex-direction: column; height: 100vh; height: 100dvh; max-width: 600px; margin: 0 auto; } /* 顶部标题栏 */ .chat-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background-color: #ffffff; border-bottom: 1px solid #e5e5e5; flex-shrink: 0; } .header-title { font-size: 17px; font-weight: 600; } .icon-btn { width: 36px; height: 36px; border: none; border-radius: 8px; background-color: #f0f0f0; font-size: 16px; color: #333; cursor: pointer; } .icon-btn:active { background-color: #e0e0e0; } /* 消息区域 */ .chat-body { flex: 1; overflow-y: auto; padding: 16px; -webkit-overflow-scrolling: touch; } .empty-tip { height: 100%; display: flex; align-items: center; justify-content: center; color: #999; font-size: 15px; } /* 消息气泡 */ .message { display: flex; margin-bottom: 16px; } .message.user { justify-content: flex-end; } .message.assistant { justify-content: flex-start; } .bubble { max-width: 85%; padding: 12px 14px; border-radius: 16px; font-size: 15px; line-height: 1.6; word-break: break-word; white-space: pre-wrap; } .message.user .bubble { background-color: #007aff; color: #fff; border-bottom-right-radius: 4px; } .message.assistant .bubble { background-color: #fff; color: #333; border-bottom-left-radius: 4px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); } /* AI 回复中的打字光标 */ .stream-cursor { display: inline-block; width: 2px; height: 16px; margin-left: 2px; background-color: #007aff; vertical-align: text-bottom; animation: blink 0.8s infinite; } keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } /* 底部输入栏 */ .chat-footer { flex-shrink: 0; padding: 10px 12px; padding-bottom: calc(10px env(safe-area-inset-bottom, 0px)); background-color: #ffffff; border-top: 1px solid #e5e5e5; } .input-wrapper { display: flex; align-items: flex-end; gap: 10px; } #userInput { flex: 1; max-height: 120px; padding: 10px 14px; border: 1px solid #ddd; border-radius: 20px; font-size: 16px; font-family: inherit; line-height: 1.4; resize: none; outline: none; background-color: #f9f9f9; } #userInput:focus { border-color: #007aff; background-color: #fff; } .send-btn { flex-shrink: 0; height: 40px; padding: 0 18px; border: none; border-radius: 20px; background-color: #007aff; color: #fff; font-size: 15px; cursor: pointer; } .send-btn:disabled { opacity: 0.5; } /* 深色模式 */ media (prefers-color-scheme: dark) { body { background-color: #1c1c1e; } .chat-header, .chat-footer { background-color: #1c1c1e; border-color: #38383a; } .bubble, .message.assistant .bubble { background-color: #2c2c2e; color: #f2f2f7; box-shadow: none; } .icon-btn { background-color: #3a3a3c; color: #f2f2f7; } #userInput { background-color: #2c2c2e; border-color: #3a3a3c; color: #f2f2f7; } #userInput:focus { border-color: #0a84ff; background-color: #3a3a3c; } }重点解释height: 100vh; height: 100dvh;这组声明兼容新旧浏览器保证聊天页面正好铺满屏幕。.chat-body使用flex: 1占据中间剩余空间消息过多时内部滚动。用户消息靠右、蓝色背景AI 消息靠左、浅色背景是最通用的聊天布局。深色模式用prefers-color-scheme媒体查询实现用户系统切换深色模式时页面自动跟随。5.3 实现本地存储新建js/storage.js封装会话和消息的本地存储逻辑。const STORAGE_KEY ai_chat_sessions_v1; function loadSessions() { const raw localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : []; } function saveSessions(sessions) { localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions)); } function createSession() { return { id: s_ Date.now() _ Math.random().toString(36).slice(2, 8), title: 新会话, messages: [], createdAt: Date.now() }; } function getCurrentSession() { const sessions loadSessions(); return sessions.find(s s.id currentSessionId) || null; } function updateCurrentSession(messages) { const sessions loadSessions(); const index sessions.findIndex(s s.id currentSessionId); if (index ! -1) { sessions[index].messages messages; if (messages.length 0) { const firstUserMsg messages.find(m m.role user); if (firstUserMsg) { sessions[index].title firstUserMsg.content.slice(0, 20); } } saveSessions(sessions); } }这里有一个全局变量currentSessionId实际项目中可以把它封装到一个 Store 对象里避免污染全局命名空间。localStorage 的容量通常只有 5MB 左右对于纯文本聊天记录来说基本够用。如果后续要存图片、语音就需要换 IndexedDB 了。5.4 编写页面交互逻辑新建js/app.js这是聊天引擎的核心调度部分。let currentSessionId null; let isStreaming false; const chatBody document.getElementById(chatBody); const userInput document.getElementById(userInput); const btnSend document.getElementById(btnSend); const btnNewChat document.getElementById(btnNewChat); const btnClear document.getElementById(btnClear); // 初始化加载最后一个会话或创建一个新会话 function init() { const sessions loadSessions(); if (sessions.length 0) { currentSessionId sessions[sessions.length - 1].id; renderSession(); } else { newChat(); } } // 新建会话 function newChat() { if (isStreaming) return; const session createSession(); const sessions loadSessions(); sessions.push(session); saveSessions(sessions); currentSessionId session.id; chatBody.innerHTML div classempty-tipp输入消息开始对话/p/div; userInput.value ; userInput.focus(); } // 渲染当前会话的全部消息 function renderSession() { const session getCurrentSession(); if (!session) return; chatBody.innerHTML ; if (session.messages.length 0) { chatBody.innerHTML div classempty-tipp输入消息开始对话/p/div; return; } for (const msg of session.messages) { appendMessage(msg.role, msg.content); } scrollToBottom(); } // 追加一条消息到页面同时返回消息元素 function appendMessage(role, content) { const emptyTip document.getElementById(emptyTip); if (emptyTip) emptyTip.remove(); const wrapper document.createElement(div); wrapper.className message role; const bubble document.createElement(div); bubble.className bubble; bubble.textContent content; wrapper.appendChild(bubble); chatBody.appendChild(wrapper); scrollToBottom(); return bubble; } // 发送消息 async function handleSend() { const text userInput.value.trim(); if (!text || isStreaming) return; const session getCurrentSession(); if (!session) return; // 追加用户消息到页面和会话 appendMessage(user, text); session.messages.push({ role: user, content: text }); updateCurrentSession(session.messages); // 清空输入框并恢复高度 userInput.value ; userInput.style.height auto; // 创建 AI 消息气泡 const aiBubble appendMessage(assistant, ); const cursor document.createElement(span); cursor.className stream-cursor; aiBubble.appendChild(cursor); let fullContent ; isStreaming true; btnSend.disabled true; try { await chatStream( session.messages, // 每收到一段内容追加并更新显示 (delta) { fullContent delta; cursor.remove(); aiBubble.textContent fullContent; aiBubble.appendChild(cursor); scrollToBottom(); }, // 流式结束时 () { cursor.remove(); aiBubble.textContent fullContent; session.messages.push({ role: assistant, content: fullContent }); updateCurrentSession(session.messages); isStreaming false; btnSend.disabled false; }, // 请求失败时 (err) { cursor.remove(); aiBubble.textContent 请求失败 err.message 。请检查网络后重试。; isStreaming false; btnSend.disabled false; } ); } catch (e) { console.error(e); isStreaming false; btnSend.disabled false; } } // 滚动到底部 function scrollToBottom() { requestAnimationFrame(() { chatBody.scrollTop chatBody.scrollHeight; }); } // textarea 自动增高 userInput.addEventListener(input, () { userInput.style.height auto; userInput.style.height Math.min(userInput.scrollHeight, 120) px; }); // Enter 发送Shift Enter 换行 userInput.addEventListener(keydown, (e) { if (e.key Enter !e.shiftKey) { e.preventDefault(); handleSend(); } }); btnSend.addEventListener(click, handleSend); btnNewChat.addEventListener(click, newChat); btnClear.addEventListener(click, () { if (isStreaming) return; const session getCurrentSession(); if (session) { session.messages []; updateCurrentSession(session.messages); renderSession(); } }); init();这里的流式渲染思路是先创建一个空的 AI 气泡在气泡后追加一个光标元素表示“正在输入”每收到一段内容就把光标移除、更新文本、再把光标加回去。这样用户能看到类似打字机的效果体验上会觉得 AI 是在实时思考并输出。5.5 后端转发代理浏览器端不能直接持 API Key所以需要一个轻量后端做转发。下面是一个 Node.js 的 Express 代理示例思路可以迁移到任何后端语言。新建server/proxy.jsconst express require(express); const app express(); app.use(express.json()); const API_KEY process.env.LLM_API_KEY; // 从环境变量读取不要硬编码 const API_URL process.env.LLM_API_URL; // 大模型接口地址 app.post(/api/chat, async (req, res) { const { messages } req.body; if (!messages || !Array.isArray(messages)) { return res.status(400).json({ error: messages 参数必须是数组 }); } try { const upstream await fetch(API_URL, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer API_KEY }, body: JSON.stringify({ model: req.body.model || default, messages: messages, stream: true }) }); if (!upstream.ok) { const errText await upstream.text(); console.error(上游接口错误:, upstream.status, errText); return res.status(502).json({ error: 上游模型接口异常 }); } // 设置 SSE 响应头 res.setHeader(Content-Type, text/event-stream; charsetutf-8); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); // 把上游的流式数据转发给浏览器 const reader upstream.body.getReader(); const decoder new TextDecoder(utf-8); while (true) { const { done, value } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); res.write(chunk); } res.end(); } catch (err) { console.error(转发失败:, err); if (!res.headersSent) { res.status(500).json({ error: 服务器内部错误 }); } else { res.end(); } } }); const PORT process.env.PORT || 3000; app.listen(PORT, () { console.log(代理服务已启动: http://localhost: PORT); });后端代理需要安装依赖npm init -y npm install express node server/proxy.js注意不同大模型接口的请求体格式不是完全一样的。有的模型字段名是model有的需要传temperature等采样参数回复格式中delta.content的位置也可能不同。接新引擎时先用 curl 或后端日志打印原始返回再写对应的解析代码。5.6 运行验证启动后端代理后用浏览器访问前端页面。如果在 PC 上调试按 F12 打开开发者工具切换到手机模拟模式选择 iPhone 或 Android 设备预设刷新页面即可看到移动端效果。验证点包括页面宽度是否铺满手机屏幕没有横向滚动条。发送消息后 AI 是否逐字输出。刷新页面后聊天记录是否还在。新建会话后原会话是否保留。输入框聚焦时页面是否被异常放大。如果要把页面部署到真实手机访问可以用 Nginx 托管静态文件并把/api/路径反向代理到后端服务。server { listen 80; server_name your-domain.com; root /var/www/ai-chat-mobile; index index.html; location /api/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_buffering off; } }这里proxy_buffering off很重要。Nginx 默认会缓冲上游响应导致 SSE 流式数据无法及时推送到浏览器。同时还需要配置proxy_read_timeout避免长连接被中断。6. 常见问题与排查思路移动端 AI 聊天引擎开发中最容易遇到的问题集中在流式解析、布局适配和网络稳定性。下面整理一张问题排查表。问题现象常见原因解决思路流式输出完全没反应后端没有正确返回 SSE 格式用 curl 直接请求上游接口确认返回格式检查 Content-Type 是否为 text/event-stream中文乱码流式数据被截断解码不完整TextDecoder 要加{ stream: true }buffer 要保留到换行符出现后再解析页面底部被遮挡未适配安全区域使用env(safe-area-inset-bottom)检查viewport-fitcover是否设置点击发送按钮没反应接口报错被 catch 吞掉在 catch 中输出完整错误打开浏览器控制台 Network 面板看请求状态AI 回复突然中断网络不稳定或上游超时增加超时重试后端设置合理的proxy_read_timeout前端支持断点续传刷新后聊天记录丢失localStorage 未正确存取检查 JSON.stringify / JSON.parse 是否成对出现确认 key 是否写错Nginx 部署后流式失效proxy_buffering 未关闭添加proxy_buffering off把 read timeout 调大输入框聚焦时页面放大iOS 自动缩放输入框字号不要小于 16px使用touch-action: manipulation发送消息后页面整体跳动输入框高度变化导致布局重排textarea 高度变化后调用 scrollToBottom尽量使用 transform 做动画6.1 流式数据解析不完整这是最常见的问题。SSE 数据在网络上传输时一个完整的数据块可能被拆成多个分片。如果每次读取后直接按\n分割很可能会遇到一行的后半段还没到达的情况。解决方法就是在代码中维护一个buffer变量每次只解析到最后一个换行符之前的内容剩下的留到下一次读取时再拼接。这正是 4.2 节代码中buffer lines.pop()的作用。6.2 移动端弱网环境怎么处理移动网络的特点是延迟高、波动大。建议在前端加两个机制第一个是超时提示。如果请求发出后 30 秒没有收到首个字节提示用户网络可能不稳定。第二个是手动重试。流式中断后不要直接丢弃已生成的内容可以把已生成的内容追加到消息上下文中让 AI 从断点继续但这个逻辑比较复杂需要模型侧配合简单场景下直接让用户重新发送即可。6.3 多会话管理的边界使用 localStorage 管理会话时要注意几个细节会话数量过多会撑爆 localStorage。建议限制最多保留 50 个会话超出后删除最旧的。单条消息过长也会占用大量空间可以在保存前截断超长文本例如超过 10000 字只保留前 10000 字并追加“内容过长已截断”。会话 id 要保证唯一。使用Date.now()加随机数的方式可以避免同一毫秒内创建多个会话时 id 冲突。7. 最佳实践与工程建议7.1 配置管理密钥永远不要出现在前端这是最基础也是最重要的一条。API Key、模型地址、环境标识全部放到后端环境变量中使用process.env或其他密钥管理服务读取不要写死在代码里更不要放在前端 JavaScript 文件中。前端代码是公开的任何人打开浏览器控制台都能看到所有请求参数。密钥一旦泄露就会被盗刷造成经济损失。7.2 异常处理区分可重试与不可重试错误网络错误、上游超时属于可重试错误参数错误、认证失败属于不可重试错误。前端拿到错误后应该根据 HTTP 状态码区分处理401 / 403提示用户密钥无效或需要重新登录。429提示请求太频繁延迟几秒后重试。500 / 502 / 503提示服务繁忙稍后重试。不要对任何错误都无脑重试尤其是 4xx 错误重试只会白白消耗配额。7.3 性能优化减少不必要的渲染流式输出过程中每收到一个 delta 就更新一次 DOM聊天窗口消息多时会有明显性能开销。优化方向有两个第一节流更新。不必每个 delta 都更新 DOM可以每 50ms 合并一次最新内容再更新肉眼几乎察觉不到差异。第二虚拟列表。当历史消息超过 100 条时只渲染可视区域附近的消息滚动时动态创建和销毁 DOM 节点。7.4 安全边界输入输出过滤不要把用户输入直接渲染为 HTML。本文示例中使用bubble.textContent content而不是bubble.innerHTML content目的就是防止 XSS 注入。AI 生成的内容同样可能包含恶意脚本必须用textContent方式渲染或者在插入前做转义。如果后续要支持 Markdown 渲染也要选择成熟的 Markdown 解析库并且关闭 HTML 标签解析选项。7.5 用户体验细节发送按钮在请求期间置灰防止用户重复点击导致消息重复发送。输入框底部预留安全区域间距。流式输出过程中禁止切换会话否则当前会话的消息数组会被串到别的会话中。页面标题在收到新消息时可以通过修改document.title提示用户移动端浏览器在切后台时通常会有提示效果。7.6 移动端调试建议调试手机端页面推荐用真机加 Chrome DevTools 远程调试。Android 手机用 USB 连接电脑在 Chrome 地址栏输入chrome://inspect可以实时查看页面 DOM 和 Console。iPhone 需要使用 Safari 的“开发”菜单在 Mac 上调试。如果没有真机也可以用 F12 的设备模拟。但模拟器只能模拟尺寸不能完全模拟输入法弹起、弱网、来电中断等真实场景所以上线前建议至少在真机上完整走一遍核心流程。7.7 数据备份与合规聊天记录属于用户数据。如果产品面向真实用户需要考虑数据归属和隐私问题。本地存储方式适合个人工具和 Demo如果要做多端同步需要把消息存储到服务器数据库并增加用户登录体系。涉及用户数据时不要把用户聊天内容发送到不必要的第三方服务也不要在日志中完整打印消息内容尤其是手机号、地址等敏感信息。8. 下一步优化方向完成了基础的移动端 AI 聊天引擎后可以从以下几个方向继续迭代。8.1 支持 Markdown 渲染目前 AI 消息是纯文本显示。实际使用中AI 经常会返回代码块、表格、列表纯文本可读性很差。可以引入 Markdown 解析库并配合代码高亮插件让展示效果更接近专业 AI 产品。8.2 增加语音输入能力移动端的一个天然优势是语音输入。通过 Web Speech API 或第三方语音转文字服务可以让用户在输入框旁点击麦克风按钮直接说话识别结果自动填入输入框。这个功能对移动场景非常实用但要注意语音服务通常有免费额度限制。8.3 接入 Pinecone 等向量库做知识库问答如果想让 AI 回答基于你自己的资料而不是依赖模型内置知识就需要引入 RAG检索增强生成方案。先把文档切片并向量化存入向量数据库用户提问时先检索相关片段再把片段连同问题一起发给大模型。这套架构在手机端和 PC 端是通用的移动端只需要增加文件上传入口即可。8.4 离线能力与 PWA通过 Service Worker 可以缓存页面静态资源让用户弱网环境下也能打开聊天页面历史消息可以从本地读取。PWA 还支持“添加到主屏幕”体验接近原生 App不需要经过应用商店审核。8.5 多模型切换有些场景需要快速回复适合轻量模型有些场景需要深度推理适合更大参数量模型。可以在设置页增加模型选择功能让用户按需切换。切换模型时要注意不同模型的上下文窗口长度不同超出限制时要自动裁剪早期消息。9. 总结本文围绕免费 AI 聊天引擎的手机端实现完整讲解了从页面适配、流式接口解析、聊天界面开发到后端代理转发的全套流程。核心内容包括Viewport 与安全区域适配、SSE 流式数据解析的细节、基于原生三件套的聊天页面实现、localStorage 多会话管理以及移动端常见的坑和排查方法。如果你正在做自己的移动端 AI 产品可以从本文的示例代码起步先跑通一个最简单的聊天链路再逐步加上 Markdown 渲染、语音输入、知识库等增强功能。移动端 AI 聊天本质上不是多高深的技术但它涉及网络、布局、存储、体验多个环节每一个小细节都在决定用户最终的使用感受。建议把代码拉到本地跑一遍多换几台真机测试把流式输出和弱网场景调顺这个项目就算真正立住了。