给 UniRAG RAG 配一个前端,为什么最后选了 Lobe UI

为什么后端能用了还要写前端

前几期把资源基座、pgvector 向量存储、文档摄取和切片都做完了,上传接口也交付了。但那套东西只给了 HTTP 接口,没有界面,没人能真正用起来。上传要靠 curl,知识库里有什么看不见,问答更是连接口都还没有。

这一期给项目配个前端。就一个服务首页,打开能提问,能看到最近更新了什么,能从空状态上传第一份文档把知识库喂起来。

技术栈最后定了 Vite 7、React 19 和 TypeScript,路由用 react-router 7,组件库挑了 @lobehub/ui 5.57。它把对等依赖卡得很死,antd 要 6,motion 要 12,engines 还要求 Node ≥ 22.22,好在本机 v24 够用。

组件库这个决定花的时间最多,得单独说说。

Lobe UI 是从 AI Agent 产品里长出来的

Lobe UI 是 LobeHub 维护的,就是 LobeChat 那家。这个出身决定了它的边界在哪。通用组件库里最难写、也最容易被低估的三块,也就是对话消息、Markdown 富文本和 Agent 控制台骨架,在它这里都是现成的。

选型时我也比过另外两条路。antd 通用件很全,但没有 AIGC 场景件,问答页得从零写。shadcn/ui 工程性更好,聊天和流式 Markdown 还是得自己来。

这个库的对话组件是真把边界状态当正事做的。ChatItem 的 props 里,一条消息要用到的东西基本都有。

ts 复制代码
interface ChatItemProps extends Omit<FlexboxProps, 'children' | 'onChange'> {
  avatar: MetaData;                    // 角色头像/名称/标签/描述
  placement?: 'left' | 'right';        // 用户/助手左右布局
  message?: ReactNode;                 // 消息内容
  loading?: boolean;                   // 流式输出的加载态
  error?: AlertProps;                  // 失败态(直接吃 Alert 配置)
  editing?: boolean;                   // 就地编辑
  onEditingChange?: (editing: boolean) => void;
  actions?: ReactNode;                 // 消息操作栏(复制/重新生成...)
  messageExtra?: ReactNode;            // 消息下方的附加区(引用、用量...)
  markdownProps?: Omit<MarkdownProps, 'className' | 'style' | 'children'>;
  aboveMessage?: ReactNode; belowMessage?: ReactNode;
  placeholderMessage?: string; fontSize?: number;
  ...
}

加载态、失败态、就地编辑、操作栏、引用区,这些都在同一个组件里。自研时最容易犯的错是把它拆成一堆互不相干的组件,然后发现「加载中的气泡要不要显示操作栏」这种问题没地方放。旁边还有配套件,LoadingDots 管生成中的动效,TokenTag 管 token 用量,ChatList、MessageInput、EditableMessage、BackBottom 也都在。

它自带 LLM 领域类型这件事更少见。

ts 复制代码
type LLMRoleType = 'user' | 'system' | 'assistant' | 'function';
interface LLMParams { temperature?: number; top_p?: number; max_tokens?: number; ... }
enum LanguageModel { GPT3_5 = 'gpt-3.5-turbo', ... }

角色类型、采样参数、模型枚举,库里都定义了。多租户 RAG 每个租户一套模型配置,前端迟早要表达这些东西,有现成的词汇表,能省一轮口径对齐。

控制台骨架我们也直接用上了。首页的信息架构分三段,问答、文档库、任务中心,dashboard 组里的 ConsoleShell、ConsoleNav、PageHeader、StatCard、RouteProgress 恰好凑成一套。

tsx 复制代码
<ConsoleShell
  brand={<ConsoleBrand href="/" logo={<Icon icon={BookOpen} />} title="知识问答" renderLink={...} />}
  navigation={<ConsoleNav items={NAV_ITEMS} pathname={pathname} onNavigate={handleNavigate} />}
  tools={<ThemeModeSwitch />}         // 顶栏右侧只留主题切换
  storageKey="unirag.console-shell"   // 侧栏折叠状态自带持久化
  ...
>
  <Outlet />
</ConsoleShell>

侧栏折叠状态靠 storageKey 自己就存住了。跳过导航的无障碍链接、移动端的抽屉导航也都是内建的。这类东西在产品早期最容易被漏掉,等上线前才想起来,补的成本比现在高得多。

