项目地址
- 在线预览:frontend-ai-assistant-two.vercel.app/
- GitHub 源码:github.com/hewq/dify-c...
说明:当前在线版本部署在 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. 清空会话时同步清除缓存
现在项目已经可以持续使用,不会一刷新就丢失记录。
下一篇我们继续升级:
实现多会话管理:新建、切换、删除、重命名和搜索。