给Nanobot写UI:从CLI到可视化Agent交互的设计与实现
2026/9/16 3:46:12 网站建设 项目流程

给Nanobot写UI这事,完全是我自己给自己找的活。Nanobot这个项目我用了大概两个月,最初只是当个命令行工具用,本地跑AI Agent场景是真的顺手——轻量、能挂多个模型提供商、工具调用直接靠自然语言。但问题也恰恰出在这里:纯CLI工具在交互层能给你的东西太少了。所以后来我动了念头,抽了几天时间给它写了个Web界面,取名叫NanobotUI。这篇文章就把我的设计思路、实现细节、踩过的坑都整理出来,给同样在用Nanobot、或者打算给命令行工具写前端的同学做个参考。

NanobotUI做的事情,简单说就是给Nanobot套上一层图形界面,核心能力包括:流式对话展示、工具调用过程可视化、多会话管理、历史记录持久化、模型和参数切换。它解决的核心问题,是让不习惯终端的用户可以平缓上手,也让习惯终端的人能在更直观的界面里审查Agent思考过程。适合的人群主要有三类:正在用Nanobot但觉得纯命令行看推理过程太费眼的开发者;想给AI Agent工具做前端、但不确定界面层该怎么设计的同学;还有单纯对"给开源CLI项目写UI"这件事感兴趣的折腾型玩家。

1. 为什么给Nanobot写UI

1.1 Nanobot本身是什么

先简单交代一下Nanobot是什么,方便没接触过的朋友衔接后面的内容。Nanobot是一个本地优先的AI Agent框架,用Go写的,核心思路是"自然语言定义工具调用"。你在配置文件里声明好工具,日常使用的时候直接用大白话提需求,Nanobot会自己判断该调用哪个工具、传什么参数,再拿工具的返回结果继续往下推理。它支持OpenAI、Anthropic、Gemini、Ollama这些主流模型提供商,也支持MCP协议,本地文件、API服务都能接进来。

这个项目的设计理念很明确:把复杂的东西藏起来,让你专注在"跟Agent对话"本身。启动之后是一个轻量的命令行交互,输入问题,模型推理,如果需要工具就自动调用工具,然后继续。整个过程在终端里是能看到日志的,模型每步在想什么、调用了什么函数、参数是什么,都会以文本形式打出来。

1.2 纯命令行交互的痛点

命令行工具虽然灵活,但实际用起来有几个很现实的问题。

第一,推理过程的展示逻辑是"平铺"的。模型在思考、调用工具、拿到结果、继续推理,这些信息在终端里全是一行行地打印出来。如果Agent连着调了三四个工具,屏幕上就是一坨挤在一起的文本,你根本分不清哪条是模型的最终回答、哪条是工具返回结果、哪条是中间的推理过程。对话稍微长一点,想回头翻某一步操作,只能靠滚轮和眼睛硬找。

第二,没有上下文的结构化展示。你问一个问题,Agent调用了你本地的Python脚本,返回了一堆JSON结果,然后基于这个结果继续回答。终端里那堆JSON打印出来非常占地方,而且没有语法高亮,辨识度极低。你要是想让一整个会话能被别人看到,直接截图发过去,对方大概率也看不懂。

第三,模型切换和参数调节靠记命令行。Nanobot本身支持多提供商多模型配置,但每次用CLI的时候必须通过参数或环境变量指定,用久了容易忘,来回切换也很烦。

1.3 NanobotUI的定位

我想要的界面不复杂,但需要把"Agent的执行过程"这件事讲清楚。核心需求有三条:消息要分角色展示、工具调用要单独卡式呈现、会话要能保存和回看。这也对应着Agent UI产品里最常见的三个要素:对话流、工具调用轨迹、会话状态。

说得直白一点,我想把它做成类似ChatGPT那样的大气泡对话风格,但左侧加一个会话栏,中间是对话窗口,每个工具调用渲染成一张独立的折叠卡片,点开能看到完整的输入参数和返回结果。这样Agent执行了什么操作,每一步是什么状态,一目了然。

2. 整体架构与设计思路

2.1 功能边界与核心交互

