使用 React、Tailwind 和 Motion 设计透明的 RAG 接口
使用 React 状态、Tailwind CSS 和 Framer Motion 构建一个 RAG 前端,实现检索流程展示、交互式引用功能、来源面板以及答案生成后的操作。
来源: How I’d Build a Modern RAG Interface with React, Tailwind CSS & Framer Motion
即便检索增强生成流程在技术上十分出色,但如果界面隐藏了系统的实际运作方式,用户仍会感觉该产品功能薄弱。一个带有旋转加载指示器的聊天框根本无法让用户知晓搜索了哪些文档、答案来自何处,或是内容仍在生成中。本指南将展示如何围绕RAG请求的各个实际阶段来构建React前端,从而使检索、数据来源、实时生成和引用等内容成为用户体验中可见且可查看的部分。
围绕检索阶段进行设计
关键在于将产品视为用户可依次操作的流程,而非简单的“聊天”模式:提问、检索、获取数据来源、生成内容、添加引用、进一步探索。这一流程决定了React应用程序的组织结构。
对于典型的内部知识助手而言,屏幕可划分为三个区域:左侧为文档库,中间为对话内容,右侧则为当前答案的来源。
---------------------------------------------------------
| Documents | Conversation | Sources |
| | | |
| SOPs | User: How does onboarding work?| SOP.pdf |
| Policies | | |
| FAQs | Searching knowledge... | FAQ.pdf |
| | | |
| | AI response... | |
---------------------------------------------------------
它仍然属于聊天界面,但对话只是产品的一部分。除此之外,应用程序还需要展示:
- 可搜索的知识库
- 当前的检索状态
- 生成答案所依据的来源
- 文档元数据
- 引用信息
- 后续操作选项
- 之前的对话记录
将所有这些内容都塞进一个聊天组件中很快就会变得难以管理。
与产品需求相匹配的组件结构
一种可行的结构如下所示:
RAGApplication
|
|-- Sidebar
| |-- NewConversation
| |-- ConversationHistory
| `-- DocumentLibrary
|
|-- ChatWorkspace
| |-- MessageList
| |-- RetrievalStatus
| |-- StreamingResponse
| |-- CitationList
| `-- PromptInput
|
`-- SourcePanel
|-- SourceCard
|-- DocumentPreview
|-- RetrievedChunk
`-- SourceMetadata
这种拆分方式体现了不同区域之间的差异性。消息会像对话一样不断积累,检索指示器存在时间很短,答案会持续流式呈现,源面板的内容取决于所选的引用,而整个系统则类似控制面板。通过分离各个组件,可以独立管理每个组件的状态,避免在每处理一个标记时都重新渲染整个屏幕。
第一步:从 API 返回足够的数据
动画和布局可以稍后处理。首先,后端必须为前端提供可用的数据。如果响应中只包含生成的文本,那么界面除了这些文本外将没有任何其他内容可显示:
{
"answer": "..."
}
更完善的接口规范会包含状态信息以及构成答案的各个来源列表,每个来源都会配有标识符、标题、章节引用以及简短摘录:
{
"answer": "Enterprise customers can request...",
"status": "complete",
"sources": [
{
"id": "doc-1",
"title": "Refund Policy",
"section": "3.2",
"snippet": "Refund requests must be submitted..."
},
{
"id": "doc-2",
"title": "Enterprise Terms",
"section": "5",
"snippet": "Enterprise subscriptions..."
}
]
}
通过这种结构,React能够呈现答案、显示引用标签、打开各个来源并预览相关段落,从而使证据成为核心交互元素而非事后补充内容。务必使用稳定的id:它既是来源卡片的React键,也是打开文档的标识。
步骤2:将信息检索建模为明确的UI状态
RAG请求会经历多个不同阶段,明确命名这些阶段会有所帮助:
idle
↓
retrieving
↓
sources_found
↓
generating
↓
complete
在React中,最简单的起点是为当前阶段、来源及答案设置几项状态:
const [status, setStatus] = useState("idle");
const [sources, setSources] = useState([]);
const [answer, setAnswer] = useState("");
当用户提交问题后,处理程序会随着请求的推进依次经历这些阶段:
async function handleAsk(question) {
setStatus("retrieving");
const result = await askKnowledgeBase(question); setSources(result.sources);
setStatus("generating"); setAnswer(result.answer);
setStatus("complete");
}
这是刻意简化的版本。由于单次调用就会同时返回数据源和结果,因此会在同一轮处理中用complete替代generating,这样React就能批量处理更新,用户也就不会看到中间状态(同时sources_found也永远不会被设置)。而流式实现则会先发出检索完成事件,再发送结果数据,最后才标记处理结束。处理函数还需要try/catch结构以及error状态,以避免请求失败后无限等待。即便如此,明确的错误状态也能让界面准确显示当前状况;而且随着处理阶段的增加,使用带有明确转换规则的useReducer比多次调用useState更能保持一致性。
步骤3:用检索状态指示器替换旋转加载图标
仅仅显示“正在加载...”这样的信息几乎没有任何意义。而 RAG 应用程序则可以描述其处理流程:首先用户会看到正在搜索知识库:
Searching your knowledge base...
接着显示找到了多少相关证据:
Found 4 relevant sources
最后显示正在生成答案:
Generating response...
这样的小改动就能解释处理流程,并让用户对等待时间有明确预期。一个简单的组件可将状态映射为对应消息:
function RetrievalStatus({ status, sourceCount }) {
if (status === "retrieving") {
return <p>Searching your knowledge base...</p>;
}
if (status === "generating") {
return <p>Found {sourceCount} sources. Generating response...</p>;
} return null;
}
并没有什么高深之处,而这正是关键:当没有内容可报告时该组件会返回 null,但应用程序依然会给人一种更加有序的感受。将消息放在 aria-live="polite" 区域,可以让屏幕阅读器用户听到相同的更新信息。
第 4 步:使用 Tailwind 构建视觉层次结构
RAG界面很快就会因用户和模型的消息、文档名称及片段、引用内容、警告信息、操作指令以及检索状态而变得拥挤不堪。Tailwind的实用类能够帮助快速建立清晰的层级结构,无需编写庞大的自定义样式表。例如,一个信息卡片可以使用带边框、轻微阴影的容器,并配上粗体标题:
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<p className="text-sm font-semibold text-slate-900">
Refund Policy
</p>
其后是颜色较浅的章节标签,以及行高足够大的易读内容片段:
<p className="mt-1 text-xs text-slate-500">
Section 3.2
</p> <p className="mt-3 text-sm leading-6 text-slate-700">
Refund requests must be submitted within...
</p>
</div>
这种样式设计刻意保持简洁。在AI界面中,清晰度比装饰更重要,因为用户只需了解三件事:答案、其来源以及可能的下一步操作。视觉呈现的权重也应遵循这一顺序。
第5步:让引用内容具备交互性
许多RAG界面仅显示类似[1]这样的括号内数字。如果能让用户查看某项主张背后的证据,引用才会更有用。将每个引用都设置为可记录所选来源的按钮,就是实现这一目标的简单方法:
<button
onClick={() => setSelectedSource(source)}
className="rounded-full border px-3 py-1 text-sm"
>
Refund Policy · 3.2
</button>
点击该按钮后会打开来源面板,显示文档标题:
Refund Policy
同时还会展示对应章节、引用的段落以及打开完整文档的途径:
Section 3.2"Refund requests must be submitted within..."[Open document]
这样的引用真正发挥了作用,答案的依据一目了然:用户只需点击一下即可核实某项主张,无需盲目相信。真正的按钮元素还能让用户通过键盘操作访问引用内容。
第6步:利用动画说明变化之处
只有当动画用于传达状态时,Framer Motion才能在这种界面中发挥作用。任何元素都不应仅仅为了动画效果而跳动。一个良好的应用方式是,在数据检索完成时让来源卡片逐渐淡出并向上滑动少许。首先需要导入 motion 组件:
import { motion } from "framer-motion";
接着将每张卡片包裹在 motion.div 中,使其在四分之秒的时间内从透明且偏移的状态过渡到完全可见的状态:
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<SourceCard source={source} />
</motion.div>
选中引用时,来源面板可以从侧面滑入;关闭时则再滑出:
<motion.aside
initial={{ x: 40, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 40, opacity: 0 }}
>
<SourcePanel source={selectedSource} />
</motion.aside>
需要注意的一点是:exit 动画仅在组件在 AnimatePresence 中渲染时才会执行,因为该功能会保持元素挂载状态直到其退出动画完成。如果没有 AnimatePresence,React 会立即移除该面板,从而导致滑出动画无法播放。另外请注意,目前 Framer Motion 是以 motion 包的形式发布的,入口点为 motion/react;请查阅最新文档以获取与您安装版本相匹配的导入路径。
其价值在于它所传达的信息:有变化发生了,新的信息已到达,且这些内容属于该答案。应尊重“减少动画”的设置,避免其造成干扰。
第 7 步:将流式加载与普通加载区分对待
流式传输会带来自身的用户体验问题。当文本逐个字符到达时,界面必须明确表明答案仍在生成中。在文本后显示一个轻微闪烁的光标就能很好地实现这一点。该组件会先渲染已有的内容:
function StreamingAnswer({ content, isStreaming }) {
return (
<div className="prose max-w-none">
{content}
仅在流式传输进行时才会添加光标:
{isStreaming && (
<span className="ml-1 inline-block h-4 w-1 animate-pulse bg-slate-500" />
)}
</div>
);
}
同时,也应限制用户对未完成答案的操作。读取功能可以保持可用,而任何会将答案保存到其他地方的操作则需等待完成:
Copy answer ✓
View sources ✓
Create task disabled while streaming
Export disabled while streaming
目标在于如实传达状态,而非追求美观:导出半完成的答案会导致结果出错。
第8步:将源代码面板整合到产品中
一个完善的源代码面板是RAG界面所能提供的最强大的功能之一。请考虑整个使用流程:
User asks question
↓
RAG retrieves 4 chunks
↓
AI generates answer
↓
Citation cards appear
↓
User clicks citation
↓
Relevant document section opens
至少,该面板可以显示:
- 文档标题
- 章节信息
- 检索到的段落
- 文档类型
- 相关性信息
- 用于打开文档的链接或按钮
随着时间推移,它还可以扩展为包含:
- 匹配文本的高亮显示
- 用于提供上下文的相邻内容块
- 文档预览功能
- 元数据筛选选项
每增加一项功能,系统就越不像是黑箱。一个优秀的面板还能暴露出检索效果不佳的问题,这也是在RAG项目初期就重视源数据质量的原因之一。
第9步:在答案之后提供操作选项
这就是RAG聊天机器人开始转变为应用程序的地方。假设有人询问团队是如何引导新客户的,系统会检索相关的入职操作标准流程,并以清单形式给出回复:
1. Create workspace
2. Request account access
3. Confirm scope
4. Assign team
5. Schedule kickoff
界面不仅限于此,还可以提供返回原始文档的链接:
[View SOP]
此外还有能够复用该结果的操作功能:
[Copy checklist][Create task list]
任务列表功能尤为有趣。人工智能系统的价值往往不在于它给出的答案,而在于它能帮助完成的后续操作,而这正是RAG应用程序开始向智能代理发展的方向。我们的多步骤AI代理UI构建指南介绍了这一发展方向。
端到端的完整交互流程
综合来看,有一个核心问题贯穿整个技术栈。一切始于用户询问企业客户是否可以在30天后取消:
USER
"Can an enterprise client cancel after 30 days?"
随后,React会创建查询状态,RAG API负责检索文档,状态栏会显示搜索结果,来源数量也会呈现出来,大语言模型则会生成有依据的答案并实时显示在屏幕上,同时会出现引用标签,点击这些标签即可查看包含相关内容的来源面板:
↓REACT
Creates query state ↓RAG API
Retrieves relevant documents ↓UI STATUS
"Searching your knowledge base..." ↓SOURCES
3 relevant sources found ↓LLM
Generates grounded response ↓STREAMING UI
Response appears progressively ↓CITATIONS
Refund Policy · 3.2
Enterprise Terms · 5 ↓USER
Clicks citation ↓MOTION
Source panel slides into view ↓SOURCE
Relevant passage displayed
可以将其视为两个相互协作的层次:后端的智能层负责数据检索与内容生成,前端的体验层则负责让这些操作更易于理解。虽然它们是独立开发的,但必须共同设计,这就是第一步协议如此重要的原因。
实用的技术栈
可以使用以下技术构建该应用的轻量级版本:
- 前端:React、Tailwind CSS 和 Framer Motion。
- 后端:基于 Python 的 FastAPI。
- 检索:嵌入向量及向量数据库,可通过 LangChain 或简单的自定义流程来实现。
- 模型:任何合适的托管 API,如 OpenAI、Gemini 或 Claude。
- 存储:PostgreSQL 或 Supabase,搭配 Qdrant 或类似的向量存储系统。
无需一开始就全部采用。对于作品集项目,一个最简的起点就已足够:
React
+
Tailwind
+
Framer Motion
+
FastAPI
+
one LLM
+
one vector store
一旦核心流程运行正常,就可以逐步改进检索质量、重新排序功能、身份验证、文档处理以及相关操作。
应避免的模式
应当避免的那种版本如下所示:
Upload PDF
↓
Chat box
↓
Spinner
↓
Answer
这虽然证明了流程的有效性,但几乎体现不出产品思维:没有任何内容是可见的、可验证的或可操作的。一个更完善的目标应整合多个层面:
Knowledge
+
Retrieval
+
Transparent states
+
Citations
+
Source exploration
+
Useful actions
这样的整合既展现了人工智能工程能力,也体现了扎实的前端实现水平。
为何界面层值得重视
模型仅是人工智能产品的一半。其余部分则是优秀软件始终需要的要素:易于理解的交互方式、清晰的反馈信息、可预测的状态、实用的工作流程以及合理的信息架构。React、Tailwind和Motion虽不具备智能功能,却决定了是否有人能从中受益,而前端工作往往被低估。
关键要点
- 首先设计API响应格式;具有稳定ID、章节和片段结构的来源数据,才是实现引用功能和源代码面板的基础。
AnimatePresence中,并尊重用户对减少动画效果的偏好设置。