项目地址
- 在线预览:frontend-ai-assistant-two.vercel.app/
- GitHub 源码:github.com/hewq/dify-c…
说明:当前在线版本部署在 Vercel,国内网络访问可能不稳定。如果打不开,可以直接查看 GitHub 源码并在本地启动。
前言
上一篇我们给项目补上了真实 AI 产品里很重要的交互能力:
停止生成
错误处理
AbortController
前端取消请求
后端监听连接关闭
到这里为止,项目功能已经比较完整了。
它已经支持:
Dify RAG 知识库
Express BFF 代理
SSE 流式输出
Markdown 渲染
代码高亮
引用来源展示
多会话管理
localStorage 持久化
停止生成
错误处理
但是功能越多,App.tsx 也越来越胖。
现在 App.tsx 里可能同时包含:
input 状态
loading 状态
sessions 状态
activeSessionId 状态
activeSession 派生逻辑
messages 派生逻辑
conversationId 派生逻辑
localStorage 保存逻辑
新建会话
切换会话
删除会话
重命名会话
清空会话
发送消息
流式更新
保存引用来源
停止生成
错误处理
AbortController
这已经不太像一个页面组件了,更像一个“大杂烩”。
这一篇我们做一次状态管理重构,把复杂逻辑拆成两个自定义 Hook:
useChatSessions
useDifyStreamChat
最终目标是:
让 App.tsx 只负责页面组装,而不是承载所有业务细节。
本篇目标
这一篇完成后,项目会变成:
useChatSessions
负责会话管理、本地持久化、当前会话状态
useDifyStreamChat
负责发送消息、流式输出、停止生成、错误处理
App.tsx
只负责把 Hook 和 UI 组件组装起来
也就是说,把原来的复杂组件拆成:
状态逻辑 Hook
请求逻辑 Hook
展示组件
为什么要抽自定义 Hook?
React 自定义 Hook 的价值不是“为了抽而抽”,而是解决几个真实问题:
1. 让组件更短,更容易阅读
2. 把相关状态和操作封装在一起
3. 降低 App.tsx 的认知负担
4. 让业务逻辑更容易测试和复用
5. 为后续接入数据库、登录、云端同步做准备
比如会话管理这部分,本质上和 UI 没有强绑定。
它关心的是:
sessions 怎么存
activeSessionId 是谁
怎么新建会话
怎么删除会话
怎么重命名会话
怎么更新当前会话的 messages
怎么保存到 localStorage
这些逻辑应该放在 useChatSessions 里。
而 Dify 流式请求关心的是:
怎么发送消息
怎么追加 chunk
怎么保存 conversationId
怎么保存 sources
怎么停止生成
怎么处理错误
这些逻辑应该放在 useDifyStreamChat 里。
拆分后的目录结构
这一篇结束后,目录大概是:
src/
api/
difyStream.ts
components/
ChatInput.tsx
ChatWindow.tsx
ChatMessage.tsx
SourceList.tsx
EmptyState.tsx
Sidebar.tsx
hooks/
useChatSessions.ts
useDifyStreamChat.ts
types/
chat.ts
utils/
storage.ts
App.tsx
main.tsx
index.css
新增的是:
hooks/useChatSessions.ts
hooks/useDifyStreamChat.ts
第一步:抽 useChatSessions
新建:
src/hooks/useChatSessions.ts
这个 Hook 负责所有会话相关逻辑。
代码如下:
import { useEffect, useMemo, useState } from 'react'
import type { ChatSession, Message } from '../types/chat'
import {
createEmptySession,
loadChatState,
saveChatState,
updateSessionTitle,
} from '../utils/storage'
export function useChatSessions() {
const initialState = loadChatState()
const [sessions, setSessions] = useState<ChatSession[]>(initialState.sessions)
const [activeSessionId, setActiveSessionId] = useState<string | undefined>(
initialState.activeSessionId
)
const activeSession = useMemo(() => {
return sessions.find(session => session.id === activeSessionId) || sessions[0]
}, [sessions, activeSessionId])
const messages = activeSession?.messages || []
const conversationId = activeSession?.conversationId
useEffect(() => {
saveChatState({
activeSessionId,
sessions,
})
}, [activeSessionId, sessions])
function updateActiveSession(
updater: (session: ChatSession) => ChatSession
) {
setSessions(prev =>
prev.map(session =>
session.id === activeSessionId ? updater(session) : session
)
)
}
function setActiveMessages(updater: (messages: Message[]) => Message[]) {
updateActiveSession(session => {
const nextMessages = updater(session.messages)
return updateSessionTitle({
...session,
messages: nextMessages,
updatedAt: Date.now(),
})
})
}
function setActiveConversationId(conversationId: string | undefined) {
updateActiveSession(session => ({
...session,
conversationId,
updatedAt: Date.now(),
}))
}
function createSession() {
const session = createEmptySession()
setSessions(prev => [session, ...prev])
setActiveSessionId(session.id)
}
function selectSession(sessionId: string) {
setActiveSessionId(sessionId)
}
function deleteSession(sessionId: string) {
setSessions(prev => {
const next = prev.filter(session => session.id !== sessionId)
if (next.length === 0) {
const session = createEmptySession()
setActiveSessionId(session.id)
return [session]
}
if (sessionId === activeSessionId) {
setActiveSessionId(next[0].id)
}
return next
})
}
function renameSession(sessionId: string, title: string) {
setSessions(prev =>
prev.map(session =>
session.id === sessionId
? {
...session,
title,
isTitleManuallyEdited: true,
updatedAt: Date.now(),
}
: session
)
)
}
function clearActiveSession() {
updateActiveSession(session =>
updateSessionTitle({
...session,
messages: [],
conversationId: undefined,
updatedAt: Date.now(),
})
)
}
return {
sessions,
activeSession,
activeSessionId,
messages,
conversationId,
setActiveMessages,
setActiveConversationId,
createSession,
selectSession,
deleteSession,
renameSession,
clearActiveSession,
}
}
useChatSessions 做了什么?
这个 Hook 把所有会话逻辑收敛到一个地方。
它对外暴露:
sessions
activeSession
activeSessionId
messages
conversationId
以及操作方法:
setActiveMessages
setActiveConversationId
createSession
selectSession
deleteSession
renameSession
clearActiveSession
这样 App.tsx 不需要知道 localStorage 怎么存,也不需要关心更新当前会话的细节。
它只需要调用这些方法。
第二步:抽 useDifyStreamChat
接下来抽 Dify 流式聊天逻辑。
新建:
src/hooks/useDifyStreamChat.ts
写入:
import { useRef, useState } from 'react'
import { sendMessageToDifyStream } from '../api/difyStream'
import type { Message } from '../types/chat'
type UseDifyStreamChatOptions = {
getMessages: () => Message[]
getConversationId: () => string | undefined
setMessages: (updater: (messages: Message[]) => Message[]) => void
setConversationId: (conversationId: string | undefined) => void
}
export function useDifyStreamChat({
getMessages,
getConversationId,
setMessages,
setConversationId,
}: UseDifyStreamChatOptions) {
const [loading, setLoading] = useState(false)
const abortControllerRef = useRef<AbortController | null>(null)
async function send(text: string) {
const message = text.trim()
if (!message || loading) return
setLoading(true)
const currentMessages = getMessages()
const assistantMessageIndex = currentMessages.length + 1
setMessages(prev => [
...prev,
{ role: 'user', content: message },
{ role: 'assistant', content: '' },
])
const abortController = new AbortController()
abortControllerRef.current = abortController
try {
await sendMessageToDifyStream(
message,
getConversationId(),
{
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)
},
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
})
},
onError: error => {
setMessages(prev => {
const next = [...prev]
const current = next[assistantMessageIndex]
if (current) {
next[assistantMessageIndex] = {
...current,
content: `请求失败:${error.message}`,
}
}
return next
})
},
onDone: () => {
setLoading(false)
abortControllerRef.current = null
},
},
abortController.signal
)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
setMessages(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
}
}
function stop() {
abortControllerRef.current?.abort()
abortControllerRef.current = null
setLoading(false)
setMessages(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
})
}
return {
loading,
send,
stop,
}
}
useDifyStreamChat 为什么要传 getMessages?
你可能会问:为什么不直接把 messages 传进去?
原因是流式输出涉及异步闭包。
如果直接在 Hook 内部使用某一刻的 messages,可能会拿到旧状态。
这里用的是函数:
getMessages: () => Message[]
getConversationId: () => string | undefined
让发送时可以读取当前最新的状态。
后面在 App 里会配合 useRef 使用。
第三步:重构 App.tsx
现在 App 可以变得清爽很多。
先引入:
import { useRef, useState } from 'react'
import { ChatInput } from './components/ChatInput'
import { ChatWindow } from './components/ChatWindow'
import { Sidebar } from './components/Sidebar'
import { useChatSessions } from './hooks/useChatSessions'
import { useDifyStreamChat } from './hooks/useDifyStreamChat'
import './index.css'
然后重写 App:
function App() {
const [input, setInput] = useState('')
const {
sessions,
activeSessionId,
messages,
conversationId,
setActiveMessages,
setActiveConversationId,
createSession,
selectSession,
deleteSession,
renameSession,
clearActiveSession,
} = useChatSessions()
const messagesRef = useRef(messages)
const conversationIdRef = useRef(conversationId)
messagesRef.current = messages
conversationIdRef.current = conversationId
const { loading, send, stop } = useDifyStreamChat({
getMessages: () => messagesRef.current,
getConversationId: () => conversationIdRef.current,
setMessages: setActiveMessages,
setConversationId: setActiveConversationId,
})
function handleSend(question?: string) {
const text = question ?? input
setInput('')
send(text)
}
function handleSelectSession(sessionId: string) {
if (loading) return
selectSession(sessionId)
}
function handleDeleteSession(sessionId: string) {
if (loading) return
deleteSession(sessionId)
}
function handleNewSession() {
if (loading) return
createSession()
}
function handleClear() {
if (loading) return
clearActiveSession()
}
return (
<div className="app-shell">
<Sidebar
sessions={sessions}
activeSessionId={activeSessionId}
loading={loading}
onNewSession={handleNewSession}
onSelectSession={handleSelectSession}
onDeleteSession={handleDeleteSession}
onRenameSession={renameSession}
/>
<div className="chat-shell">
<header className="app-header">
<div>
<h1>Frontend AI Assistant</h1>
<p>基于 Dify + DeepSeek 的前端知识库助手</p>
</div>
</header>
<main className="app-main">
<ChatWindow
messages={messages}
loading={loading}
onExampleClick={question => handleSend(question)}
/>
</main>
<ChatInput
value={input}
loading={loading}
onChange={setInput}
onSend={() => handleSend()}
onClear={handleClear}
onStop={stop}
/>
</div>
</div>
)
}
export default App
第四步:为什么 App 里还需要 useRef?
这里有两行:
const messagesRef = useRef(messages)
const conversationIdRef = useRef(conversationId)
messagesRef.current = messages
conversationIdRef.current = conversationId
它们的作用是保证 useDifyStreamChat 在发送请求时,能拿到最新的:
messages
conversationId
如果直接把 messages 传进 Hook,在某些异步场景里容易遇到闭包旧值问题。
尤其是流式输出时:
请求开始
↓
状态不断变化
↓
回调持续执行
用 ref 可以让异步逻辑始终读取最新值。
第五步:ChatInput 需要支持 onStop
如果你前一篇已经改过,可以跳过。
ChatInput 的 props 应该包含:
type ChatInputProps = {
value: string
loading: boolean
onChange: (value: string) => void
onSend: () => void
onClear: () => void
onStop: () => void
}
按钮逻辑:
{loading ? (
<button onClick={onStop}>停止生成</button>
) : (
<button onClick={onSend} disabled={!value.trim()}>
发送
</button>
)}
这样 App 中就可以传:
onStop={stop}
第六步:重构后的职责划分
现在项目职责更清晰:
App.tsx
负责:
页面组合
输入框状态
loading 时禁止切换会话
连接 Hook 和组件
useChatSessions
负责:
sessions
activeSessionId
messages
conversationId
localStorage 持久化
新建 / 删除 / 重命名 / 清空会话
useDifyStreamChat
负责:
发送消息
流式更新
引用来源保存
conversationId 保存
停止生成
错误处理
components
负责:
展示 UI
接收 props
触发事件
不关心 Dify 和 localStorage
这就是比较清晰的前端工程结构。
第七步:测试重构是否成功
重构后一定要完整回归测试。
执行:
npm run dev:all
测试清单:
1. 首页能正常打开
2. 示例问题可以点击发送
3. 流式输出正常
4. Markdown 渲染正常
5. 引用来源正常
6. 停止生成正常
7. 新建会话正常
8. 切换会话正常
9. 删除会话正常
10. 重命名会话正常
11. 搜索会话正常
12. 刷新后会话仍然保留
13. 清空当前会话正常
这一步重构不应该改变用户可见功能。
如果功能变了,说明重构过程中引入了行为差异。
常见问题
1. 发送后消息没有更新
检查传给 useDifyStreamChat 的 setMessages 是否是:
setMessages: setActiveMessages
不要传错成普通的 setState。
2. conversationId 没有保存
检查:
setConversationId: setActiveConversationId
以及 onConversationId 是否调用了:
setConversationId(id)
3. 多会话切换后消息串了
检查 messagesRef.current = messages 是否每次渲染都更新。
如果 ref 里还是旧会话消息,流式输出可能写错位置。
4. 停止生成后 loading 不恢复
检查 stop 里是否:
setLoading(false)
以及 onDone 里是否也清理了:
abortControllerRef.current = null
这一步的工程价值
这篇文章没有新增用户功能,但它非常重要。
因为项目从 Demo 走向真实工程时,不能只看功能能不能跑,还要看代码能不能继续维护。
这次重构带来的价值是:
1. App.tsx 明显变薄
2. 会话逻辑和请求逻辑分离
3. localStorage 持久化集中管理
4. 流式请求和停止生成集中管理
5. UI 组件更纯粹
6. 后续迁移数据库更容易
比如后面如果要把 localStorage 换成服务端数据库,主要改 useChatSessions 或它底层的存储逻辑。
如果要把 Dify 换成其他模型平台,主要改 useDifyStreamChat 和 api/difyStream.ts。
这就是分层的好处。
当前版本还有哪些不足?
现在前端结构已经清晰了,但服务端还比较粗糙。
目前服务端可能还是一个 server/index.ts 文件,里面包含:
读取环境变量
Express 初始化
接口定义
Dify 请求
错误处理
流式转发
这和之前的 App.tsx 有点像,也开始变胖了。
所以下一篇我们会继续做工程化:
把 Express 服务端拆成 config、routes、services、types、utils。
让服务端也更像正式项目。
本篇总结
这一篇我们做了一次重要的前端状态管理重构。
主要完成:
1. 新增 useChatSessions
2. 把 sessions / activeSession / localStorage 逻辑收敛到 Hook
3. 新增 useDifyStreamChat
4. 把发送消息 / 流式输出 / 停止生成 / 错误处理收敛到 Hook
5. App.tsx 变成页面组装层
6. 用 useRef 解决异步流式场景下的旧状态问题
7. 保持原有功能不变
现在前端代码结构已经更适合继续扩展。
下一篇继续做服务端工程化:
Express 项目分层实践:config、routes、services、types、utils。