动手之前我先给自己画了几条边界,避免做着做着就失控。

  • 只做Web端,不做桌面端。Nanobot本身是本地服务,Web前端够用,装个浏览器就能开。
  • 身份定位是"前端界面层",不是重写后端。所有与模型提供商的通信、工具调度、推理过程都在Nanobot内部完成,UI只负责把现有数据流可视化。
  • 会话数据默认保存在浏览器本地,不额外堆后端存储。
  • 界面要支持明暗主题,移动端能看能用,优先保证桌面体验。

这个边界定下来之后,整个项目的重心就很明确了:怎么把Nanobot暴露出来的信息,以最舒服的方式呈现给用户。

2.2 技术选型:为什么是Vue 3

技术选型我纠结了不到半天,最后还是选了Vue 3加Vite。原因有三:一是Vue 3的组合式API写起来顺手,逻辑复用简单,特别是像WebSocket重连这种有状态逻辑,用composable封起来很干净;二是生态成熟,Element Plus、Naive UI这些组件库随便挑;三是Vite的开发体验确实好,秒级热更新,改完代码立刻能看到效果。

组件库方面,我对比了Element Plus和Naive UI。Element Plus组件全,文档详细,但样式识别度太高,一眼就是后台管理系统的味道。Naive UI更轻,风格更现代,主题定制也灵活。考虑到我想要的聊天界面气质偏"产品化",Naive UI更合适。

这里有个小建议:如果你也想给某个CLI工具写UI,不要在技术选型上反复横跳。随便选一个你熟悉的框架,能快速跑起来才是最重要的。界面好不好看,后面慢慢调都来得及。

2.3 与Nanobot的通信方式

Nanobot支持通过--serve参数启动本地HTTP服务,这就好办了。我的方案是:NanobotUI作为一个静态前端工程,通过HTTP接口与Nanobot通信;对话消息的流式返回,用SSE(Server-Sent Events)接收;会话列表和工具调用记录,通过REST接口拉取。

前端工程和Nanobot进程是两个独立的东西。你本地起一个Nanobot服务,再在另一个端口起NanobotUI的静态服务,前端配一个后端地址就能连上。这样解耦的好处是,你完全可以把NanobotUI部署到别的机器上,只要网络能访问到Nanobot的端口就行。

3. 核心功能实现细节

3.1 流式对话输出

AI对话UI最核心的体验就是流式输出。模型一个字一个字往外蹦的时候,前端要实时渲染,不能有卡顿,也不能等到全部生成完再一次性显示。

我用的是SSE。Nanobot在流式返回的时候,会以事件流的形式不断推送增量内容,前端收到一个chunk就追加到当前消息的显示区。具体做法是,用EventSource或者fetchReadableStream来读取数据流,每拿到一段就更新Vue的响应式数据。这里的关键点是:不要在每次更新时重新渲染整条消息,而是维护一个累积字符串,只更新显示文本对应的DOM。Vue的虚拟DOM diff本身就做了优化,实际用下来Vue 3在长文本流式渲染场景下足够流畅。

消息渲染的时候要注意一个问题:Markdown是在流式过程中做的增量解析,还是等整个消息接收完再一次性渲染?我最初是一股脑往v-html里灌Markdown渲染结果,发现模型在生成一半的时候,Markdown语法往往是残缺的,比如代码块只有一个开始的三反引号,还没闭合,渲染出来的样式一团糟。后来我改成节流渲染:以50到100毫秒为间隔做一次Markdown解析和重绘。这样既保证了实时性,又不会每敲一个字就去跑一次解析,性能也能接受。

// streaming过程中定时渲染的简化逻辑 let renderTimer = null const scheduleRender = () => { if (renderTimer) return renderTimer = setTimeout(() => { renderMarkdown(currentContent.value) renderTimer = null }, 80) }

3.2 工具调用的可视化还原

工具调用是NanobotUI和普通聊天UI最大的区别点,也是最花心思的部分。

默认情况下,Nanobot在调用工具时会输出类似call_tool: python_script这样的日志行,参数以JSON格式打印。我的做法是:在流式数据处理时,把工具调用的信息单独截获出来,不混在对话气泡里,而是沉淀成一个结构化的ToolCall对象,渲染成一张独立的卡片。

