项目地址
- 在线预览:frontend-ai-assistant-two.vercel.app/
- GitHub 源码:github.com/hewq/dify-c…
说明:当前在线版本部署在 Vercel,国内网络访问可能不稳定。如果打不开,可以直接查看 GitHub 源码并在本地启动。
前言
上一篇我们完成了一个很关键的架构升级:
React 前端
↓
Express BFF
↓
Dify API
这样 Dify API Key 就不再暴露在浏览器里了。
不过上一篇还有一个体验问题:AI 回答是一次性返回的。
也就是:
用户提问
↓
等待几秒
↓
完整答案一次性出现
这和 ChatGPT 的体验差很多。
真正的 AI 产品通常会让回答逐步输出:
用户提问
↓
AI 立即开始生成
↓
答案一段一段显示
这一篇我们就来实现这个能力。
目标是:
把 Dify 的 blocking 模式改成 streaming 模式,并在前端逐步渲染 AI 回答。
本篇目标
完成后,我们要实现:
1. 后端新增 /api/chat/stream 接口
2. Dify 请求改成 response_mode: streaming
3. Express 将 Dify 流式响应转发给前端
4. 前端读取 ReadableStream
5. 解析 SSE data 行
6. 每收到一段 answer,就追加到最后一条 AI 消息中
最终效果是:
AI 回答像 ChatGPT 一样逐步出现
什么是 SSE?
SSE 全称是 Server-Sent Events。
它是一种服务端向浏览器持续推送数据的方式。
在 AI 场景里,SSE 非常常见。大模型不是一次性生成完整答案,而是一边生成一边返回。
SSE 数据通常长这样:
data: {"event":"message","answer":"前端"}
data: {"event":"message","answer":"架构"}
data: {"event":"message","answer":"主要包括"}
每一段以 data: 开头,后面是 JSON 字符串。
我们要做的事情就是:
读取流
↓
按行拆分
↓
找到 data: 开头的行
↓
JSON.parse
↓
取出 answer
↓
追加到页面
Dify 的 blocking 和 streaming
上一篇我们调用 Dify 时使用的是:
response_mode: 'blocking'
blocking 模式会等大模型生成完整答案后,一次性返回 JSON。
这一篇改成:
response_mode: 'streaming'
streaming 模式会返回 SSE 流。
也就是说,后端不能再简单地:
const data = await response.json()
而是要读取 response.body,并把它继续写给前端。
第一步:新增后端流式接口
打开:
server/index.ts
新增一个接口:
app.post('/api/chat/stream', async (req, res) => {
try {
const { message, conversationId } = req.body
if (!message || typeof message !== 'string') {
return res.status(400).json({ error: 'message is required' })
}
const difyResponse = await fetch(DIFY_API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${DIFY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
inputs: {},
query: message,
response_mode: 'streaming',
conversation_id: conversationId || '',
user: DIFY_USER,
}),
})
if (!difyResponse.ok || !difyResponse.body) {
const errorText = await difyResponse.text()
return res.status(difyResponse.status).json({
error: 'Dify API stream request failed',
detail: errorText,
})
}
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-transform')
res.setHeader('Connection', 'keep-alive')
const reader = difyResponse.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
res.write(chunk)
}
res.end()
} catch (error) {
console.error('[POST /api/chat/stream]', error)
if (!res.headersSent) {
return res.status(500).json({
error: 'Internal server error',
detail: error instanceof Error ? error.message : 'Unknown error',
})
}
res.write(
`data: ${JSON.stringify({
event: 'error',
message: error instanceof Error ? error.message : 'Unknown error',
})}\n\n`
)
res.end()
}
})
这个接口做的事情是:
前端请求 /api/chat/stream
↓
Express 请求 Dify streaming
↓
Express 读取 Dify 返回的流
↓
Express 原样写回给浏览器
为什么后端要设置这些 Header?
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-transform')
res.setHeader('Connection', 'keep-alive')
Content-Type
告诉浏览器这是 SSE 流:
text/event-stream
Cache-Control
避免代理或浏览器缓存、压缩、改写流式内容。
Connection
保持连接不断开。
这些 Header 对流式响应很重要。
第二步:新建前端流式 API
新建:
src/api/difyStream.ts
写入:
export type RetrieverResource = {
dataset_name?: string
document_name?: string
content?: string
}
export type StreamCallbacks = {
onMessage: (text: string) => void
onConversationId?: (conversationId: string) => void
onSources?: (sources: RetrieverResource[]) => void
onError?: (error: Error) => void
onDone?: () => void
}
export async function sendMessageToDifyStream(
message: string,
conversationId: string | undefined,
callbacks: StreamCallbacks
) {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message,
conversationId,
}),
})
if (!response.ok || !response.body) {
const errorText = await response.text()
throw new Error(errorText || '请求失败')
}
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) {
callbacks.onDone?.()
break
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) continue
const jsonStr = trimmed.replace(/^data:\s*/, '')
if (jsonStr === '[DONE]') {
callbacks.onDone?.()
return
}
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 || []
callbacks.onSources?.(sources)
}
if (data.event === 'error') {
callbacks.onError?.(new Error(data.message || 'Dify stream error'))
}
} catch {
// 忽略无法解析的 SSE 行
}
}
}
}
这段代码是整个流式输出的核心。
第三步:为什么需要 buffer?
流式数据不是每次都刚好按完整行返回。
可能一次读取到:
data: {"event":"message","answer":"前
下一次才读到:
端"}
如果直接 JSON.parse,就会报错。
所以我们需要一个 buffer:
let buffer = ''
每次读取后追加:
buffer += decoder.decode(value, { stream: true })
然后按换行拆分:
const lines = buffer.split('\n')
buffer = lines.pop() || ''
最后一段可能是不完整的,先放回 buffer,等下一次数据来了再继续拼。
这是处理流式响应时非常常见的写法。
第四步:修改 App 的发送逻辑
原来我们是:
const result = await sendMessageToDify(text, conversationId)
现在要改成:
await sendMessageToDifyStream(text, conversationId, callbacks)
核心思路是:
1. 用户消息先加入列表
2. 再加入一条空的 AI 消息
3. 每收到一段 answer,就更新最后这条 AI 消息
示例:
import { useState } from 'react'
import { sendMessageToDifyStream } from './api/difyStream'
import type { Message } from './types/chat'
import './App.css'
function App() {
const [input, setInput] = useState('')
const [messages, setMessages] = useState<Message[]>([])
const [conversationId, setConversationId] = useState<string>()
const [loading, setLoading] = useState(false)
async function handleSend() {
const text = input.trim()
if (!text || loading) return
setInput('')
setLoading(true)
const assistantMessageIndex = messages.length + 1
setMessages(prev => [
...prev,
{ role: 'user', content: text },
{ role: 'assistant', content: '' },
])
try {
await sendMessageToDifyStream(text, conversationId, {
onMessage: chunk => {
setMessages(prev => {
const next = [...prev]
const current = next[assistantMessageIndex]
if (current) {
next[assistantMessageIndex] = {
...current,
content: current.content + chunk,
}
}
return next
})
},
onConversationId: id => {
setConversationId(id)
},
onError: error => {
console.error(error)
setMessages(prev => {
const next = [...prev]
const current = next[assistantMessageIndex]
if (current) {
next[assistantMessageIndex] = {
...current,
content: `请求失败:${error.message}`,
}
}
return next
})
},
onDone: () => {
setLoading(false)
},
})
} catch (error) {
console.error(error)
setMessages(prev => {
const next = [...prev]
const current = next[assistantMessageIndex]
if (current) {
next[assistantMessageIndex] = {
...current,
content:
error instanceof Error
? `请求失败:${error.message}`
: '请求失败,请稍后重试。',
}
}
return next
})
setLoading(false)
}
}
return (
<div className="app">
<h1>Frontend AI Assistant</h1>
<div className="messages">
{messages.map((message, index) => (
<div
key={index}
className={`message ${message.role === 'user' ? 'user' : 'ai'}`}
>
<strong>{message.role === 'user' ? '你' : 'AI'}:</strong>
<div>{message.content}</div>
</div>
))}
{loading && <div className="loading">AI 正在思考...</div>}
</div>
<textarea
value={input}
onChange={event => setInput(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSend()
}
}}
placeholder="请输入你的问题,按 Enter 发送,Shift + Enter 换行"
rows={4}
/>
<div className="actions">
<button onClick={handleSend} disabled={loading || !input.trim()}>
{loading ? '回答中...' : '发送'}
</button>
</div>
</div>
)
}
export default App
第五步:为什么先插入一条空 AI 消息?
流式输出不是一次性拿到完整答案。
如果等所有内容结束再插入消息,就失去了流式的意义。
所以我们先插入:
{ role: 'assistant', content: '' }
然后每来一段 chunk,就更新这条消息:
content: current.content + chunk
这样页面就会逐步展示内容。
第六步:测试流式输出
启动项目:
npm run dev:all
打开:
http://localhost:5173
输入:
请介绍一下前端架构主要包括哪些内容
如果一切正常,你会看到 AI 回答不是一次性出现,而是一段一段追加出来。
这说明完整链路已经变成:
React 前端
↓
/api/chat/stream
↓
Express SSE 代理
↓
Dify streaming
↓
前端逐步渲染
第七步:在 Network 里看流式请求
打开 DevTools → Network。
发送一次问题。
你应该能看到请求:
/api/chat/stream
它的响应类型是 event-stream,内容类似:
data: {"event":"message","answer":"前端"}
data: {"event":"message","answer":"架构"}
data: {"event":"message","answer":"主要"}
如果看到这些,说明 Dify 的 streaming 数据已经通过 Express 成功转发到浏览器。
常见问题
1. 前端还是一次性返回
检查后端请求 Dify 时是否改成:
response_mode: 'streaming'
如果仍然是 blocking,就不会流式输出。
2. 页面没有内容,但接口有响应
检查前端是否正确解析了 SSE:
if (!trimmed.startsWith('data:')) continue
以及 Dify 返回的事件字段是否是:
event: message
answer: xxx
3. JSON.parse 经常报错
大概率是没有处理半包问题。
要用 buffer:
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
不要假设每次 reader.read 都能读到完整 JSON。
4. Nginx 或部署后不流式
如果后面部署到 Nginx,要注意关闭缓冲:
proxy_buffering off;
proxy_cache off;
否则服务端可能已经流式返回了,但被 Nginx 缓存后再一次性吐给浏览器。
当前版本还有哪些不足?
这一篇实现了流式输出,但项目还不够完善。
接下来还有几个问题:
1. AI 回答是纯文本
如果回答里有 Markdown、代码块、表格,现在展示效果不好。
下一篇会用:
react-markdown
remark-gfm
rehype-highlight
highlight.js
实现 Markdown 渲染和代码高亮。
2. 引用来源还没显示
Dify 的 message_end 事件里可能会包含:
metadata.retriever_resources
这是 RAG 应用非常重要的引用来源。
后面会展示成:
引用来源:frontend-notes.md
3. 还不能停止生成
如果 AI 正在输出,用户现在不能中断。
后面会用 AbortController 实现停止生成。
4. 刷新页面会丢失会话
当前 messages 仍然存在 React state 里。
后面会先用 localStorage 做本地持久化,再迁移到数据库。
本篇总结
这一篇我们把 AI 聊天体验从 blocking 升级到了 streaming。
关键改动有:
1. 后端新增 /api/chat/stream
2. Dify 请求改为 response_mode: streaming
3. Express 设置 text/event-stream 响应头
4. 后端读取 Dify ReadableStream 并写回前端
5. 前端读取 response.body
6. 解析 SSE data 行
7. 每个 answer chunk 追加到 AI 消息里
现在项目体验已经明显接近真实 AI 产品。
下一篇我们继续优化展示效果:
让 AI 回答支持 Markdown 渲染和代码高亮。