给Nanobot写个Web UI:基于SSE的流式聊天界面设计与实现
2026/9/16 16:13:43 网站建设 项目流程

先说下背景。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 + JavaScript(ES6),没有构建步骤。
  • 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.md

server/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 和 SSE(Server-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:15+08:00", "messages": [ { "role": "user", "content": "端午节想去苏州玩两天,有什么推荐吗?", "timestamp": "2024-06-15T20:30:15+08:00" }, { "role": "assistant", "content": "苏州两日游可以从这几个方向考虑...", "timestamp": "2024-06-15T20:30:28+08:00" } ] }

聊天的标题我会用第一条用户消息的前 20 个字自动生成,这样侧边栏会话列表看起来干净。如果第一条消息太短,就补一个默认标题“新对话”。

实际操作中有一个细节值得提:不要每次都完整写整个会话文件,因为流式输出过程中消息会频繁变化。我是在用户消息发送时创建一个新会话文件,等助手回复完全结束后再整体写入一次。这样既避免了频繁磁盘 IO,又保证了最终数据一致。

3.2 消息渲染:Markdown、代码高亮与 XSS 防护

模型输出通常带 Markdown 格式,比如标题、列表、加粗、代码块。直接在页面上用innerHTML塞进去是绝对不行的,既有格式问题,也有安全风险。我用了marked这个库来做 Markdown 解析,再配合highlight.js做代码高亮。

大概的渲染链路:

模型输出文本 ↓ marked.parse() HTML 字符串 ↓ DOMPurify.sanitize() ← 这一步不能少 安全 HTML ↓ 插入消息气泡 代码通过 highlight.js 自动高亮

DOMPurify 是很多人容易忽略的一环。模型如果被人用提示词注入攻击,比如让模型输出<img src=x onerror=alert(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 不要同时调太高。两个都拉满,输出基本就是胡言乱语;两个都过低,回答会变得机械重复。我的建议是主要调 temperature,top_p 保持默认就好,除非你是做特定任务需要严格控制输出分布。

多模型适配这块,NanobotUI 的代理层抽象了一个ChatStream(messages, model, params)接口,底层根据配置文件里的backend字段,决定走哪个 API。不管是 Ollama 本地模型,还是 OpenAI 格式的云端接口,统一转成内部的消息格式,前端不需要关心。

我还在配置里支持了“模型别名”,比如把本地跑的qwen2.5:14b起个名字叫“主力模型”,把nomic-embed-text归到嵌入模型分类。界面上展示的是别名,不是底层真实模型名,这样以后换模型后端,前端配置不用改,用户无感。

4. 实测中的坑与排查技巧

4.1 终端正常,浏览器乱码

这个现象把我折腾了半小时:命令行里直接 curl Nanobot 的接口没问题,但通过 NanobotUI 的浏览器页面发消息,返回的中文经常出现乱码,尤其流式输出的过程中,偶尔会有几个字符变成

排查下来有两个原因。第一是响应头没有显式声明charset=utf-8Content-Type只写了application/jsontext/event-stream,虽然浏览器默认一般是 UTF-8,但部分反向代理环境可能会用别的编码解析。解决方式是后端所有响应头都写完整:Content-Type: text/event-stream; charset=utf-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 最核心的一条心得——克制地做加法,别让工具变成负担。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询