第 10 篇:实现多会话管理:新建、切换、删除、重命名和搜索

0 阅读8分钟

项目地址

说明:当前在线版本部署在 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 都在当前会话里,所以不能再直接 setMessagessetConversationId

在 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 产品必备交互:

停止生成与错误处理。