
简介面向中高级前端开发者的一份Vue 3 AI聊天页面完整源码演示如何将大模型对话能力嵌入现代化Web界面。压缩包共79个文件以js、vue、json、png等类型为主同时包含完整.git版本库信息与hooks钩子配置加上组件、路由、store、静态资源等目录结构构成可直接运行的前端工程整体仅668KB轻量易读。已有1110人学习下载。源码中App.vue入口、独立组件与views页面分工清晰并预置babel.config及vue.config等工程化配置便于本地启动、改造或二次开发。通过阅读项目可掌握Vue 3组合式API在聊天场景下的组件通信、状态维护与消息流渲染思路还可在现有模板基础上替换接口、接入不同大模型服务适合希望快速落地AI对话前端界面的学习者用作参考模板。1. 基于Vue 3的AI聊天页面不是套个组件就完事接手第一个AI对话需求时很多团队的第一反应是找现成的聊天组件套上去。真做起来才发现AI聊天页面和普通表单页完全是两回事接口是流式的内容是边生成边展示的中途用户可能要停止、要重试、要追加提问这些动作同时作用于网络请求、消息状态和页面滚动。如果不在数据结构、SSE接入、增量渲染三个层面提前想清楚后面几乎必然返工。下面这套思路从消息模型设计讲到流式接口接入、打字机渲染和移动端适配每一节都能直接照抄参数和代码。适合准备自研对话界面的前端也适合用组件库做过一版但体验不达标的团队。2. 先定消息模型Vue 3里数据结构与状态管理怎么设计AI 聊天页面相比普通 CRUD 页面特殊在“消息是变化的”内容在变、状态在变、错误会插入、用户还能中止。如果从一开始就把消息设计成简单的字符串数组后面加状态、加错误、加会话历史时全都得返工。所以我建议动手写界面前先把消息的数据结构和状态管理方案定下来这部分决定了后面所有功能的开发速度。2.1 消息对象给每条内容留足扩展位实践中见过最典型的翻车现场是初始消息格式是[ { role: user, content: ... } ]等到要多会话、要错误提示、要附件时发现字段完全不够用。我一般这样定义消息类型// 消息类型定义基础字段之外给状态与错误预留位置 export type MessageRole user | assistant | system export interface ChatMessage { id: string // 客户端生成用作列表 key 和流式更新的定位 role: MessageRole // 谁说的用户 / AI / 系统提示 content: string // 展示文本流式过程中持续追加 status: pending | streaming | done | error // 驱动 UI 状态 createdAt: number // 时间戳 error?: string // 出错时给用户看的信息独立存放 attachments?: string[] // 预留图片、文件等 }逻辑说明这里最容易被忽略的是 status 字段。没有它你想实现“生成中”的禁用输入框、停止按钮、光标动画都得靠字符串长度猜非常脆弱。error 字段也建议独立不要把错误文本拼接进 content否则用户重试时要清掉错误文本还得截取字符串。参数说明id 用crypto.randomUUID()生成兼容性不足时再引入 uuid 库。不要用数组下标当 key流式追加时消息列表频繁更新用下标会导致 Vue 的 diff 错位出现内容串行。createdAt 在消息创建时写入一次不要在渲染函数里反复取Date.now()。2.2 用 ref 还是 Pinia聊天场景的状态管理选型先说结论单页面、单会话的场景用 ref 加自定义组合式函数足够会话列表、历史记录需要跨组件共享时再用 Pinia不要一上来就引入全局状态库。选型理由很简单聊天组件的消息流是高频率局部更新全局状态越分散越难排查。// 组件内消息列表最简单可靠的形态 import { ref } from vue import type { ChatMessage } from /types const messages refChatMessage[]([]) // 追加一条消息 function appendMessage(msg: ChatMessage) { messages.value.push(msg) } // 按 id 原地更新所有流式回调都走这一个入口 function updateContent(id: string, content: string, status: ChatMessage[status]) { const index messages.value.findIndex(m m.id id) if (index ! -1) { messages.value[index].content content messages.value[index].status status } }逻辑说明为什么用 ref 而不是 reactive因为后续要做消息替换、删除、数量裁剪ref 配合数组方法语义最清楚。updateContent 是全页面唯一的写入口排查“内容没更新”“状态不对”时只需检查这一处。参数说明findIndex 在消息量超过几百条时有性能损耗但聊天对话常见场景是几十条内不用提前优化。真要优化可以用 Map 维护 id 到索引的映射后面进阶章会提到。把消息提升到 Pinia 的时机是侧边栏会话列表、顶部标题栏、聊天主区三个组件都要读同一份会话数据并且切换会话后历史不能丢。此时把sessions放进 store组件只负责消费状态避免每个组件自己维护一份消息数组。// Pinia store多会话时把消息数组提升到 store 层 export const useChatStore defineStore(chat, { state: () ({ sessions: new Mapstring, ChatMessage[](), currentSessionId: }), actions: { appendToSession(sessionId: string, msg: ChatMessage) { const list this.sessions.get(sessionId) ?? [] list.push(msg) this.sessions.set(sessionId, list) } } })参数说明store 里用 Map 保存多个会话比对象更不容易遇到 key 命名冲突。注意 Pinia 的 state 会做响应式包装Map 本身也可以被代理操作时直接 set 即可不需要整个替换。2.3 展示层和协议层不要把 system prompt 混进页面列表另一个容易踩的坑是把发送给后端的请求体直接当页面消息列表用。AI 接口的协议消息往往包含 system 指令、历史截断后的摘要这些内容不应该展示给用户也不应该出现在聊天记录里。我一般会拆成两层展示层存ChatMessage[]发送协议单独构造。// 由展示层消息构造协议体只取角色和内容附加 system 提示 function buildPayload(messages: ChatMessage[], systemPrompt: string) { const protocolMessages [ { role: system, content: systemPrompt }, ...messages.map(m ({ role: m.role, content: m.content })) ] // 可在这里做历史裁剪控制协议体大小 return { messages: protocolMessages } }逻辑说明展示层的 role 只有 user / assistant协议层可以多一个 system。不要试图在页面列表里渲染 system 消息否则历史记录会出现用户没说过的话。发送前统一做一次映射也方便在接入多家模型时调整字段名比如某个模型要求用prompt而不是messages只需要改 buildPayload 的返回值。这一章的内容把数据结构、状态存放位置、协议隔离说清楚下一节可以放心接接口了。3. 把接口接进来SSE流式接收与中止控制AI 对话接口和普通接口最大的差别在传输方式内容不是一次性到齐的而是一块一块流式到达。如果前端像请求普通 JSON 一样等它全部完成用户会面对十几秒的白屏这就是许多人觉得 AI 页面“笨重”的根本原因。所以这章专门讲基于 fetch 的 SSE 解析、中止和异常兜底。3.1 为什么 AI 聊天不推荐用 WebSocket很多开发者第一反应是“流式嘛上 WebSocket”。但实际上大多数对话补全接口以 SSE 方式提供原因是 AI 场景是典型的单向流前端发起请求后端持续推送生成结果中途没有频繁的双向交互。SSE 走 HTTP天然支持 POST、能带自定义请求头而且后端实现成本低WebSocket 是双向全双工适合实时协作、多人同步这类场景。一句话选型后端给什么用什么如果后端接口是 SSE前端用 fetch ReadableStream 就够没必要引入 WebSocket 连接管理和重连逻辑。特殊情况是产品要求像协同编辑那样前后端实时互推才考虑 WebSocket 承载消息推送那是另一个方案了。3.2 fetch 读取 SSE 的最小实现用 EventSource 只能发 GET 请求AI 对话接口基本都要 POST 请求体所以不能用它。标准做法是用 fetch 拿到 body 后通过 ReadableStream 逐块读取。// 流式请求封装解析 SSE逐块回调更新界面 async function streamChat(payload: unknown, messageId: string) { const controller new AbortController() currentController.value controller // 保存到组件停止按钮要 abort const resp await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload), signal: controller.signal }) if (!resp.ok || !resp.body) { throw new Error(HTTP ${resp.status}) } const reader resp.body.getReader() const decoder new TextDecoder(utf-8) let buffer while (true) { const { done, value } await reader.read() if (done) break // 关键stream: true 避免中文被截断成乱码 buffer decoder.decode(value, { stream: true }) // 按换行切分保留末尾可能不完整的一行 const sepIndex buffer.lastIndexOf(\n) if (sepIndex ! -1) { const lines buffer.slice(0, sepIndex).split(\n) buffer buffer.slice(sepIndex 1) for (const line of lines) { if (line.startsWith(data:)) { handleSSEData(line.slice(5).trim(), messageId) } } } } }逻辑说明buffer 是防止 TCP 分包时一次 read 拿不到完整一行。如果直接拿每块去解析一条 data 可能被切成两半JSON.parse 就会失败。lastIndexOf 找最后一个换行保证处理的是完整行留在 buffer 里的是半行。参数说明TextDecoder 必须传{ stream: true }这样当多字节字符被拆到两个 chunk 时解码器会暂存不完整字节等下一块到了再拼出来否则中文大概率出乱码。这是流式中文聊天页面最容易翻车的细节之一。3.3 处理 data 行和结束标记 [DONE]SSE 协议每行以data:开头AI 接口通常在全部生成完后推送一个[DONE]标记。处理时先把 JSON 解析出来取出增量文本再追加到对应消息的 content 上。// 解析单个 data 行内容追加到消息 function handleSSEData(data: string, messageId: string) { // [DONE] 表示生成结束 if (data [DONE]) { updateContent(messageId, , done) return } let json: any try { json JSON.parse(data) } catch (e) { console.warn(这一块解析失败跳过, data) return } const delta: string json.choices?.[0]?.delta?.content ?? if (!delta) return // 有些块只携带 role 字段没有文本 const msg messages.value.find(m m.id messageId) if (msg) { updateContent(messageId, msg.content delta, streaming) } }逻辑说明不是每个 data 块都带文本比如第一个块可能只设置了 role所以取不到 delta 就跳过不要追加空字符串。JSON.parse 失败时跳过而不是抛错因为某些私有接口会在流中夹带注释行或心跳包。参数说明choices[0].delta.content是对应主流对话补全接口的返回格式。如果你接的是自研接口字段名可能叫data或text这里就是唯一需要改的地方。把这段解析逻辑单独放一个函数方便针对各家接口做兼容。还有一些后端不按 SSE 标准来每行直接是一个 JSON 对象而不是带data:前缀。碰到这种接口也不慌在循环里加一个判断if (line.trim().startsWith({))就直接走 JSON.parse其余行按标准处理。3.4 中止、超时与断线别让界面卡死在“生成中”用户体验的另一个关键点用户可以随时停止生成半路断线也不能让页面一直停在 streaming 状态。停止依赖 AbortController断线判断依赖超时逻辑。// 停止生成用户主动中断保留已生成的内容 const currentController refAbortController | null(null) function stopStreaming() { currentController.value?.abort() // 注意abort 会抛 AbortError要单独处理不能标记为 error } // 超时判定最后一块数据超过 60s 未到达视为断线 let lastChunkAt Date.now() const heartbeat setInterval(() { if (Date.now() - lastChunkAt 60_000) { currentController.value?.abort() } }, 10_000)逻辑说明catch 里判断error.name AbortError是用户主动停止还是真实报错是主动停止就把消息 status 置为 done反之置为 error。超时判断为什么不从请求开始算因为模型首字延迟可能很长但它在正常工作从“最后一块数据”开始计时才能把超时和磨蹭区分开。参数说明60s 是保守值内网自部署模型可以调到 30s公网大模型建议 60s。心跳定时器 10s 扫一次避免每次 read 都做时间比较。组件卸载时记得 clearInterval否则定时器会一直跑造成后台残留检测。断线后把 status 置为 error同时带上可读错误信息用户点重试就能重新请求后面进阶章讲重试。到这里接口层已经聊完下一章处理页面层最核心的渲染体验。4. 让输出动起来Vue 3里的流式渲染、Markdown解析与滚动控制接口通了不代表页面好用。流式数据到了前端如果没有合理的渲染策略会出现两种极端要么逐字蹦导致页面频繁重排卡成 PPT要么一次性全部出现丢失了流式体验。这章解决“内容怎么显示”的问题。4.1 按块更新还是逐字打字机性能与观感的取舍我见过刚上手 Vue 3 的开发者拿到流式接口后在回调里把 delta 拆成单个字符用 setInterval 逐个推进结果中文一句没说完页面已经掉到十几帧。原因很简单每推一个字符就触发一次响应式更新Vue 的 diff 和 DOM 重排在长文本下扛不住。更务实的策略是“接口推一块页面显示一块”把流式的节奏直接当作打字机效果。用户看到的本身就是逐字或逐词出现的没必要再人为拆细。只有在后端一次性返回完整文本的场景里才需要用打字机效果模拟生成过程。两种方案的取舍取决于接口到底是不是真正流式。// 真正的流式接一块更新一块不额外拆字 function handleDelta(chunk: string, messageId: string) { const msg messages.value.find(m m.id messageId) if (!msg) return updateContent(messageId, msg.content chunk, streaming) }逻辑说明这一段代码看起来简单但它背后是“信任流式接口的节奏”。如果担心块太大一次性出现可以调整节流时间而不是拆字。每 100ms 合并一次更新体验上依然是连贯的开销却低很多。// 节流控制100ms 内最多触发一次 UI 更新 let lastRender 0 function renderWithThrottle(content: string, messageId: string) { const now Date.now() if (now - lastRender 100) return lastRender now updateContent(messageId, content, streaming) }逻辑说明节流会丢中间帧但不会丢最终内容因为每次都以最新完整文本为准。适合内网接口或本地 mock 这种块特别多的情况。公网大模型本身输出很慢一般用不上节流。4.2 非流式接口如何模拟打字机requestAnimationFrame如果后端给的是普通 JSON 一次性返回又想保留打字机观感做法是“内容已接收但显示层加游标”用 requestAnimationFrame 推进。// 一次性返回后模拟打字游标逐帧推进 const displayText ref() const fullText ref() let cursor 0 let rafId 0 function startTypewriter(text: string) { fullText.value text cursor 0 cancelAnimationFrame(rafId) // 防止上一次没播完又开新的出现跳字 const tick () { cursor 2 // 每帧两个字符约 120 字/秒 displayText.value fullText.value.slice(0, cursor) if (cursor fullText.value.length) { rafId requestAnimationFrame(tick) } } tick() }逻辑说明cancelAnimationFrame 是必须的否则用户连点多次“重新生成”会同时跑多个循环出现文字乱跳。这个现象在本地几乎无法稳定复现属于“偶发但特别显眼”的玄学问题根子就在定时器或 rAF 没清理。参数说明每帧 2 字符按 60 帧算约 120 字/秒比默认逐字快不少读者按自己产品气质调。如果要慢速效果改成 1 个字符或每两帧推进一次。4.3 Markdown 渲染与代码高亮哪些开销不能在流式里做AI 回复基本都带 Markdown代码块、表格、列表很常见。渲染库我用 markdown-it 配 highlight.js还是同一个原则流式过程中别做重活。先看一个反面例子把增量文本丢进模板后用 computed 每次都跑md.render()。流式时每来一个块就全量 markdown 解析一次长回答能把页面卡死。改进是手动控制渲染时机同时处理“未闭合代码块”这个流式特有场景。import MarkdownIt from markdown-it import hljs from highlight.js const md new MarkdownIt({ highlight(str: string, lang: string): string { if (lang hljs.getLanguage(lang)) { try { return pre classhljscode${hljs.highlight(str, { language: lang }).value}/code/pre } catch (e) { /* 语言名不认识时走默认转义 */ } } return pre classhljscode${md.utils.escapeHtml(str)}/code/pre } }) // 流式未结束时代码块可能只有开头没有结尾临时补一个闭合 function closeUnclosedCodeFence(text: string): string { const fenceCount text.split(\n).filter(l /^/.test(l.trim())).length return fenceCount % 2 1 ? text \n : text } function renderMessageContent(text: string, done: boolean): string { return md.render(done ? text : closeUnclosedCodeFence(text)) }逻辑说明closeUnclosedCodeFence 用围栏个数判断代码块是否闭合奇数说明还差一个闭合符临时补上这样流式渲染时代码块不会把后续内容全吞进pre里。等 done 后用完整文本重新渲染一次把临时闭合符覆盖掉视觉上就稳定了。参数说明markdown-it 的highlight回调返回带 hljs class 的 HTML。highlight.js在语言不存在时要 fallback 到转义否则容易出脚本注入问题。不要在模板里直接拿原始 content 用v-html必须先经过 md.render 和 escapeHtml 处理这是安全底线。提示AI 生成内容里可能夹带 HTML 标签甚至脚本v-html 渲染前必须经过 markdown-it 的转义逻辑处理不能直接把原始文本塞进模板。4.4 自动滚动判断用户意图而不是盲目滚到底自动滚动的坑比想象中多。最简单实现是每次内容变化后直接 scrollTo 底部但用户一旦上翻看历史新的输出又会把他拉回底部体验非常差。正确做法是维护一个“用户是否离开底部”的标记。const containerRef refHTMLElement | null(null) let stickToBottom true // 监听容器滚动判断是否处于底部附近 function onScroll() { const el containerRef.value if (!el) return const distance el.scrollHeight - el.scrollTop - el.clientHeight stickToBottom distance 80 } // 内容变化后仅在需要贴底时滚动 watch(displayContent, async () { if (!stickToBottom) return await nextTick() const el containerRef.value el?.scrollTo({ top: el.scrollHeight, behavior: auto }) })逻辑说明distance表示离底部还有多少像素。小于 80 就认为用户还在底部一旦用户往上滚就不再自动贴底。为什么用 nextTick因为 Markdown 渲染后 DOM 高度还没更新立刻 scrollTo 会滚不到最新高度下一帧就出现弹回。参数说明80px 是经验值距离太大用户会察觉列表被拖了一点太小则手指刚碰一下滚动条就失去了自动跟随。移动端建议 120px因为滚动惯性更大。behavior 用 auto 而不是 smooth流式场景下 smooth 会造成滚动动画堆积反而一卡一卡的。5. 避坑AI聊天页面的 6 个高频翻车现场写到这里把前面几章的经验汇总成一张踩坑地图。以下六条都是实际接入 AI 聊天时高频出现的问题每一条按现象、原因、解决列清楚方便读者直接对照排查。5.1 渲染与滚动流式时代最容易翻车的区域现象流式过程中回答里的表格和代码块反复闪烁文字跳来跳去感觉页面一直在重排。原因每次收到 delta 就立刻把整段文本交给 markdown-it 重新渲染中间态本身不完整表格行列数在变化代码块未闭合DOM 高度一直在变浏览器只能不断重排。另一个同源问题是没有做渲染节流高频块把主线程占满。解决渲染节流控制在 100ms 左右未结束时用临时闭合规则渲染等流式结束后再跑一次完整渲染。具体代码已经在 4.3 的 closeUnclosedCodeFence。还要注意不要用 computed 直接对原始 content 做 md.render渲染时机要显式控制放在流式回调或 watch 里。现象用户往上翻历史记录新消息一出页面就自动弹回底部翻看的内容被强行打断。原因无条件在内容更新后执行 scrollTo 底部没有判断用户当前是否在底部附近。“用户想翻历史”和“用户在看新内容”两个意图没有区分不管三七二十一贴底。解决按 4.4 的 stickToBottom 方案用滚动距离判断意图。注意这个判断必须在用户滚动事件里实时更新而不是只在内容更新时判断。现象列表消息一多输入内容偶尔串到上一条回复里或者删除一条消息后 Vue 报 key 冲突。原因用了数组下标做列表 key。流式更新时msg.content delta本身没问题但中间插入、删除消息后下标对应的消息已经变了diff 复用错节点。解决消息 id 用crypto.randomUUID()或自增不重复值列表 key 一律用 id。这个习惯要从第一版保持不要等出问题再补。5.2 数据与交互中断、超时和移动端的血泪记录现象接口报错或断网后刚才已经生成到一半的内容全没了聊天记录里只剩用户自己的提问。原因异常处理直接进入了错误分支没有把 reader 已经读到的 buffer 写入消息 content。流式中途断掉时前端内存里其实有完整的已接收文本但 catch 分支没有同步它页面停留在未接收的状态。解决catch 里先把 buffer 里的剩余内容追加到消息 content 里再把状态置为 error。以下是流式中断兜底的简化写法结合 3.4 的循环使用// 流式中断后的兜底先把已读到的内容放进消息 catch (e: any) { if (e?.name AbortError) { // 用户手动停止内容保留状态置 done updateContent(messageId, lastText, done) } else { // 真实错误保留已生成内容状态置 error updateContent(messageId, lastText, error) } }逻辑说明lastText 是每次读取数据后同步维护的最新文本。AbortError 是主动停止不应显示红色错误提示其他异常才标记 error并给用户重试入口。现象多轮对话之后请求越来越慢最后直接超时。检查 Network 面板发现请求体已经几十 KB。原因前端把整个历史消息列表都发给了后端没有做上下文裁剪。模型上下文长度有限历史越长首字延迟越高最终超时。解决发送协议前按 token 估算做滑动窗口裁剪。中文场景可以用一个简单近似每个中文字符约 1 token4 个英文字符约 1 token保留最近的 N 条消息超出预算就丢弃更早的。现象移动端打开页面点击输入框后整个聊天区被压缩键盘弹起把输入框顶出屏幕有时候连刚生成的内容都看不到。原因没有处理移动端可视区域变化和安全区。移动浏览器点击输入框后 visualViewport 高度变化固定定位的输入框高度没跟着调整。解决输入区用position: fixed; bottom: 0同时补height: calc(100vh - env(safe-area-inset-bottom))或监听 visualViewport resize。底部留出安全区高度键盘弹起时输入框保持在可视区内。.chat-input-bar { position: fixed; bottom: 0; left: 0; right: 0; padding-bottom: env(safe-area-inset-bottom); background: #fff; border-top: 1px solid #eee; }参数说明env(safe-area-inset-bottom) 依赖 viewport-fitcover 的 meta 设置没加的话值为 0。键盘弹起场景要先确认页面用100dvh而不是100vh动态视口单位在移动端更可靠。6. 进阶让聊天页从“能对话”到“好用”的三个落地技巧6.1 上下文窗口截断把历史当缓冲而不是收藏夹给后端发请求时不是消息越多越好。一个常见做法是在 buildPayload 里按 token 预算截断保留最近对话同时用摘要替代超长的历史。// 估算文本 token 数近似值够用来做裁剪 function estimateTokens(text: string): number { const chineseChars text.match(/[\u4e00-\u9fa5]/g)?.length ?? 0 const otherChars text.length - chineseChars return chineseChars Math.ceil(otherChars / 4) } // 滑动窗口保留最近的消息对 function trimMessages(messages: ChatMessage[], maxTokens 6000) { let total 0 const result [] for (let i messages.length - 1; i 0; i--) { const cost estimateTokens(messages[i].content) if (total cost maxTokens) break total cost result.unshift(messages[i]) } return result }参数说明6000 是给常见模型留的余量具体值看模型最大上下文。这样做之后多轮对话的响应速度会明显回升。6.2 统一重试入口错误恢复不靠刷新页面网络抖动是常态。重试时注意两点不重复插入消息、保持同一消息 id。给每条消息的状态加一个 error 分支错误时显示“重新生成”按钮点击后把 status 置回 pending再次调用 streamChat。async function retryStream(messageId: string) { const idx messages.value.findIndex(m m.id messageId) if (idx -1) return messages.value[idx].status pending messages.value[idx].error undefined // 重新构造请求复用原 id避免列表里多出一条重复消息 await streamChat(buildPayload(trimMessages(messages.value.slice(0, idx 1))), messageId) }逻辑说明slice(0, idx 1) 只保留到当前消息重试时不让模型重复看到错误的那轮回复后续内容。复用 messageId 是让 Vue 的列表 key 不变UI 上只看到状态从“重试中”变回结果而不是插入新卡。6.3 本地 mock 流式把开发从后端进度里解放出来最后分享一个我一直在用的习惯开发阶段用一个本地 Node 脚本模拟真实 SSE 输出让前端先把交互跑通后端接口好后只改请求地址。用项目根目录的 mock-sse.mjs 就能起一个流式接口。// mock-sse.mjs —— 用 Node 内置模块模拟流式响应 const http require(node:http) http.createServer((req, res) { res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache }) const text 这是本地模拟的流式回复用来验证打字机效果和滚动控制。 let i 0 const timer setInterval(() { if (i text.length) { res.write(data: [DONE]\n\n) clearInterval(timer) res.end() return } res.write(data: ${JSON.stringify({ choices: [{ delta: { content: text[i] } }] })}\n\n) }, 60) }).listen(3100)参数说明Content-Type 必须是 text/event-stream每条 data 以双换行结尾。跑起来后前端 fetch 指向http://localhost:3100/chat就能在无后端依赖的情况下调试停止、重试、Markdown 渲染和滚动逻辑。实际接入时移动端键盘遮挡是我吃过亏的点后来养成的习惯是任何 AI 聊天页面上线前先在真机验证“键盘弹起时输入框可见、历史可上翻、流式中断有兜底”。这三件事过了基础体验就不会太差。希望这些细节能帮到你少在流式和渲染上返工。本文还有配套的精品资源点击获取