第 9 篇:让 AI 助手记住会话:示例问题点击发送与 localStorage 持久化

项目地址

说明:当前在线版本部署在 Vercel,国内网络访问可能不稳定。如果打不开,可以直接查看 GitHub 源码并在本地启动。


前言

上一篇我们做了一次 UI 和组件结构升级。

项目现在已经有了更像 AI 产品的界面:

css 复制代码
顶部 Header
中间消息区
底部输入框
用户 / AI 消息气泡
空状态示例问题
Markdown 渲染
引用来源展示

但当前项目还有一个很明显的问题:

刷新页面后,所有聊天记录都会丢失。

因为现在消息保存在 React state 中:

scss 复制代码
const [messages, setMessages] = useState<Message[]>([])
const [conversationId, setConversationId] = useState<string>()

React state 只存在于当前页面生命周期里。只要刷新页面,状态就会重新初始化。

这篇文章我们先不引入数据库,而是用最简单的方式解决这个问题:

使用 localStorage 保存会话历史和 conversationId。

同时,我们也把空状态里的示例问题改成可点击发送。


本篇目标

完成后项目会支持:

markdown 复制代码
1. 空状态示例问题可以点击发送
2. 消息列表保存到 localStorage
3. Dify conversationId 保存到 localStorage
4. 刷新页面后自动恢复历史会话
5. 清空会话时同步清除本地缓存

这一篇仍然只做单会话持久化。

多会话列表会在下一篇实现。


为什么先用 localStorage?

真正的产品里,会话历史应该保存在服务端数据库里。

比如:

复制代码
SQLite
PostgreSQL
MySQL
MongoDB

但这个阶段我们还没有用户系统,也没有数据库。

如果一上来就引入数据库,会多出很多额外复杂度:

复制代码
表结构设计
接口设计
数据同步
用户身份
服务端存储
迁移脚本

而我们当前最想解决的问题很简单:

复制代码
刷新页面后不要丢失聊天记录

所以 localStorage 是一个很合适的过渡方案。

它的优点是:

markdown 复制代码
1. 使用简单
2. 不需要后端接口
3. 适合本地 Demo 和个人项目
4. 可以快速验证会话持久化体验

缺点也很明显:

markdown 复制代码
1. 只保存在当前浏览器
2. 清缓存会丢失
3. 不能多设备同步
4. 不适合多用户系统
5. 存储容量有限

但作为项目演进的中间阶段,足够用了。


第一步:让示例问题可以点击发送

上一篇的 EmptyState 只是展示示例问题:

xml 复制代码
<div className="example-card" key={example}>
  {example}
</div>

现在我们希望点击示例问题后,直接发送这个问题。

修改:

bash 复制代码
src/components/EmptyState.tsx

改成:

typescript 复制代码
type EmptyStateProps = {
  onExampleClick: (question: string) => void
}

export function EmptyState({ onExampleClick }: EmptyStateProps) {
  const examples = [
    '前端架构主要包括哪些内容?',
    '什么是 RAG?',
    '大型前端项目可以怎么分层?',
  ]

  return (
    <div className="empty-state">
      <h2>Frontend AI Assistant</h2>
      <p>基于你的知识库回答前端学习和架构问题。</p>

      <div className="example-list">
        {examples.map(example => (
          <button
            type="button"
            className="example-card"
            key={example}
            onClick={() => onExampleClick(example)}
          >
            {example}
          </button>
        ))}
      </div>
    </div>
  )
}

注意这里把 div 改成了 button

因为它现在是可交互元素,用 button 更符合语义,也更利于可访问性。


第二步:把 onExampleClick 传给 ChatWindow

修改:

bash 复制代码
src/components/ChatWindow.tsx

给 props 增加 onExampleClick

typescript 复制代码
import { useEffect, useRef } from 'react'
import type { Message } from '../types/chat'
import { ChatMessage } from './ChatMessage'
import { EmptyState } from './EmptyState'

type ChatWindowProps = {
  messages: Message[]
  loading: boolean
  onExampleClick: (question: string) => void
}

export function ChatWindow({
  messages,
  loading,
  onExampleClick,
}: ChatWindowProps) {
  const bottomRef = useRef<HTMLDivElement | null>(null)

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages, loading])

  if (messages.length === 0) {
    return <EmptyState onExampleClick={onExampleClick} />
  }

  return (
    <div className="chat-window">
      {messages.map((message, index) => (
        <ChatMessage
          key={index}
          role={message.role}
          content={message.content}
          sources={message.sources}
        />
      ))}

      {loading && <div className="typing">AI 正在思考...</div>}

      <div ref={bottomRef} />
    </div>
  )
}

这样空状态的点击事件就能传回 App。