这张卡片包含这么几个部分:工具名称、调用状态(执行中、成功、失败)、输入参数、返回结果。默认折叠,点开展开,这样对话流不会被大段JSON污染,需要看细节的时候又能展开检查。

// 数据结构参考 { id: 'call_abc123', toolName: 'python_script', status: 'success', input: { script: 'print("hello")' }, output: 'hello', startTime: 1710000000000, endTime: 1710000001200 }

这里我最想强调的是"执行状态"这个字段。Agent调用工具是有耗时和失败率的,如果UI上能实时显示"正在执行中"的转圈状态,体验会提升很多。我通过流式消息里的状态位来同步这个卡片的状态,工具执行完成后把最终结果回填进去。整个执行链条在界面上看起来非常清晰:模型说了一句话,然后卡片显示调用Python脚本成功,模型基于返回结果继续说下一句。

3.3 会话管理与历史记录

会话管理我采用的是"本地优先"方案。所有会话数据用IndexedDB存浏览器里,结构上用一个会话列表加消息列表,每条消息带上工具调用ID的引用,方便还原。

选IndexedDB而不是localStorage,是因为消息体里可能包含大段代码和JSON,localStorage只能存字符串且容量有限(通常5MB左右),IndexedDB的容量和结构化存储能力都更胜一筹。我用idb-keyval这个库包了一层,API简单,读写的代码量也不大。

// idb-keyval 读写示例 import { get, set, keys } from 'idb-keyval' const saveSession = async (session) => { await set(`session:${session.id}`, session) }

会话管理看起来是个不起眼的模块,但实际是日常使用频率最高的功能。我加了几个设计:会话标题默认取第一句话的前20个字,侧边栏支持搜索,单个会话可以删除,全部会话可以一键清空。对于本地工具类的Agent来说,会话本身就是工作日志,能保存并能随时回看,价值非常大。

3.4 Markdown渲染与代码高亮

Markdown渲染我没有用现成的完整组件,而是选了markedhighlight.js的组合。marked负责把Markdown转HTML,代码块的语法高亮单独交给highlight.js处理。

之所以不直接用v-html一把梭,是因为需要处理代码安全、样式冲突和流式渲染的兼容性。marked支持自定义渲染器,我可以对代码块单独做处理,在渲染前先把内容转义,再交给高亮器。这样既能防止HTML注入,又能保证代码块高亮正常。

样式方面,我针对代码块做了暗色主题适配。对话里的代码块和普通文本的视觉区分要明显,因为Agent工具调用返回的结果里经常含代码片段,看的人需要快速抓住代码内容。浅色主题下用浅灰背景加左边框,暗色主题下用深灰背景加细微内阴影,效果都还不错。

4. 踩坑记录与排查实录

4.1 SSE断线重连与消息乱序

实战中第一个让我头疼的问题就是SSE连接不稳定。Nanobot服务偶尔会因为本地网络波动或者长时间空闲主动断开连接,前端一旦断掉,正在生成的回复就卡在半截,重新连上之后又不知道从哪里续上。

我的处理办法分两层。第一层是NanobotUI本地做了自动重连,断线后每隔3秒尝试重新建立连接,如果当前消息还没结束,重连成功后从Nanobot拉取当前会话的完整最新状态,用增量补齐的方式刷新界面。第二层是给每一条消息都打上时间戳和序号,前端渲染时按序号排序,防止重连后消息顺序错乱。

// 断线重连的简化逻辑 const connect = () => { source = new EventSource(apiUrl) source.onerror = () => { source.close() setTimeout(connect, 3000) } }

这个坑给我的教训是:任何涉及网络状态的前端应用,一定要把"断线重连后数据如何对齐"想在前头,不要等真断了再临场设计。

4.2 流式Markdown的XSS风险

流式渲染还有个安全隐患:模型生成的内容里如果包含HTML标签,直接渲染到页面里会有XSS风险。比如你让模型"输出一个HTML页面",模型真的输出了带script标签的内容,如果前端直接不转义渲染,代码就直接执行了,这是个很严重的坑。

我的解决办法是在marked里把所有原始字符串先做HTML转义,再走Markdown解析。同时代码块内容一律通过highlight.jshighlightElement处理,经过这样两道过滤之后,即使模型输出恶意结构也不会执行。

