项目地址
- 在线预览:frontend-ai-assistant-two.vercel.app/
- GitHub 源码:github.com/hewq/dify-c…
说明:当前在线版本部署在 Vercel,国内网络访问可能不稳定。如果打不开,可以直接查看 GitHub 源码并在本地启动。
前言
上一篇我们解决了 AI 回答的展示体验问题:
Markdown 渲染
代码高亮
表格展示
列表展示
现在 AI 的回答已经更像一个正式产品,而不是普通纯文本输出。
但对于一个 RAG 知识库问答系统来说,还有一个非常关键的问题:
AI 的答案到底来自哪里?
如果用户只能看到 AI 回答,却看不到引用来源,那他很难判断:
这段内容是知识库里的?
还是模型自己补充的?
有没有可能编造?
我能不能回到原文确认?
所以这一篇我们要做一个非常重要的能力:
展示知识库引用来源。
最终效果类似:
前端架构主要包括项目分层、组件设计、状态管理、权限控制、性能优化、工程化和可观测性。
引用来源:
- frontend-notes.md
这一步做完后,我们的 AI 知识库应用会更像真正的 RAG 产品。
为什么引用来源很重要?
RAG 的核心不是“让 AI 回答”,而是“让 AI 基于可检索的知识回答”。
普通聊天机器人可以随便回答,但知识库问答系统更强调:
可追溯
可验证
可解释
减少幻觉
引用来源的价值在于:
1. 告诉用户答案来自哪份文档
2. 增强回答可信度
3. 方便用户回到原文确认
4. 帮助开发者调试知识库召回效果
5. 判断模型有没有脱离上下文发挥
尤其是企业知识库场景,如果 AI 回答后能显示:
引用自:员工手册.pdf
引用自:研发规范.md
引用自:项目介绍.docx
用户会更容易信任这个系统。
Dify 的引用来源在哪里?
在 Dify 的流式响应中,答案片段通常通过 message 事件返回。
类似:
{
"event": "message",
"answer": "前端架构"
}
而引用来源通常会在回答结束时的 message_end 事件中返回。
结构大概是:
{
"event": "message_end",
"conversation_id": "xxx",
"metadata": {
"retriever_resources": [
{
"dataset_name": "frontend-learning-kb",
"document_name": "frontend-notes.md",
"content": "前端架构主要包括项目分层、组件设计、状态管理、权限控制、性能优化、工程化和可观测性。"
}
]
}
}
我们要做的就是解析:
metadata.retriever_resources
然后把它展示到 AI 回答下面。
本篇目标
这一篇完成后,项目会支持:
1. 从 Dify streaming 的 message_end 事件中解析 retriever_resources
2. 保存每条 AI 消息对应的引用来源
3. 在 AI 消息下方展示引用文档名称
4. 为后续展示引用片段、跳转原文打基础
第一步:扩展消息类型
之前我们的消息类型可能是:
export type Role = 'user' | 'assistant'
export type Message = {
role: Role
content: string
}
现在要给 AI 消息增加引用来源。
打开:
src/types/chat.ts
改成:
export type Role = 'user' | 'assistant'
export type Source = {
datasetName?: string
documentName?: string
content?: string
}
export type Message = {
role: Role
content: string
sources?: Source[]
}
这里的 sources 是可选的。
原因是:
用户消息没有引用来源
AI 消息也不一定每次都有引用来源
比如用户问了一个知识库没有命中的问题,或者 Dify 没有返回 retriever_resources,那 sources 就可以为空。
第二步:扩展流式 API 类型
打开:
src/api/difyStream.ts
定义 Dify 返回的引用来源类型:
export type RetrieverResource = {
dataset_name?: string
document_name?: string
content?: string
}
然后在回调类型里增加 onSources:
export type StreamCallbacks = {
onMessage: (text: string) => void
onConversationId?: (conversationId: string) => void
onSources?: (sources: RetrieverResource[]) => void
onError?: (error: Error) => void
onDone?: () => void
}
onSources 的作用是:
当流式回答结束,并且 Dify 返回引用来源时,把 sources 通知给外层组件。
第三步:解析 message_end 事件
在 sendMessageToDifyStream 的 SSE 解析逻辑中,之前我们已经处理了:
if (data.event === 'message' && data.answer) {
callbacks.onMessage(data.answer)
}
现在加上 message_end:
if (data.event === 'message_end') {
const sources = data.metadata?.retriever_resources || []
if (sources.length > 0) {
callbacks.onSources?.(sources)
}
}
完整片段类似:
try {
const data = JSON.parse(jsonStr)
if (data.event === 'message' && data.answer) {
callbacks.onMessage(data.answer)
}
if (data.conversation_id) {
callbacks.onConversationId?.(data.conversation_id)
}
if (data.event === 'message_end') {
const sources = data.metadata?.retriever_resources || []
if (sources.length > 0) {
callbacks.onSources?.(sources)
}
}
if (data.event === 'error') {
callbacks.onError?.(new Error(data.message || 'Dify stream error'))
}
} catch {
// 忽略无法解析的 SSE 行
}
这样,前端就能拿到 Dify 返回的引用来源了。
第四步:把引用来源保存到 AI 消息上
我们之前处理流式回答时,是先插入一条空 AI 消息:
setMessages(prev => [
...prev,
{ role: 'user', content: text },
{ role: 'assistant', content: '' },
])
然后每收到一段 answer,就更新这条 AI 消息的 content。
现在拿到 sources 后,也要更新同一条 AI 消息。
在调用 sendMessageToDifyStream 时增加:
onSources: sources => {
setMessages(prev => {
const next = [...prev]
const current = next[assistantMessageIndex]
if (current) {
next[assistantMessageIndex] = {
...current,
sources: sources.map(source => ({
datasetName: source.dataset_name,
documentName: source.document_name,
content: source.content,
})),
}
}
return next
})
}
这里把 Dify 的字段名转换成了前端更习惯的驼峰命名:
dataset_name → datasetName
document_name → documentName
content → content
这样组件里使用会更自然。
第五步:创建 SourceList 组件
接下来写一个组件专门展示引用来源。
新建:
src/components/SourceList.tsx
写入:
import type { Source } from '../types/chat'
type SourceListProps = {
sources: Source[]
}
export function SourceList({ sources }: SourceListProps) {
if (sources.length === 0) return null
return (
<div className="source-list">
<div className="source-title">引用来源</div>
<ul>
{sources.map((source, index) => (
<li key={index}>
<span>{source.documentName || '未知文档'}</span>
</li>
))}
</ul>
</div>
)
}
第一版我们只展示文档名。
后续可以扩展成:
展示知识库名称
展示引用片段
点击展开原文
显示相似度分数
跳转到原文位置
但第一版先简单一点。
第六步:在 ChatMessage 中展示来源
打开:
src/components/ChatMessage.tsx
引入类型和组件:
import type { Source } from '../types/chat'
import { SourceList } from './SourceList'
把 props 改成:
type ChatMessageProps = {
role: 'user' | 'assistant'
content: string
sources?: Source[]
}
然后在 AI 消息下面加:
{!isUser && sources && sources.length > 0 && (
<SourceList sources={sources} />
)}
完整组件类似:
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import type { Source } from '../types/chat'
import { SourceList } from './SourceList'
type ChatMessageProps = {
role: 'user' | 'assistant'
content: string
sources?: Source[]
}
export function ChatMessage({ role, content, sources }: ChatMessageProps) {
const isUser = role === 'user'
return (
<div className={`message ${isUser ? 'user' : 'ai'}`}>
<strong>{isUser ? '你' : 'AI'}:</strong>
{isUser ? (
<div className="message-text">{content}</div>
) : (
<div className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
>
{content}
</ReactMarkdown>
</div>
)}
{!isUser && sources && sources.length > 0 && (
<SourceList sources={sources} />
)}
</div>
)
}
第七步:App 渲染时传入 sources
原来渲染消息时可能是:
<ChatMessage
key={index}
role={message.role}
content={message.content}
/>
现在改成:
<ChatMessage
key={index}
role={message.role}
content={message.content}
sources={message.sources}
/>
这样每条 AI 消息就可以显示自己的引用来源。
第八步:补充样式
在 CSS 里加入:
.source-list {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #e5e7eb;
font-size: 13px;
color: #4b5563;
}
.source-title {
font-weight: 600;
margin-bottom: 4px;
}
.source-list ul {
margin: 0;
padding-left: 18px;
}
.source-list li {
margin: 2px 0;
}
这样引用来源会作为 AI 消息的一部分显示在回答底部。
第九步:测试引用来源
启动项目:
npm run dev:all
提问:
前端架构主要包括哪些内容?
理想效果是:
前端架构主要包括项目分层、组件设计、状态管理、权限控制、性能优化、工程化和可观测性。
引用来源:
- frontend-notes.md
如果你能看到 frontend-notes.md,说明引用来源已经展示成功。
第十步:如何判断来源是否真的来自知识库?
可以打开浏览器 DevTools → Network。
找到:
/api/chat/stream
查看流式响应里是否有 message_end 事件。
你应该能看到类似:
{
"event": "message_end",
"metadata": {
"retriever_resources": [
{
"dataset_name": "frontend-learning-kb",
"document_name": "frontend-notes.md",
"content": "前端架构主要包括项目分层、组件设计、状态管理、权限控制、性能优化、工程化和可观测性。"
}
]
}
}
如果这里有数据,但页面没展示,说明是前端状态或组件传参问题。
如果这里没有数据,说明 Dify 没有返回引用来源,可能是:
1. 知识检索没有命中
2. Dify 应用配置没有启用相关返回
3. 当前问题没有走知识库
4. Prompt 或流程配置有问题
一个细节:为什么 sources 要放在 Message 上?
因为每一条 AI 回答都应该有自己的引用来源。
如果把 sources 单独放成全局状态,比如:
const [sources, setSources] = useState([])
会有几个问题:
1. 多轮对话时,来源会被后一次回答覆盖
2. 历史消息无法保留各自来源
3. 多会话管理时更容易混乱
4. 后续持久化不好设计
所以更合理的结构是:
type Message = {
role: 'user' | 'assistant'
content: string
sources?: Source[]
}
也就是:
回答内容和引用来源属于同一条 AI 消息
这对后续 localStorage 持久化、多会话管理、数据库存储都更友好。
现在引用来源只展示文档名够吗?
第一版够用。
但从产品体验上看,未来还可以继续优化。
比如可以展示:
1. 文档名称
2. 知识库名称
3. 引用片段
4. 相似度分数
5. 点击展开详情
比如 SourceList 可以扩展成卡片:
引用来源
frontend-notes.md
前端架构主要包括项目分层、组件设计、状态管理...
不过这会带来 UI 和交互复杂度。
当前阶段我们的目标是先把来源链路跑通。
当前版本还有哪些不足?
现在项目已经有:
Dify RAG
Express BFF
流式输出
Markdown 渲染
引用来源
但是 UI 仍然比较像 Demo。
比如:
页面布局不够正式
输入框没有固定底部
消息区不够像聊天产品
空状态比较简陋
组件拆分还不够完整
所以下一篇我们会开始做产品化 UI:
ChatLayout
ChatWindow
ChatInput
ChatMessage
SourceList
EmptyState
让项目从“功能 Demo”变成“像样的 AI 产品界面”。
本篇总结
这一篇我们完成了 RAG 产品很关键的一步:展示引用来源。
具体做了:
1. 扩展 Message 类型,增加 sources 字段
2. 扩展 difyStream 回调,增加 onSources
3. 解析 Dify message_end 事件中的 metadata.retriever_resources
4. 把引用来源保存到对应的 AI 消息上
5. 创建 SourceList 组件
6. 在 ChatMessage 下方展示文档来源
这一步让 AI 回答变得更加可信。
因为用户不再只能看到“AI 说了什么”,还能看到“AI 参考了哪里”。
下一篇我们继续做前端体验升级:
从 Demo 到产品:拆分组件,优化聊天 UI。