第三步:把发送函数改成支持传入指定问题

原来的发送函数大概是:

arduino 复制代码
async function handleSend() {
  const text = input.trim()
  if (!text || loading) return

  // 发送逻辑
}

现在我们希望两种方式都能发送:

markdown 复制代码
1. 用户在输入框输入后点击发送
2. 用户点击空状态示例问题

所以把 handleSend 改成:

scss 复制代码
async function handleSend(question?: string) {
  const text = (question ?? input).trim()

  if (!text || loading) return

  setInput('')
  setLoading(true)

  // 后续发送逻辑保持不变
}

然后在 ChatWindow 中传入:

ini 复制代码
<ChatWindow
  messages={messages}
  loading={loading}
  onExampleClick={question => handleSend(question)}
/>

ChatInput 中仍然这样调用:

ini 复制代码
<ChatInput
  value={input}
  loading={loading}
  onChange={setInput}
  onSend={() => handleSend()}
  onClear={handleClear}
/>

这样示例问题和输入框共用同一套发送逻辑。


第四步:设计本地存储结构

我们要保存两类信息:

复制代码
messages:聊天记录
conversationId:Dify 多轮对话 ID

所以本地存储结构可以设计成:

ini 复制代码
type StoredState = {
  messages: Message[]
  conversationId?: string
}

为什么要保存 conversationId?

因为 Dify 的多轮对话依赖它。

如果只保存 messages,不保存 conversationId,刷新后页面虽然能看到历史消息,但下一次追问时,Dify 会认为这是一个新会话。

保存 conversationId 后,刷新页面继续追问,仍然可以延续同一个 Dify 会话。


第五步:创建 storage 工具函数

新建:

bash 复制代码
src/utils/storage.ts

写入:

javascript 复制代码
import type { Message } from '../types/chat'

const STORAGE_KEY = 'frontend-ai-assistant-state'

type StoredState = {
  messages: Message[]
  conversationId?: string
}

export function loadChatState(): StoredState {
  try {
    const raw = localStorage.getItem(STORAGE_KEY)

    if (!raw) {
      return {
        messages: [],
        conversationId: undefined,
      }
    }

    const parsed = JSON.parse(raw) as StoredState

    return {
      messages: Array.isArray(parsed.messages) ? parsed.messages : [],
      conversationId: parsed.conversationId,
    }
  } catch {
    return {
      messages: [],
      conversationId: undefined,
    }
  }
}

export function saveChatState(state: StoredState) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}

export function clearChatState() {
  localStorage.removeItem(STORAGE_KEY)
}

这里做了几个保护:

markdown 复制代码
1. localStorage 没有数据时返回默认值
2. JSON.parse 失败时返回默认值
3. messages 不是数组时返回空数组

不要假设 localStorage 里的内容永远是可靠的。

用户可能手动改,旧版本数据结构也可能和新版本不一致。


第六步:初始化状态时读取 localStorage

打开:

css 复制代码
src/App.tsx

先引入:

javascript 复制代码
import { useEffect, useState } from 'react'
import {
  clearChatState,
  loadChatState,
  saveChatState,
} from './utils/storage'

然后把原来的状态初始化:

scss 复制代码
const [messages, setMessages] = useState<Message[]>([])
const [conversationId, setConversationId] = useState<string>()

改成:

scss 复制代码
const initialState = loadChatState()

const [messages, setMessages] = useState<Message[]>(initialState.messages)
const [conversationId, setConversationId] = useState<string | undefined>(
  initialState.conversationId
)

这样页面第一次加载时,会优先从 localStorage 恢复历史会话。


第七步:状态变化时自动保存

App 组件中增加:

scss 复制代码
useEffect(() => {
  saveChatState({
    messages,
    conversationId,
  })
}, [messages, conversationId])

这段逻辑表示:

javascript 复制代码
只要 messages 或 conversationId 变化,就保存到 localStorage

所以:

复制代码
用户发送消息 → 保存
AI 流式输出 → 保存
conversationId 返回 → 保存
引用来源更新 → 保存

都会自动持久化。


第八步:清空会话时同步清除本地缓存

原来的清空函数可能是:

scss 复制代码
function handleClear() {
  setMessages([])
  setConversationId(undefined)
}

现在要加上:

scss 复制代码
clearChatState()

完整如下:

scss 复制代码
function handleClear() {
  setMessages([])
  setConversationId(undefined)
  clearChatState()
}

这样点击清空后,刷新页面也不会恢复旧记录。


第九步:补充示例卡片样式

上一篇的 .example-card 是普通展示卡片。

现在它变成了按钮,可以补充 hover 效果:

