项目地址
- 在线预览:frontend-ai-assistant-two.vercel.app/
- GitHub 源码:github.com/hewq/dify-c…
说明:当前在线版本部署在 Vercel,国内网络访问可能不稳定。如果打不开,可以直接查看 GitHub 源码并在本地启动。
前言
上一篇我们实现了多会话管理。
现在项目已经支持:
新建会话
切换会话
删除会话
重命名会话
搜索会话
每个会话独立保存 messages 和 conversationId
这让项目更像一个真正的 AI 助手。
但真实 AI 产品还必须处理一个很常见的交互:
用户想中途停止 AI 生成。
比如用户问题问错了,或者发现 AI 回答方向不对,或者内容太长不想继续等。
如果没有“停止生成”,用户只能等它输出完,体验会很差。
同时,AI 请求链路比较长:
前端
↓
Express BFF
↓
Dify
↓
模型服务
任何一层都可能出错:
网络断开
Dify 报错
模型额度不足
API Key 配错
请求超时
用户重复点击
所以这一篇我们做两件事:
1. 支持停止生成
2. 增强错误处理
本篇目标
完成后项目会支持:
1. AI 输出时,发送按钮变成“停止生成”
2. 点击停止后,前端立即中断请求
3. 已输出内容保留,并追加“已停止生成”提示
4. 停止后可以继续发送新问题
5. Dify 报错时,页面显示错误信息
6. 后端在客户端断开时尽量中断到 Dify 的请求
这一篇的核心技术点是:
AbortController
AbortSignal
ReadableStream cancel
Express req.close
为什么需要 AbortController?
浏览器里的 fetch 默认发出去后,会一直等待响应。
如果想主动取消请求,需要用 AbortController。
基本用法是:
const controller = new AbortController()
fetch('/api/chat/stream', {
method: 'POST',
signal: controller.signal,
})
controller.abort()
当调用:
controller.abort()
fetch 会被中断,并抛出一个 AbortError。
在流式输出场景里,这正好可以用来实现“停止生成”。
第一步:给流式请求增加 signal 参数
打开:
src/api/difyStream.ts
之前的函数大概是:
export async function sendMessageToDifyStream(
message: string,
conversationId: string | undefined,
callbacks: StreamCallbacks
) {
// ...
}
现在给它增加一个可选的 signal:
export async function sendMessageToDifyStream(
message: string,
conversationId: string | undefined,
callbacks: StreamCallbacks,
signal?: AbortSignal
) {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
signal,
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) {
if (signal?.aborted) {
await reader.cancel()
callbacks.onDone?.()
return
}
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 行
}
}
}
}
核心变化有两个:
signal,
以及:
if (signal?.aborted) {
await reader.cancel()
callbacks.onDone?.()
return
}
这样前端就有能力主动取消流式读取。
第二步:在 App 中保存 AbortController
打开:
src/App.tsx
引入 useRef:
import { useRef, useState } from 'react'
在组件内部加:
const abortControllerRef = useRef<AbortController | null>(null)
为什么用 useRef?
因为 AbortController 不需要触发 UI 重新渲染,只需要在当前组件生命周期里保存一个可变对象。
useRef 正好适合这种场景。
第三步:发送请求时创建 AbortController
在 handleSend 里,调用 sendMessageToDifyStream 前创建:
const abortController = new AbortController()
abortControllerRef.current = abortController
然后把 signal 传进去:
await sendMessageToDifyStream(
text,
conversationId,
{
onMessage: chunk => {
// 更新 AI 消息
},
onConversationId: id => {
setActiveConversationId(id)
},
onSources: sources => {
// 保存引用来源
},
onError: error => {
// 显示错误
},
onDone: () => {
setLoading(false)
abortControllerRef.current = null
},
},
abortController.signal
)
这样每一次 AI 生成请求都有自己的控制器。
第四步:实现停止生成函数
在 App 里新增:
function handleStop() {
abortControllerRef.current?.abort()
abortControllerRef.current = null
setLoading(false)
setActiveMessages(prev => {
const next = [...prev]
const last = next[next.length - 1]
if (last?.role === 'assistant' && last.content.trim()) {
next[next.length - 1] = {
...last,
content: `${last.content}\n\n_已停止生成_`,
}
}
if (last?.role === 'assistant' && !last.content.trim()) {
next[next.length - 1] = {
...last,
content: '_已停止生成_',
}
}
return next
})
}
这里做了几件事:
1. abort 当前请求
2. 清空 ref
3. 设置 loading=false
4. 在最后一条 AI 消息后追加“已停止生成”
如果 AI 已经输出了一部分内容,就保留已有内容。
如果 AI 还没来得及输出,就把空消息改成:
已停止生成
第五步:ChatInput 支持停止按钮
打开:
src/components/ChatInput.tsx
给 props 增加 onStop:
type ChatInputProps = {
value: string
loading: boolean
onChange: (value: string) => void
onSend: () => void
onClear: () => void
onStop: () => void
}
按钮区域改成:
<div className="chat-input-actions">
<button onClick={onClear} disabled={loading}>
清空会话
</button>
{loading ? (
<button onClick={onStop}>停止生成</button>
) : (
<button onClick={onSend} disabled={!value.trim()}>
发送
</button>
)}
</div>
然后 App 中传入:
<ChatInput
value={input}
loading={loading}
onChange={setInput}
onSend={() => handleSend()}
onClear={handleClear}
onStop={handleStop}
/>
现在 AI 输出时,按钮会从“发送”变成“停止生成”。
第六步:区分主动停止和真实错误
当调用 abort() 后,fetch 会抛出 AbortError。
这不应该当成真正的错误展示给用户。
所以 catch 里要区分:
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
console.error(error)
setActiveMessages(prev => {
const next = [...prev]
const current = next[assistantMessageIndex]
if (current) {
next[assistantMessageIndex] = {
...current,
content:
error instanceof Error
? `请求失败:${error.message}`
: '请求失败,请稍后重试。',
}
}
return next
})
setLoading(false)
abortControllerRef.current = null
}
如果是用户主动停止,不需要再插入“请求失败”。
因为 handleStop 已经处理了 UI。
第七步:增强 onError 处理
Dify 流式返回中可能会出现:
{
"event": "error",
"message": "xxx"
}
我们在 difyStream.ts 里已经处理了:
if (data.event === 'error') {
callbacks.onError?.(new Error(data.message || 'Dify stream error'))
}
在 App 中可以把错误展示到当前 AI 消息:
onError: error => {
console.error(error)
setActiveMessages(prev => {
const next = [...prev]
const current = next[assistantMessageIndex]
if (current) {
next[assistantMessageIndex] = {
...current,
content: `请求失败:${error.message}`,
}
}
return next
})
}
这样用户能看到具体错误,而不是页面什么都不显示。
第八步:后端也监听客户端断开
前端 abort 之后,浏览器到 Express 的连接会断开。
但如果 Express 没有处理,后端可能还在继续请求 Dify。
为了减少浪费,我们可以在后端也加 AbortController。
打开:
server/index.ts
在 /api/chat/stream 中增加:
const controller = new AbortController()
req.on('close', () => {
controller.abort()
})
然后在请求 Dify 时传入:
signal: controller.signal,
完整片段类似:
app.post('/api/chat/stream', async (req, res) => {
const controller = new AbortController()
req.on('close', () => {
controller.abort()
})
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',
},
signal: controller.signal,
body: JSON.stringify({
inputs: {},
query: message,
response_mode: 'streaming',
conversation_id: conversationId || '',
user: DIFY_USER,
}),
})
// 后续流式转发逻辑
} catch (error) {
if (controller.signal.aborted) {
return
}
// 真实错误处理
}
})
这样当前端中断请求时,服务端也会尽量中断到 Dify 的请求。
第九步:为什么说“尽量中断”?
因为从浏览器到后端、后端到 Dify、Dify 到模型服务,中间有多层。
调用 abort() 后,可以中断当前 fetch 请求。
但模型侧是否已经停止计费、Dify 是否已经完全停止生成,取决于平台内部实现。
所以更准确地说:
前端停止生成:可以立即停止页面展示
后端中断请求:可以尽量减少无意义的连接和资源消耗
模型是否完全停止:取决于服务商实现
但从产品体验上,用户点击停止后,页面立即停止输出,这一点是确定的。
第十步:测试停止生成
启动项目:
npm run dev:all
测试:
请详细介绍一下前端架构、性能优化、工程化和可观测性
当 AI 正在输出时,点击:
停止生成
预期效果:
1. AI 输出立即停止
2. 最后一条消息末尾出现“已停止生成”
3. loading 结束
4. 按钮恢复成“发送”
5. 可以继续发送新问题
第十一步:测试错误处理
可以临时把 .env 中的 Dify API Key 改错,然后重启服务端。
再发送问题,页面应该显示类似:
请求失败:xxx
而不是一直 loading。
测试完记得把 Key 改回来。
常见错误包括:
DIFY_API_KEY 错误
Dify 应用未发布
DeepSeek 额度不足
模型供应商 Key 失效
网络请求失败
这些错误不一定都能被我们完全处理,但至少应该在页面上有反馈。
当前版本还有什么不足?
这一篇实现了停止生成和错误处理,但 App 的逻辑已经越来越复杂。
现在 App 里可能包含:
sessions
activeSessionId
input
loading
abortControllerRef
handleSend
handleStop
handleClear
handleNewSession
handleDeleteSession
handleRenameSession
setActiveMessages
setActiveConversationId
这已经不是一个很清爽的组件了。
所以下一篇我们会做一次状态管理重构,把逻辑拆成两个 Hook:
useChatSessions
useDifyStreamChat
让 App.tsx 只负责页面组装。
本篇总结
这一篇我们补上了真实 AI 产品里非常重要的交互能力:停止生成和错误处理。
主要做了:
1. 给 sendMessageToDifyStream 增加 AbortSignal
2. fetch 请求支持 signal
3. 流式读取时检测 signal.aborted
4. App 中用 useRef 保存 AbortController
5. 发送请求时创建 controller
6. 点击停止时调用 abort
7. ChatInput 支持“停止生成”按钮
8. catch 中区分 AbortError 和真实错误
9. Dify error 事件展示到页面
10. Express 监听 req.close,尽量中断 Dify 请求
现在项目的交互体验更接近真实 AI 应用了。
下一篇我们继续做工程化重构:
App.tsx 太胖了?用自定义 Hook 拆分状态管理。