项目地址
- 在线预览:frontend-ai-assistant-two.vercel.app/
- GitHub 源码:github.com/hewq/dify-c…
说明:当前在线版本部署在 Vercel,国内网络访问可能不稳定。如果打不开,可以直接查看 GitHub 源码并在本地启动。
前言
上一篇我们用 localStorage 做了单会话持久化。
现在刷新页面后,聊天记录不会丢了,Dify 的 conversationId 也能保存下来。
但它仍然有一个明显不足:
整个应用只有一个会话。
真实 AI 产品一般都会有多个会话,比如 ChatGPT 左侧的历史会话列表。
用户可能今天问前端架构,明天问性能优化,后天问 TypeScript 类型设计。如果所有内容都堆在一个会话里,很快就会变乱。
所以这一篇我们来实现多会话管理。
最终效果是:
左侧会话列表
+ 新建会话
+ 切换会话
+ 删除会话
+ 自动生成标题
+ 手动重命名
+ 搜索历史会话
+ 刷新后仍然保留
本篇目标
这一篇完成后,项目会支持:
1. 多个 ChatSession
2. 每个会话独立保存 messages
3. 每个会话独立保存 conversationId
4. 支持新建会话
5. 支持切换会话
6. 支持删除会话
7. 支持会话标题自动生成
8. 支持双击重命名
9. 支持搜索会话标题
10. localStorage 保存所有会话
这一篇内容会稍微多一点,因为它涉及状态结构升级。
为什么要做多会话?
单会话适合 Demo。
多会话才更接近真实 AI 产品。
多会话的价值主要有:
1. 不同主题隔离
2. 历史记录更清晰
3. 可以随时回到之前的问题
4. 每个会话维护自己的上下文
5. 后续更容易迁移到数据库
尤其是 Dify 的 conversationId 是按会话维度工作的。
如果多个话题共用一个 conversationId,模型上下文可能会串。
所以更合理的设计是:
一个前端 ChatSession
对应一个 Dify conversationId
第一步:升级数据结构
上一篇 localStorage 存的是:
type StoredState = {
messages: Message[]
conversationId?: string
}
现在要升级为:
type StoredState = {
activeSessionId?: string
sessions: ChatSession[]
}
也就是说,不再直接存一组 messages,而是存多个会话。
打开:
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[]
}
export type ChatSession = {
id: string
title: string
messages: Message[]
conversationId?: string
createdAt: number
updatedAt: number
isTitleManuallyEdited?: boolean
}
字段说明:
id:前端本地会话 ID
title:会话标题
messages:该会话的消息列表
conversationId:Dify 的 conversation_id
createdAt:创建时间
updatedAt:更新时间
isTitleManuallyEdited:标题是否被用户手动修改过
第二步:重写 storage 工具
修改:
src/utils/storage.ts
写入:
import type { ChatSession } from '../types/chat'
const STORAGE_KEY = 'frontend-ai-assistant-sessions'
type StoredState = {
activeSessionId?: string
sessions: ChatSession[]
}
function createSessionTitle(messages: ChatSession['messages']) {
const firstUserMessage = messages.find(message => message.role === 'user')
if (!firstUserMessage) {
return '新的会话'
}
return firstUserMessage.content.slice(0, 20)
}
export function createEmptySession(): ChatSession {
const now = Date.now()
return {
id: crypto.randomUUID(),
title: '新的会话',
messages: [],
conversationId: undefined,
createdAt: now,
updatedAt: now,
isTitleManuallyEdited: false,
}
}
export function loadChatState(): StoredState {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) {
const session = createEmptySession()
return {
activeSessionId: session.id,
sessions: [session],
}
}
const parsed = JSON.parse(raw) as StoredState
if (!Array.isArray(parsed.sessions) || parsed.sessions.length === 0) {
const session = createEmptySession()
return {
activeSessionId: session.id,
sessions: [session],
}
}
return {
activeSessionId: parsed.activeSessionId || parsed.sessions[0].id,
sessions: parsed.sessions,
}
} catch {
const session = createEmptySession()
return {
activeSessionId: session.id,
sessions: [session],
}
}
}
export function saveChatState(state: StoredState) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}
export function updateSessionTitle(session: ChatSession): ChatSession {
if (session.isTitleManuallyEdited) {
return session
}
return {
...session,
title: createSessionTitle(session.messages),
}
}
export function clearChatState() {
localStorage.removeItem(STORAGE_KEY)
}
这里有几个关键点。
1. 默认至少有一个会话
如果 localStorage 里没有数据,就创建一个空会话。
这样 UI 不会出现没有 active session 的异常状态。
2. 标题默认来自第一条用户消息
return firstUserMessage.content.slice(0, 20)
比如用户第一句问:
前端架构主要包括哪些内容?
会话标题会自动变成:
前端架构主要包括哪些内容?
最多截取 20 个字符。
3. 手动改过标题后不再自动覆盖
if (session.isTitleManuallyEdited) {
return session
}
如果用户已经手动重命名,后续继续聊天时,不应该再被第一条消息覆盖。
第三步:新增 Sidebar 组件
多会话需要一个侧边栏。
新建:
src/components/Sidebar.tsx
写入:
import { useMemo, useState } from 'react'
import type { ChatSession } from '../types/chat'
type SidebarProps = {
sessions: ChatSession[]
activeSessionId?: string
loading: boolean
onNewSession: () => void
onSelectSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onRenameSession: (sessionId: string, title: string) => void
}
export function Sidebar({
sessions,
activeSessionId,
loading,
onNewSession,
onSelectSession,
onDeleteSession,
onRenameSession,
}: SidebarProps) {
const [keyword, setKeyword] = useState('')
const [editingId, setEditingId] = useState<string | null>(null)
const [editingTitle, setEditingTitle] = useState('')
const filteredSessions = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase()
return sessions
.filter(session => {
if (!normalizedKeyword) return true
return session.title.toLowerCase().includes(normalizedKeyword)
})
.sort((a, b) => b.updatedAt - a.updatedAt)
}, [sessions, keyword])
function startEdit(session: ChatSession) {
if (loading) return
setEditingId(session.id)
setEditingTitle(session.title)
}
function cancelEdit() {
setEditingId(null)
setEditingTitle('')
}
function submitEdit(sessionId: string) {
const title = editingTitle.trim()
if (title) {
onRenameSession(sessionId, title)
}
cancelEdit()
}
return (
<aside className="sidebar">
<button
className="new-chat-button"
onClick={onNewSession}
disabled={loading}
>
+ 新建会话
</button>
<input
className="session-search"
value={keyword}
onChange={event => setKeyword(event.target.value)}
placeholder="搜索会话"
/>
<div className="session-list">
{filteredSessions.map(session => {
const isEditing = editingId === session.id
return (
<div
key={session.id}
className={`session-item ${
session.id === activeSessionId ? 'active' : ''
}`}
>
{isEditing ? (
<input
className="session-edit-input"
value={editingTitle}
autoFocus
onChange={event => setEditingTitle(event.target.value)}
onBlur={() => submitEdit(session.id)}
onKeyDown={event => {
if (event.key === 'Enter') {
submitEdit(session.id)
}
if (event.key === 'Escape') {
cancelEdit()
}
}}
/>
) : (
<button
className="session-title"
onClick={() => onSelectSession(session.id)}
onDoubleClick={() => startEdit(session)}
title="双击重命名"
disabled={loading}
>
{session.title}
</button>
)}
<button
className="session-delete"
onClick={() => onDeleteSession(session.id)}
title="删除会话"
disabled={loading}
>
×
</button>
</div>
)
})}
</div>
</aside>
)
}
这个组件负责:
新建会话按钮
搜索框
会话列表
当前会话高亮
双击重命名
删除按钮
按更新时间排序
第四步:重构 App 状态
原来 App 里是:
const [messages, setMessages] = useState<Message[]>(initialState.messages)
const [conversationId, setConversationId] = useState<string | undefined>(
initialState.conversationId
)
现在要改成:
const initialState = loadChatState()
const [sessions, setSessions] = useState<ChatSession[]>(initialState.sessions)
const [activeSessionId, setActiveSessionId] = useState<string | undefined>(
initialState.activeSessionId
)
const activeSession =
sessions.find(session => session.id === activeSessionId) || sessions[0]
const messages = activeSession?.messages || []
const conversationId = activeSession?.conversationId
这样当前页面展示的 messages,不再是全局 messages,而是当前 activeSession 里的 messages。
第五步:自动保存多会话状态
原来保存的是:
saveChatState({ messages, conversationId })
现在改成:
useEffect(() => {
saveChatState({
activeSessionId,
sessions,
})
}, [activeSessionId, sessions])
这样会把所有会话一起保存到 localStorage。
第六步:封装更新当前会话的方法
因为现在 messages 和 conversationId 都在当前会话里,所以不能再直接 setMessages 和 setConversationId。
在 App 里加几个辅助函数:
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(),
}))
}
后续所有消息更新都走 setActiveMessages。
第七步:修改发送逻辑
原来流式更新时用的是:
setMessages(prev => { ... })
setConversationId(id)
现在全部改成:
setActiveMessages(prev => { ... })
setActiveConversationId(id)
核心发送逻辑示例:
async function handleSend(question?: string) {
const text = (question ?? input).trim()
if (!text || loading) return
setInput('')
setLoading(true)
const assistantMessageIndex = messages.length + 1
setActiveMessages(prev => [
...prev,
{ role: 'user', content: text },
{ role: 'assistant', content: '' },
])
try {
await sendMessageToDifyStream(text, conversationId, {
onMessage: chunk => {
setActiveMessages(prev => {
const next = [...prev]
const current = next[assistantMessageIndex]
if (current) {
next[assistantMessageIndex] = {
...current,
content: current.content + chunk,
}
}
return next
})
},
onConversationId: id => {
setActiveConversationId(id)
},
onSources: sources => {
setActiveMessages(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
})
},
onDone: () => {
setLoading(false)
},
onError: error => {
console.error(error)
},
})
} catch (error) {
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)
}
}
第八步:实现会话操作
在 App 里加入这些函数:
function handleNewSession() {
if (loading) return
const session = createEmptySession()
setSessions(prev => [session, ...prev])
setActiveSessionId(session.id)
}
function handleSelectSession(sessionId: string) {
if (loading) return
setActiveSessionId(sessionId)
}
function handleDeleteSession(sessionId: string) {
if (loading) return
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 handleRenameSession(sessionId: string, title: string) {
setSessions(prev =>
prev.map(session =>
session.id === sessionId
? {
...session,
title,
isTitleManuallyEdited: true,
updatedAt: Date.now(),
}
: session
)
)
}
function handleClear() {
if (loading) return
updateActiveSession(session =>
updateSessionTitle({
...session,
messages: [],
conversationId: undefined,
updatedAt: Date.now(),
})
)
}
这里有几个注意点:
1. loading 时不允许切换、删除、新建,避免流式输出写错会话
2. 删除最后一个会话时,自动创建一个新会话
3. 删除当前会话时,自动切换到下一个会话
4. 清空只清空当前会话,不影响其他会话
第九步:修改页面布局
之前页面是上下结构:
Header
ChatWindow
ChatInput
现在要加左侧 Sidebar,变成:
Sidebar | Chat Area
App 返回结构可以改成:
return (
<div className="app-shell">
<Sidebar
sessions={sessions}
activeSessionId={activeSessionId}
loading={loading}
onNewSession={handleNewSession}
onSelectSession={handleSelectSession}
onDeleteSession={handleDeleteSession}
onRenameSession={handleRenameSession}
/>
<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}
/>
</div>
</div>
)
第十步:补充 Sidebar 样式
修改 index.css。
原来的 .app-shell 可能是:
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
现在要改成横向布局:
.app-shell {
min-height: 100vh;
display: flex;
}
.chat-shell {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
然后新增 Sidebar 样式:
.sidebar {
width: 260px;
background: #111827;
color: #f9fafb;
padding: 14px;
display: flex;
flex-direction: column;
}
.new-chat-button {
width: 100%;
border: 1px solid #374151;
background: #1f2937;
color: #f9fafb;
border-radius: 10px;
padding: 10px 12px;
text-align: left;
}
.session-search {
width: 100%;
margin-top: 12px;
border: 1px solid #374151;
background: #1f2937;
color: #f9fafb;
border-radius: 8px;
padding: 9px 10px;
outline: none;
}
.session-search::placeholder {
color: #9ca3af;
}
.session-search:focus {
border-color: #60a5fa;
}
.session-list {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
}
.session-item {
display: flex;
align-items: center;
border-radius: 8px;
overflow: hidden;
}
.session-item.active {
background: #374151;
}
.session-title {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
color: #f9fafb;
padding: 9px 10px;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-delete {
border: 0;
background: transparent;
color: #9ca3af;
padding: 8px;
}
.session-delete:hover {
color: #ffffff;
}
.session-edit-input {
flex: 1;
min-width: 0;
border: 1px solid #60a5fa;
background: #111827;
color: #f9fafb;
border-radius: 6px;
padding: 7px 8px;
outline: none;
}
第十一步:测试多会话功能
启动项目:
npm run dev:all
依次测试:
1. 新建会话
点击左侧:
+ 新建会话
应该出现一个空会话,并切换过去。
2. 自动标题
在新会话里问:
前端架构主要包括哪些内容?
会话标题应该自动变成这句话的前 20 个字符。
3. 切换会话
创建多个会话后,点击左侧不同会话。
每个会话应该显示自己的消息,不应该串。
4. 删除会话
点击会话右侧的 ×。
删除当前会话后,应该自动切换到其他会话。
如果删掉最后一个会话,应该自动创建一个新的空会话。
5. 重命名会话
双击会话标题,输入新标题。
按 Enter 保存,按 Esc 取消。
手动改过标题后,继续聊天不应该自动覆盖标题。
6. 搜索会话
在搜索框输入关键词。
左侧只显示标题匹配的会话。
7. 刷新页面
刷新后,会话列表、当前会话、消息内容都应该保留。
一个重要问题:流式输出时能不能切换会话?
这一版我们选择:
loading 时禁止切换、新建、删除会话
原因是:流式输出是异步的,如果用户在 AI 输出过程中切换会话,流式 chunk 可能会写入错误的会话。
更高级的做法是:
每次请求绑定 sessionId
流式回调按 sessionId 精确更新对应会话
允许用户切换会话
但实现复杂度更高。
当前阶段先选择更简单稳妥的方式:生成中不允许切换。
后面重构 Hook 时,也可以继续优化这部分。
当前版本还有什么不足?
现在我们已经有了多会话,但 App 里的状态逻辑明显变复杂了。
比如:
sessions
activeSessionId
activeSession
messages
conversationId
updateActiveSession
setActiveMessages
setActiveConversationId
handleNewSession
handleDeleteSession
handleRenameSession
handleSend
这些逻辑都堆在 App.tsx 里,文件会越来越大。
所以后面需要做状态管理重构,把它们抽成自定义 Hook:
useChatSessions
useDifyStreamChat
但在那之前,下一篇我们先补一个更重要的交互能力:
停止生成与错误处理增强。
本篇总结
这一篇我们完成了从单会话到多会话的升级。
主要做了:
1. 定义 ChatSession 数据结构
2. 升级 localStorage 存储结构
3. 每个会话独立保存 messages 和 conversationId
4. 新增 Sidebar 组件
5. 实现新建会话
6. 实现切换会话
7. 实现删除会话
8. 实现双击重命名
9. 实现搜索历史会话
10. 按 updatedAt 排序
11. 刷新后恢复多会话状态
现在项目已经更接近一个真实 AI 产品了。
下一篇我们继续完善 AI 产品必备交互:
停止生成与错误处理。