css 复制代码
.example-card {
  width: 100%;
  background: #ffffff;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  padding: 14px 16px;
  text-align: left;
  color: #374151;
  transition:
    border-color 0.15s ease,
    box-shadow 0.15s ease,
    transform 0.15s ease;
}

.example-card:hover {
  border-color: #2563eb;
  box-shadow: 0 4px 12px rgba(37, 99, 235, 0.12);
  transform: translateY(-1px);
}

这样用户能明显感知它是可点击的。


第十步:测试功能

启动项目:

arduino 复制代码
npm run dev:all

依次测试:

1. 点击示例问题

初始页面点击:

复制代码
前端架构主要包括哪些内容?

应该能直接发送,并开始流式回答。

2. 刷新页面

等待回答完成后刷新页面。

历史消息应该仍然存在。

3. 刷新后继续追问

刷新后继续问:

复制代码
那大型项目怎么分层?

因为 conversationId 被保存了,所以 Dify 仍然可以延续同一个会话。

4. 清空会话

点击清空会话后,再刷新页面。

旧消息不应该再恢复。


localStorage 持久化有什么坑?

1. 服务端渲染环境不能直接访问 localStorage

当前项目是 Vite SPA,所以没问题。

但如果以后迁移到 Next.js,要注意:

javascript 复制代码
localStorage 只存在浏览器环境
服务端渲染时不能直接访问

需要放到 useEffect 或判断 typeof window !== 'undefined'

2. 数据结构升级要兼容旧数据

比如现在存的是:

css 复制代码
{
  messages: [],
  conversationId: 'xxx'
}

下一篇做多会话后,结构会变成:

css 复制代码
{
  activeSessionId: 'xxx',
  sessions: []
}

所以读取 localStorage 时要做好兜底,不能盲目信任旧数据。

3. 不要存敏感信息

localStorage 不是安全存储。

不要把这些内容放进去:

vbnet 复制代码
Dify API Key
DeepSeek API Key
用户密码
敏感 Token

我们这里只存聊天内容和 conversationId。

4. 流式输出会频繁写入

因为 AI 每输出一段,messages 都会更新一次,所以 useEffect 也会频繁写 localStorage。

当前项目规模小,问题不大。

如果后面内容变多,可以考虑:

复制代码
防抖保存
只在 message_end 后保存
迁移到数据库

当前版本的局限

现在已经解决了单会话刷新丢失问题,但还有一个不足:

只有一个会话。

真实 AI 产品一般都有左侧会话列表,比如:

复制代码
新建会话
历史会话
切换会话
删除会话
重命名会话
搜索会话

目前我们的 localStorage 结构只支持一个会话。

下一篇会把它升级成多会话结构:

typescript 复制代码
type ChatSession = {
  id: string
  title: string
  messages: Message[]
  conversationId?: string
  createdAt: number
  updatedAt: number
}

然后实现类似 ChatGPT 的左侧会话列表。


本篇总结

这一篇我们完成了两个可用性增强:

markdown 复制代码
1. 示例问题点击发送
2. localStorage 单会话持久化

具体做了:

markdown 复制代码
1. EmptyState 支持 onExampleClick
2. ChatWindow 透传示例点击事件
3. handleSend 支持传入指定问题
4. 创建 storage 工具函数
5. 保存 messages 和 conversationId
6. 页面刷新后恢复会话
7. 清空会话时同步清除缓存

现在项目已经可以持续使用,不会一刷新就丢失记录。

下一篇我们继续升级:

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

相关推荐
Densen20141 小时前
企业H5站点升级PWA (三)
前端·nginx·c#
做cv的小昊1 小时前
【TJU】应用统计学——第一周作业(1.1 数理统计的基本内容、1.2 数理统计的基本概念)
人工智能·笔记·考研·机器学习·数学建模·概率论
小王毕业啦1 小时前
1990-2024年 省级-绿色金融指数(+文献)
大数据·人工智能·数据挖掘·数据分析·社科数据·实证分析·经管数据
朝阳391 小时前
react【实战】搜索框(含联动动画,清空按钮)
前端·javascript·react.js
victory04311 小时前
CPQS-TUNING 论文引文阅读
人工智能
哎呦哥哥和巨炮叔叔1 小时前
GPT-Image-2最优使用渠道推荐:AI1505一站式AI创作平台,解锁创意新可能
人工智能·ai视频生成·gpt-image-2·ai1505平台·ai生图工具·一站式ai创作平台·免费ai生图
AI模力圈1 小时前
多模态理解类模型技术小结
人工智能·多模态
QuestLab1 小时前
【第25期】2026年4月28日 AI日报
人工智能
新知图书1 小时前
LangGraph 基础图创建思路
人工智能·agent·智能体·langgraph·langchian