ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

给Nanobot写个Web UI:基于SSE的流式聊天界面设计与实现

2026/9/16 16:13:50 拓冰建站 浏览量
给Nanobot写个Web UI:基于SSE的流式聊天界面设计与实现 先说下背景。Nanobot 是我一直在用的一款极简 AI 机器人项目本质上是把各种大模型后端Ollama、OpenAI 兼容接口等封装成一个轻量服务几乎没有自带的可视化界面平时调用基本靠命令行或者 API。工具本身很稳但问题也很明显我想在浏览器里跟它聊天想看完整的上下文记录想拖个参数试试不同采样温度的效果这些都做不了。于是我就花了两周时间给它写了一个 Web UI顺手命名为 NanobotUI。这篇文章把整个设计和实现过程完整拆一遍包括技术选型、架构思路、核心功能实现还有我在实际开发中踩过的一堆坑希望能帮到也想给自己的 AI 工具套个壳的朋友。这个 UI 适合谁用一类是 Nanobot 的现有用户觉得命令行交互太生硬想要一个清爽的聊天界面另一类是正在给自己的本地 LLM 服务做前端的人可以参考一下我选型的思路和流式输出的处理方案。整个项目不大但麻雀虽小五脏俱全会话管理、流式响应、Markdown 渲染、参数调节这些核心点都覆盖到了。1. 整体设计思路先想清楚“为什么需要 UI”再谈怎么做1.1 Nanobot 原本的使用方式与痛点Nanobot 本身的设计理念是“轻量、可脚本化”所以它的默认交互方式非常朴素。你可以用命令行直接发一条消息让它回复也可以通过 HTTP API 去调用但一切都是数据层面的交互没有任何界面可言。这就带来几个实际问题聊天记录没有可视化历史翻起来全靠终端滚动时间一长根本找不到之前的上下文。调参数只能靠手改配置或者每次调用时修改请求体没法实时对比不同参数下的回答效果。想给团队里非技术背景的同事演示或者自己在手机上临时问个问题命令行完全没戏。有的模型输出带 Markdown 格式终端里看就是一坨带星号和反引号的原始字符可读性很差。“给 Nanobot 写 UI”本质上不是做一个炫酷的前端项目而是把底层的模型能力以一种更友好、更直观的方式暴露出来。UI 只是壳核心还是怎么把 Nanobot 的 API 能力顺畅地翻译成浏览器里的交互体验。1.2 我为什么选择了 Web UI 而不是桌面客户端动手之前我其实纠结过一阵子到底是做桌面端比如 Avalonia UI、Electron还是 Web 端。后来把需求列了一遍答案非常明确选 Web。第一跨平台。我自己的主力环境是 Windows但 Nanobot 可能跑在 Linux 服务器上也可能跑在软路由、NAS 甚至树莓派上。Web UI 天然跨平台只要浏览器能打开就行手机、平板、电脑都能用不需要针对每个平台分别打包。第二部署成本低。桌面客户端需要处理安装、更新、依赖这些乱七八糟的事而 Web UI 只需要一个静态目录加一个反向代理入口。第三调试和扩展方便。浏览器自带的开发者工具可以直接看网络请求、调试 CSS而且以后想接别的服务Web 技术栈的生态也更成熟。Electron 这种方案对我来说太重了为了一个聊天界面套一个 Chromium内存和磁盘开销都不划算。Avalonia UI 我也看过但那是 C# / .NET 的生态跟我现有技术栈不太匹配而且做响应式布局没有 Web 灵活。所以最终定为后端用 Go 写一个薄薄的代理服务前端用纯 HTML JavaScript 少量第三方库不做工程化重架构。1.3 UI 方案选型轻量优先克制加依赖前端这块我特意控制了自己“什么都想上框架”的冲动。一开始确实想过 Vue 或者 React但仔细评估后发现这个 UI 的核心复杂度不在数据绑定和组件化而在“如何处理好流式响应”和“如何渲染好模型输出”。Vue 或 React 对于聊天列表这种简单场景属于杀鸡用牛刀反而会增加构建步骤和依赖体积。所以 NanobotUI 的技术栈很朴素原生 HTML CSS JavaScriptES6没有构建步骤。Markdown 渲染用 marked 库轻量、社区活跃。代码高亮用 highlight.js。状态管理完全靠手写用几个简单的 JS 对象和事件监听完成。不是说 Vue / React 不好而是“够用”优先。如果以后 UI 复杂度上来了需要做复杂的组件交互、多人协作、消息流虚拟滚动我会迁移到 Vue 3 Vite。但就目前的需求来说原生 JavaScript 能解决所有问题而且每次改动刷新浏览器就能看到效果开发效率特别高。另外在热词里我还看到有人提 Comfy UI、Element UI 之类的东西但那是另一类偏专业工具或后台管理的场景跟 NanobotUI 这种轻量聊天界面的定位完全不同。2. 核心架构与前后端交互设计2.1 NanobotUI 的整体架构NanobotUI 不是一个传统意义上的“纯前端项目”而是一个“前端 本地代理”的组合体。前端负责展示和交互代理服务负责转发请求、附加密钥、处理跨域和流式转发。目录结构大概长这样nanobotui/ ├── web/ │ ├── index.html │ ├── style.css │ └── app.js ├── server/ │ └── main.go ├── config.json └── README.mdserver/main.go 是核心入口它做三件事托管静态文件、暴露 API 代理接口、读取配置文件。启动之后用户访问 http://localhost:8080 就能直接打开聊天界面不需要额外配 Nginx也不需要 Node 环境。为什么一定要有这个代理层因为浏览器直接调大模型 API 会有几个绕不开的问题跨域CORS大模型服务大多不会给你开跨域权限浏览器里直接 fetch 会报错。密钥安全如果前端直接存储 API Key等于把密钥公开给所有能打开页面的人。请求格式统一不同后端Ollama、OpenAI、其他兼容服务的请求格式可能不一样代理层可以把它们转换成统一格式前端不用关心具体调的是谁。所以代理层是必须要有的这也是“给 Nanobot 写 UI”时最容易被新手忽略的一层。2.2 关键 API 设计NanobotUI 的 API 设计遵循一个原则前端只关心自己需要的数据格式不关心底层模型服务的差异。我设计了这几个核心接口接口方法说明/api/configGET获取当前配置信息模型列表、默认参数、系统提示词等/api/chatPOST发送聊天消息流式返回模型回复/api/historyGET获取会话历史记录/api/history/deletePOST删除指定会话/api/systemPOST更新系统提示词、参数配置其中/api/chat是最核心的接口它接收一个 JSON 请求体包含消息列表、模型名称、采样参数等然后内部去调用 Nanobot 的 API再将返回的流式数据边收边转发给前端浏览器。这个转发过程必须用流式的方式做不能等模型全部生成完再一次性返回否则用户体验会非常差——大模型生成一段文字可能需要几十秒让用户盯着空白页面干等是不可接受的。2.3 流式响应选型SSE 好过 WebSocket前端流式展示模型输出业界有两个方案WebSocket 和 SSEServer-Sent Events。我在实际开发中都试过最终选了 SSE。WebSocket 是双向通信能力很强但在这个场景里属于“杀鸡用牛刀”。聊天场景下消息发起方向是固定的——客户端先发服务端再推。不需要服务端主动向客户端推送什么所以单向的 SSE 完全够用而且 SSE 有几个天然优势基于 HTTP不需要额外的协议握手调试非常方便浏览器开发者工具里直接就能看响应内容。有自动重连机制EventSource内置断线重连而 WebSocket 得自己实现心跳和重连逻辑。实现简单后端只需要设置Content-Type: text/event-stream然后向响应流里持续写数据就行。NanobotUI 的 SSE 实现用了一个非常直白的方式后端循环读取上游模型 API 的流式响应解析出增量文本然后按 SSE 格式写入 HTTP 响应func streamChat(w http.ResponseWriter, req ChatRequest) { // 设置 SSE 响应头 w.Header().Set(Content-Type, text/event-stream) w.Header().Set(Cache-Control, no-cache) w.Header().Set(Connection, keep-alive) flusher, ok : w.(http.Flusher) if !ok { http.Error(w, streaming unsupported, http.StatusInternalServerError) return } // 调用 Nanobot 的上游 API拿到流式响应 stream, err : nanobot.ChatStream(req.Messages, req.Model, req.Params) if err ! nil { // 发送错误事件后返回 fmt.Fprintf(w, event: error\ndata: %s\n\n, err.Error()) return } defer stream.Close() for chunk : range stream.Chunks() { // 将增量数据包装成 SSE 事件 fmt.Fprintf(w, data: %s\n\n, chunk.Delta) flusher.Flush() } // 发送结束标记 fmt.Fprintf(w, event: done\ndata: [DONE]\n\n) flusher.Flush() }前端这边用fetch而不是EventSource原因是EventSource只能接收 GET 请求而聊天消息内容太长放在 URL 里太不现实。用fetch读取流式响应需要手动处理一下const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload) }); 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 格式解析 buffer 中的事件 // 遇到 data: 开头的内容就追加到当前消息的展示区域 }这里有一个特别注意的坑TextDecoder如果不用{ stream: true }遇到多字节字符被截断时就会出现乱码。中文内容尤其明显一个汉字三个字节如果恰好被切到一半不设置 stream 模式就会解析出。这也是很多流式输出项目乱码的根源之一。3. 核心功能实现与实操细节3.1 会话管理与历史记录既然做 UI就不能像命令行那样“聊完就忘”。我设计了一个轻量级的会话管理机制底层存储直接用 JSON 文件每个会话一个文件按时间戳命名。为什么不用 SQLite因为当前场景下并发量很低基本就是个人或小团队在用JSON 文件简单直观出了问题还能手工编辑。等以后真需要多用户并发、按关键字查询历史再迁到 SQLite 也不迟。会话数据的大致结构{ id: 20240615-203015-ab12cd, title: 关于端午节出游的建议, created_at: 2024-06-15T20:30:1508:00, messages: [ { role: user, content: 端午节想去苏州玩两天有什么推荐吗, timestamp: 2024-06-15T20:30:1508:00 }, { role: assistant, content: 苏州两日游可以从这几个方向考虑..., timestamp: 2024-06-15T20:30:2808:00 } ] }聊天的标题我会用第一条用户消息的前 20 个字自动生成这样侧边栏会话列表看起来干净。如果第一条消息太短就补一个默认标题“新对话”。实际操作中有一个细节值得提不要每次都完整写整个会话文件因为流式输出过程中消息会频繁变化。我是在用户消息发送时创建一个新会话文件等助手回复完全结束后再整体写入一次。这样既避免了频繁磁盘 IO又保证了最终数据一致。3.2 消息渲染Markdown、代码高亮与 XSS 防护模型输出通常带 Markdown 格式比如标题、列表、加粗、代码块。直接在页面上用innerHTML塞进去是绝对不行的既有格式问题也有安全风险。我用了marked这个库来做 Markdown 解析再配合highlight.js做代码高亮。大概的渲染链路模型输出文本 ↓ marked.parse() HTML 字符串 ↓ DOMPurify.sanitize() ← 这一步不能少 安全 HTML ↓ 插入消息气泡 代码通过 highlight.js 自动高亮DOMPurify 是很多人容易忽略的一环。模型如果被人用提示词注入攻击比如让模型输出img srcx onerroralert(1)未经处理的 HTML 一旦被插入页面就会执行脚本。DOMPurify 会把script、onerror这类危险属性和标签过滤掉只保留安全的标签。像有道云笔记、飞书文档的网页版也都用了类似的防护机制。代码块的渲染我额外做了一个“复制代码”按钮。实现方式是在marked的渲染器钩子里给code元素包一层 div动态插入复制按钮点击时用navigator.clipboard.writeText()把代码内容写入剪贴板。这个功能看着小实际使用频率特别高——模型经常给出大段配置代码手动选中复制费时费力还容易漏。3.3 参数面板与多模型适配NanobotUI 的右侧栏有一个参数面板暴露了几个最常用的采样参数参数默认值建议范围作用temperature0.70 ~ 2控制回答随机性越高越发散top_p0.90 ~ 1核采样控制候选词范围max_tokens2048128 ~ 8192回答最大长度presence_penalty0-2 ~ 2鼓励讨论新话题frequency_penalty0-2 ~ 2减少重复内容这里有个容易踩的坑temperature 和 top_p 不要同时调太高。两个都拉满输出基本就是胡言乱语两个都过低回答会变得机械重复。我的建议是主要调 temperaturetop_p 保持默认就好除非你是做特定任务需要严格控制输出分布。多模型适配这块NanobotUI 的代理层抽象了一个ChatStream(messages, model, params)接口底层根据配置文件里的backend字段决定走哪个 API。不管是 Ollama 本地模型还是 OpenAI 格式的云端接口统一转成内部的消息格式前端不需要关心。我还在配置里支持了“模型别名”比如把本地跑的qwen2.5:14b起个名字叫“主力模型”把nomic-embed-text归到嵌入模型分类。界面上展示的是别名不是底层真实模型名这样以后换模型后端前端配置不用改用户无感。4. 实测中的坑与排查技巧4.1 终端正常浏览器乱码这个现象把我折腾了半小时命令行里直接 curl Nanobot 的接口没问题但通过 NanobotUI 的浏览器页面发消息返回的中文经常出现乱码尤其流式输出的过程中偶尔会有几个字符变成。排查下来有两个原因。第一是响应头没有显式声明charsetutf-8Content-Type只写了application/json或text/event-stream虽然浏览器默认一般是 UTF-8但部分反向代理环境可能会用别的编码解析。解决方式是后端所有响应头都写完整Content-Type: text/event-stream; charsetutf-8。第二就是前面提到的TextDecoder流式解码问题。如果不设置stream: true当系统把一个 UTF-8 中文字符的三个字节分两次推送到前端时第一次只收到一两个字节解码器就会认为数据不完整直接输出替换字符。设置stream: true后解码器会把不完整的字节存在内部缓冲区等下一次数据到达时再拼接解码问题立刻消失。4.2 SSE 连接总是被断开流式回复到一半前端连接断了然后用户看到一条半截回答。这个问题在不同环境下表现不一样我排查后发现主要有三类原因。第一类是反向代理的超时设置太短。很多反向代理的默认proxy_read_timeout是 60 秒如果模型生成慢超过 60 秒没有新数据代理就主动断开连接。解决办法是在代理配置里加大超时时间或者两种思路配合定期发送注释行: keep-alive\n\n维持连接同时调整超时配置。第二类是本地代理服务没有在处理完请求前一直保持响应流打开。Go 里如果http.ResponseWriter没有调用Flush()反向代理或浏览器可能会认为连接已经空闲进入等待或断开状态。每收到一个 chunk 就手动Flush()是必须的。第三类是浏览器端的fetch没有正确处理连接关闭。读取流时如果遇到done: true要正常结束而不是抛错。我还加了自动重试逻辑如果收到“连接中断”信号前端会在界面上弹一个“继续生成”的按钮用户可以手动重试或让模型接着生成而不是被迫重新开始整个对话。4.3 请求跨域失败第一次写完前端直接用文件形式双击打开 index.html 测试发现所有请求都报 CORS 错误。这也是新手最常见的坑。浏览器安全策略规定网页只能请求同源资源或者被服务器明确允许跨域的接口。文件协议file://默认没有 Origin请求出去大多会被拦截。解决方式是不要直接打开 HTML 文件而是通过代理服务来访问。NanobotUI 启动后统一监听localhost:8080前端和 API 走同一个端口浏览器认为是“同源”就不存在跨域问题。如果确实需要把前端部署到另一个域名下可以在后端加上 CORS 中间件把允许的来源显式配置上w.Header().Set(Access-Control-Allow-Origin, https://your-domain.com) w.Header().Set(Access-Control-Allow-Methods, GET, POST, OPTIONS) w.Header().Set(Access-Control-Allow-Headers, Content-Type)但大多数场景下同源部署是最省心的方案。4.4 流式输出打字机效果卡顿前端拿到流式数据后如果是每收到一个小 chunk 就立刻刷新整个消息 DOM性能会非常差。模型输出速度快的时候一秒钟可能有几十个事件每个事件都触发一次 DOM 重绘页面会明显卡顿输入框也跟着掉帧。我的优化思路是“攒一批、渲染一次”。维护一个渲染队列每 50 毫秒批量把新增文本追加到 DOM 里而不是每个 SSE 事件都立刻更新。另外在渲染期间用requestAnimationFrame配合确保 UI 更新跟浏览器刷新率同步。实测下来即使模型输出速度很快界面也能保持 60 帧流畅度。这里还有一个性能细节消息气泡里的 Markdown 渲染是重操作如果每次追加文本都重新解析整个消息内容代价太高。我的做法是把解析拆成两步——收到的纯文本先正常追加显示等流式输出结束后再对完整消息重新做一次 Markdown 解析和代码高亮。也就是说“流式过程中看到的是纯文本结束后变排版”这样既不卡顿最终效果又完整。4.5 其他小问题速查表问题原因解决方案模型输出总是截断max_tokens 设置过小调大默认 max_tokens或在界面上提示“已截断可续写”页面加载后模型列表为空上游服务没启动或配置错误检查配置文件中 base URL 和模型名称发送消息后无响应代理服务崩溃或上游超时查看服务日志确认请求是否到达代理层代码块无法复制剪贴板 API 需要 HTTPS 或 localhost本机 localhost 不受限远程访问需启用 HTTPS长对话后回复质量下降上下文窗口超限配置上下文字数上限超出后自动截断最早的消息上下文超限这个问题特别值得展开一下。模型不是无限记忆的太长的历史消息会导致输入 token 超限或者回答质量下降。NanobotUI 默认保留最近 20 轮对话用户 助手算一轮超过的部分自动丢弃最旧的。同时我会在界面上显示当前会话的大致 token 占用让用户心里有数。这里我直接用了一个宽泛的估算公式中文字符数约等于 token 数英文按 4 字符算 1 token不需要精确计算够用就行。5. 后续扩展方向与一点个人心得给 Nanobot 写 UI 这件事技术上并不难但很考验对细节的把控。整个项目从零到能顺畅使用大概花了两周的空闲时间大部分时间其实都花在调试流式输出和做浏览器兼容上真正写界面反而很快。如果后续想继续扩展我认为有几个方向很有意思。第一个是支持多用户的权限体系。现在的 NanobotUI 是单用户设计谁打开页面都能用如果部署到团队内部需要加一层简单的登录认证。实现也不难加一个登录页面用 Cookie 存 session 就行不用引入太重的东西。第二个是支持图片输入。现在很多模型是多模态的但目前 UI 只支持纯文本。可以在输入框旁边加一个图片上传按钮把图片转 Base64 塞进消息里发过去前端渲染的时候对图片消息做特殊处理。第三个是做一个“提示词管理库”。我在实际使用时发现有些系统提示词要反复用比如“你是帮我写代码的助手”、“你是帮我润色文章的老师”每次都手动粘贴太麻烦。做一个预设模板库侧边栏一键切换体验会好很多。第四个是加入 token 用量统计。虽然本地模型不花钱但了解每次对话消耗多少 token对调优上下文策略很有参考价值。目前我只是在日志里输出还没做可视化展示。最后再说一个关于 UI 开发的个人体会。很多人在做这种工具界面时容易陷入“用框架、引依赖、追求酷炫”的误区。实际上对于一个明确场景的工具型界面最简单直接的方案往往才是最好的方案。原生 JavaScript 少量库完全够用维护成本低也不容易被依赖绑架。等你真正遇到了框架能解决的问题再迁移也不迟不用提前给自己加包袱。这也是我这次做 NanobotUI 最核心的一条心得——克制地做加法别让工具变成负担。