为什么后端能用了还要写前端
前几期把资源基座、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 的体积也得正面处理。再往后是文档库补齐列表、分页、详情和分块上传,任务中心消费异步摄取任务,最后把体积和首屏时间做成可核对的门槛。