另外几个点也都刚好对上了。主题是受控的,ThemeProvider 持有主题状态,ThemeSwitch 只负责切换,我们只改了 primaryColor 和 neutralColor,代价是 auto 语义要自己补。Markdown、Highlighter、mdx 自带 shiki 代码高亮和 katex 公式,是 RAG 回答里必然出现的东西。FileTypeIcon 按文件类型出图标,文档库页不用自己维护映射表。底层是 antd 和 antd-style,团队已有的排障经验能接着用。包的格式是 ESM,而且提供了子路径导出,我们只从 @lobehub/ui/es/chat/ChatInputArea/index 取输入框,从 base-ui 取按钮和文本,没让 barrel 导入把 shiki 拖进首屏。

代价也得摆出来

Lobe UI 内部有两套实现,core 和 base-ui。官方已经把 core 标成弃用,但两套 props 不兼容,core 的 Tag 有 color/variant/shape,base-ui 的 Tag 是新一代 API。项目里因此立了条规矩,通用件统一从 base-ui 导入,core 同名组件一律不新用。不然后面升级会集中爆炸。

另一处是默认值不合首页场景。ChatInputArea 的 expand 默认是 true,会渲染成全屏浮层,改成 false 又变成底部可拖拽面板。首页要的是居中大号输入框,只能用 Inner 变体。这个默认值后来真把我们坑了一次。

再有就是体积。Markdown 和 shiki 的代价实打实,只能靠懒加载和按需导入守着。

工程形态,独立 SPA 加一层代理

新建了一个独立的 web/ 工程。没上 Next.js,内部管理台和对话应用都没有 SEO 和 SSR 诉求,收益是零,成本是 RSC 的心智负担,外加多一个 Node 进程。也没把产物塞进 src/main/resources/static,那得改后端,SPA 刷新还要后端补 forward 规则。

跨域这事挺省心。全仓库 grep 了 cors、addCorsMappings、@CrossOrigin,零命中,后端根本没有跨域配置,:5173 直连 :8080 必被浏览器拦截。做法是 vite.config.ts 里配 server.proxy 把 /api 转给后端,零后端改动,开发期还是同源。

路由表刻意留了占位。/ 是首页,/chat?q= 放问答上下文,/documents 和 /jobs 还没做,先占个位,文档详情走首页内 Drawer,不占路由,因为需求写明不能离开首页上下文。占位让导航结构现在就固定下来,后面的 feature 只替换页面组件,不会出现死链。

租户这件事,用户不该知道