这个点必须重视。给AI工具做前端和给普通内容展示做前端的风险边界完全不一样——模型输出不可控的程度远高于用户输入,所有渲染路径都要假定内容是"不可信的"。

4.3 工具调用JSON的递归渲染

刚开始做工具卡片的时候,我直接把JSON序列化成字符串塞进<pre>里,凑合能用,但一旦工具返回嵌套很深的对象,看的人要自己在字符串里数括号,体验极差。

后来我写了一个递归渲染JSON的组件,遇到对象就缩进展开,数组就渲染成列表,基础类型直接显示。嵌套层级也能控制,默认展开两层,更深的内容折叠起来。这样工具返回的结果在卡片里就是结构化的,可读性提升了一大截。

<!-- 递归渲染JSON的组件骨架 --> <template> <div class="json-viewer"> <template v-if="typeof value === 'object' && value !== null"> <div v-for="(val, key) in value" :key="key"> <span class="key">{{ key }}</span>: <JsonViewer :value="val" /> </div> </template> <span v-else>{{ value }}</span> </div> </template>

4.4 界面卡顿与长会话性能

长会话场景下界面会变卡,这个问题在对话超过上百条消息后开始显现。一开始没优化,每条消息都完整渲染,整个页面的DOM节点数量爆炸,滚动和输入都有明显延迟。

优化方案做了三步。第一步是虚拟滚动,只渲染视口附近的几条消息,其他消息用占位高度撑住滚动条。第二步是长内容折叠,超过一定高度的代码块和JSON卡片默认折叠,需要时再展开。第三步是样式隔离,避免全局样式互相干扰导致重排开销变大。

三步做下来,即使上千条消息的会话,也能保持滚动流畅。这部分的优化逻辑对任何聊天类前端都有参考价值,核心思路永远只有一句话:不要渲染用户当前看不到的东西。

5. 工具/方案对比与部署扩展

5.1 与现有方案对比

做之前我也翻了翻社区,给其他AI CLI工具写UI的项目有一些,但针对Nanobot的成熟图形界面并不多。要么是简单的聊天壳子,只有输入框和气泡式输出,没有工具调用轨迹的展示;要么是维护状态不活跃,依赖的依赖版本已经过时就懒得动。

NanobotUI比较鲜明的差异是把"工具调用可视化"作为一个一等公民来处理,而不是把它藏在消息里。另外,本地优先的会话存储、离线可用的静态页面、一次起服务就能用的部署方式,对日常使用者来说都更友好。

5.2 快速部署与使用指引

如果你想自己跑起来试试,步骤非常简单。

先确保本机已经装好Nanobot并配好模型提供商,然后执行nanobot --serve把后端服务跑起来。接着克隆NanobotUI代码,配置VITE_NANOBOT_API_URL指向Nanobot的地址,执行npm installnpm run dev,浏览器打开本地开发端口就能看到界面了。

生产部署更简单,npm run build之后把静态产物扔到任意Web服务器(Nginx、Caddy都行),或者直接用vite preview预览,访问一个静态页面就完事。前端只有一个静态目录,不依赖服务器端渲染和数据库。

5.3 后续迭代计划

目前用的顺手,但还有一些想加的东西排在计划里。

第一个是MCP工具市场。既然Nanobot已经支持MCP,以后在UI侧做一个可视化的工具管理面板,让用户能浏览、启用、停用可用的MCP工具,比纯写配置文件直观得多。

第二个是Prompt模板。把常用的系统提示词和任务模板做成可保存的预设,一键填充到输入框,省去反复写的功夫。

第三个是更精细的模型参数面板。温度、top_p、max_tokens这些参数直接在界面上放滑杆和数字输入框,面向非开发者的使用场景会更友好。

这个项目给我最深的体会是,给命令行工具补一个UI,看起来是"面子工程",实际上是在打磨交互的本质。一个工具如果能把内部决策过程用清晰、结构化的方式展示给用户,用户对它的信任感会成倍增加。NanobotUI目前还谈不上完美,但它确实让我的日常Agent使用频率上了一个台阶——毕竟很多时候,界面友好本身就是一种生产力。

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

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

立即咨询