后端对所有 /api/** 强制要求 X-Tenant-Code,缺失直接返回 400。但产品的要求是用户完全不感知租户这回事。

租户编码的来源分了三层优先级,URL 参数 ?tenant=xxx 是运维通道,写进 sessionStorage 后下次直接读,读不到再兜底到部署级配置 VITE_DEFAULT_TENANT。最终值只在发请求时由 api/client.ts 注入请求头,不进任何渲染路径。连错误映射都守着这条线,TENANT_CODE_REQUIRED 和 TENANT_NOT_FOUND 在界面上只说「服务暂时不可用,请稍后重试」,绝不会出现「租户」两个字。既不暴露概念,也不把运维的问题甩给用户。

首页状态机,别把失败说成空库

首页的状态由两个维度合成,没写成一个大的枚举。数据维度是 loading | error | ready,内容维度是 empty | processing | ready,合起来是四个视图状态,优先级 error > empty > processing > ready。

这条顺序不是随便排的。请求失败绝不能渲染成「知识库为空」,那会诱导用户去做无效操作,比如上传文档,还掩盖了真正的故障。loading 的时候只给副标题上 Skeleton,提问框和导航层照常渲染,不做整页 loading。processing 是容易漏掉的中间态,已经有文档,但一份可检索的都没有,全在摄取中。这时候提问框要能用,提示语说正在处理,不能说空库。

状态颜色也集中管理。INGESTED、INGESTING、PARTIAL、FAILED 对应绿、蓝、橙、红四色,上传结果里的 NOOP 用灰色中性表达,但它不能拿来当列表状态。

那个把整页盖住的提问框

这个坑让首页整页空白,只露出一行输入框占位文字。根因就是 Lobe UI 的默认值不合首页场景。

按计划我们该用 ChatInputArea 的 Inner 变体,也就是一个纯受控的 TextArea。计划里写明了选它的理由,外层 expand 默认 true,会渲染成全屏浮层。结果导入写成了这样。

ts 复制代码
// 错误写法:这个模块的默认导出其实是外层 ChatInputArea
import ChatInputAreaInner, { ChatSendButton } from '@lobehub/ui/es/chat/ChatInputArea/index';

那个深路径模块的默认导出是外层 ChatInputArea,Inner 只是具名导出。我以为在用内层,实际渲染的是外层。expand 默认 true 时它渲染 styles.fullscreen,一个铺满视口的全屏容器,position 是 absolute,z-index 是 10,还带着白底,套在提问框里把后面所有 position: static 的内容全盖住了。

定位过程值得记一笔。DOM 是完整的,无障碍树里 29 个可交互元素一个不少,元素也有正常布局盒,所以光看 DOM 看不出问题。最后靠 elementsFromPoint 在内容位置取元素栈,发现有个不透明全屏层压在 h1 上面,再顺着父链和库源码找到 if (expand) return <div className={styles.fullscreen}>。

改法很简单,换成具名导入就行。顺手补了条回归断言,因为这个 bug 在 jsdom 里测不出来。jsdom 没有布局和绘制,全屏浮层在那儿只是个样式问题。断言只能落在结构特征上,textarea 的祖先链里不许出现带 inset-block-start 内联样式的容器。我把错误导入改回去跑过一遍,这条用例确实会红。

包体积,计划里的 manualChunks 被实测否掉了

计划原本要拆两个 vendor 包,react 一个,antd 和 lobehub 一个,首屏 gzip 目标是不超过 400 KB。实测否了。手动把 @lobehub/ui 归进 vendor 后,入口要预加载整个组件库,首屏 gzip 反而涨到 1463 KB,shiki 的动态语言包还会被合成一个 10 MB 级的共享块,跟着一起预加载。

最后交给 Rollup 按动态导入自动切分,首屏 gzip 583 KB,/chat 独立懒加载。这个数和 400 KB 的目标有差距,这点我不掩饰。那个懒加载块自己有 861 KB,主要是 ChatItem 连带拉进来的 Markdown 和 shiki。同一个组件既是 Lobe UI 最大的便利,也是最大的重量,它不拖首屏,但问答页真落地时这笔账要先记上。配套的纪律是首页不引入 Markdown,路由级一律 React.lazy。

现在的进度

做完的部分有独立前端工程、首页(提问台、知识导航、空态引导、最近对话)、最小可用的一次性上传、文档详情 Drawer、明暗主题与跟随系统、路由占位、测试和体积基线,全部经 Vite 代理访问既有的 /api/v1 接口,后端一行没改。

没做的部分里,分块上传和异步任务轮询留给文档库和任务中心,那两个页面现在还是空占位。问答只有上下文占位,没有真的检索和回答。没有 E2E,前后端联调靠手工验证加组件级断言,CI 里也没有视觉回归。

欠账有几笔。首屏 583 KB 比计划的 400 KB 高。/chat 那个 861 KB 的懒加载块要治理。Lobe UI 双实现这条线,得长期守住只用 base-ui 的规矩。还有一条经验,像 ChatInputArea 这种默认值不合场景的组件,下次引入前得先在真实浏览器里过一遍,不能只看文档和类型。

下一步是问答页接真实检索接口,把占位换成流式回答和引用片段,到那时 ChatItem 的加载态、失败态、操作栏才算真正派上用场,Markdown 和 shiki 的体积也得正面处理。再往后是文档库补齐列表、分页、详情和分块上传,任务中心消费异步摄取任务,最后把体积和首屏时间做成可核对的门槛。

相关推荐
imDwAaY1 小时前
用户态与内核态到底怎么切换?从系统调用到上下文切换
后端·操作系统
imDwAaY1 小时前
进程、线程、协程有什么区别?从资源分配到调度,一篇理清
后端·操作系统
威哥爱编程1 小时前
【AI全栈12-09】Spring Boot 用流式输出做打字机对话:点下去不再干等
java·spring boot·后端
威哥爱编程1 小时前
【AI全栈12-08】Spring Boot 用 MCP 统一接入内部系统:让 AI 接一次,全公司复用
java·spring boot·后端
deviant-ART1 小时前
Java 并发编程:深入理解 synchronized 的使用与原理
java·开发语言·后端
tachibana21 小时前
Go语言里slice是怎么扩容的?
后端·golang·go·切片
西瓜很甜哟2 小时前
golang实现大文件分片上传
后端·golang·html
sp42a2 小时前
简单的 Spring 文件上传与下载组件
java·后端·spring
步行cgn2 小时前
为什么 Spring 建议使用构造器注入?
java·后端·spring