对话历史模块
本节重点
本节我们将为 AI 零代码应用生成平台添加对话历史管理功能,让用户能够查看和管理历史对话记录。这一节的核心在于实现完整的对话记忆体系,让 AI 能够基于历史上下文进行网站的迭代优化,用户可以通过多轮对话来完善和优化生成的网站应用。
本节主要内容:
- 对话历史游标方案设计
- 对话历史的保存和查询(后端 + 前端)
- 对话记忆持久化
- Redis 分布式 Session
一、需求分析
在前面的章节中,我们实现了 AI 生成应用的核心功能,但存在一个明显的问题:**每次对话都是独立的,AI 无法记住之前的交互内容。**这导致用户无法基于已生成的网站进行迭代改进,极大地限制了平台的实用性。
举个例子:用户首先让 AI 生成一个博客网站,然后希望在此基础上添加评论功能,最后再优化一下页面样式。在没有对话记忆的情况下,每次都需要重新描述完整需求,AI 也会重新生成整个网站,而不是在原有基础上进行改进。
因此,我们需要实现以下需求:
1)对话历史的持久化存储:用户发送消息时,需要保存用户消息;AI 成功回复后,需要保存 AI 消息。即使 AI 回复失败,也要记录错误信息,确保对话的完整性。
2)应用级别的数据隔离:每个应用的对话历史都是独立的。删除应用时,需要关联删除该应用的所有对话历史,避免数据冗余。
3)对话历史查询:支持分页查看某个应用的对话历史,需要区分用户和 AI 消息。类似聊天软件的消息加载机制,每次加载最新 10 条消息,支持 向前加载 更多历史记录。(仅应用创建者和管理员可见)
详细来说,进入应用页面时,前端根据应用 id 先加载一次对话历史消息,关联查询最新 10 条消息。如果存在历史对话,直接展示;如果没有历史记录,才自动发送初始化提示词。这样就解决了之前浏览别人的应用时意外触发对话的问题
4)管理对话历史:管理员可以查看所有应用的对话历史,按照时间降序排序,便于内容监管。
二、方案设计
分页查询
对于对话历史(聊天记录)的分页查询,不建议使用传统分页查询。
为什么呢?
传统分页查询的问题
在传统分页中,数据通常是 基于页码或偏移量 进行加载的。如果数据在分页过程发生了变化,比如插入新数据、删除老数据,用户看到的分页数据可能会出现不一致,导致用户错过或重复某些数据。
举个例子,假设用户会持续收到新的消息。如果按照传统分页基于偏移量加载,第一页已经加载了第 1 - 5 行的数据,本来要查询的第二页数据是第 6 - 10 行(对应的 SQL 语句为 limit 5, 5),数据库记录如下:
结果在查询第二页前,突然用户又收到了 5 条新消息,数据库记录就变成了下面这样。原本的第一页,变成了当前的第二页!
这样就导致查询出的第二页数据,正好是之前已经查询出的第一页的数据,造成了消息重复加载。
此外,传统的 offset 分页方式在处理大量对话数据时存在严重的性能问题。假设一个热门应用积累了几万条对话记录后,如果用户想查看较早的历史消息,执行 LIMIT 10000, 10 这样的查询会非常缓慢。
这是因为数据库需要先扫描和跳过前面的 10000 条记录,才能返回用户真正需要的 10 条数据。随着 offset 值的增大,查询性能会线性下降,在高并发场景下很容易成为系统瓶颈。
游标查询
为了解决这些问题,可以使用游标分页。使用一个游标来跟踪分页位置,而不是基于页码,每次请求从上一次请求的游标开始加载数据。
一般我们会选择数据记录的唯一标识符(主键)、时间戳、或者具有排序能力的字段作为游标。比如即时通讯系统中的每个消息,通常都有一个唯一自增的 id,就可以作为游标。每次查询完当前页面的数据后,可以将最后一条消息记录的 id 作为游标值传递给前端(客户端)。
当要加载下一页时,前端携带游标值发起查询,后端操作数据库从 id 小于当前游标值的数据开始查询,这样查询结果就不会受到新增数据的影响。
标准实践建议优先使用 id 作为游标,因为主键性能最优且不重复。但针对我们的场景,按时间排序是核心需求,而且同一个 appId 下时间重复的可能性极低,所以直接使用对话历史的创建时间 createTime 作为游标是完全可行的。不需要额外带上对话历史的 id 作为复合游标,简化了游标查询的逻辑。
示例 SQL 语句如下:
SELECT * FROM chat_history
WHERE appId = 123 AND createTime < '2025-07-29 10:30:00'
ORDER BY createTime DESC
LIMIT 10;
而且还可以给 appId 和 createTime 增加复合索引,进一步提高检索效率。这样执行过程就变成了:
- 直接定位到 (appId=123, createTime<‘2025-07-29 10:30:00’) 的索引位置
- 顺序读取 10 条记录
- 完成查询
几乎只有 10 次读取成本!
再举个明显的对比例子,假设一个热门聊天室有 10 万条对话记录,用户要查看 1 个月前的消息(大约在第 3000 页):
| 查询方式 | SQL语句 | 执行时间 | 资源消耗 |
|---|---|---|---|
| Offset 分页 | LIMIT 30000, 10 | 800ms | 需要扫描 30000 条记录 |
| 游标查询 | createTime < ‘2025-07-29’ | 12ms | 直接定位,仅读取 10 条 |
💡 鱼皮之前写过一篇 关于游标的文章,感兴趣的同学可以阅读。
库表设计
1、核心设计
根据我们的方案,可以设计出对话历史表的结构。为了避免与 AI 库的 ChatMessage 冲突,表名采用 chat_history:
-- 对话历史表
create table chat_history
(
id bigint auto_increment comment 'id' primary key,
message text not null comment '消息',
messageType varchar(32) not null comment 'user/ai',
appId bigint not null comment '应用id',
userId bigint not null comment '创建用户id',
createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
isDelete tinyint default 0 not null comment '是否删除',
INDEX idx_appId (appId), -- 提升基于应用的查询性能
INDEX idx_createTime (createTime), -- 提升基于时间的查询性能
INDEX idx_appId_createTime (appId, createTime) -- 游标查询核心索引
) comment '对话历史' collate = utf8mb4_unicode_ci;
这个设计中最关键的是复合索引 idx_appId_createTime,能够大幅提高游标查询的效率。
2、扩展设计
1)可以按需添加 parentId 字段,将 AI 消息和对应的用户提示词进行关联,便于生成失败时的重试、或者用户手动重新生成。
parentId bigint null comment '父消息id(用于上下文关联)',
2)如果需要保存每个版本的代码文件,还可以添加 fileList 字段,结构为 JSON 数组格式,这样每条消息就对应一个代码版本。
不过代码文件很大时,存到数据库里不是一个合适的选择。
三、对话历史后端开发
基础代码生成
首先使用 MyBatis Flex 生成器生成基础代码:
将生成的文件移动到对应的包下。
然后修改 ChatHistory 类,将主键 ID 的生成策略改为雪花算法:
/**
* id
*/
@Id(keyType = KeyType.Generator, value = KeyGenerators.snowFlakeId)
private Long id;
业务代码生成 - Vibe Coding
之前我们已经写了清晰的需求描述,并且项目中已有多个模块的代码,可以给 AI 参考。因此这里直接使用 AI 来生成业务代码。
在 Cursor 中打开 后端项目根目录,执行以下提示词:
请参考项目中已有的 User 和 APP 模块的文件和代码风格,帮我根据下列需求,生成完整的 ChatHistory 模块的后端代码。
## 需要的功能如下
1)对话历史的持久化存储:用户发送消息时,需要保存用户消息;AI 成功回复后,需要保存 AI 消息。即使 AI 回复失败,也要记录错误信息,确保对话的完整性。
2)应用级别的数据隔离:每个应用的对话历史都是独立的。删除应用时,需要关联删除该应用的所有对话历史,避免数据冗余。
3)对话历史查询:支持分页查看某个应用的对话历史,需要区分用户和 AI 消息。类似聊天软件的消息加载机制,每次加载最新 10 条消息,支持 向前加载 更多历史记录。(仅应用创建者和管理员可见)
详细来说,进入应用页面时,前端根据应用 id 先加载一次对话历史消息,关联查询最新 10 条消息。如果存在历史对话,直接展示;如果没有历史记录,才自动发送初始化提示词。这样就解决了之前浏览别人的应用时意外触发对话的问题。
4)管理对话历史:管理员可以查看所有应用的对话历史,按照时间降序排序,便于内容监管。
## 实现提示
1)需要为 messageType 创建一个枚举类
注意,上述提示词中,我特地给 AI 了一个引导,让它为 messageType 创建一个枚举类。
生成过程如图:
AI 生成了完整的枚举类,代码如下:
@Getter
public enum ChatHistoryMessageTypeEnum {
USER("用户", "user"),
AI("AI", "ai");
private final String text;
private final String value;
ChatHistoryMessageTypeEnum(String text, String value) {
this.text = text;
this.value = value;
}
/**
* 根据 value 获取枚举
*
* @param value 枚举值的value
* @return 枚举值
*/
public static ChatHistoryMessageTypeEnum getEnumByValue(String value) {
if (ObjUtil.isEmpty(value)) {
return null;
}
for (ChatHistoryMessageTypeEnum anEnum : ChatHistoryMessageTypeEnum.values()) {
if (anEnum.value.equals(value)) {
return anEnum;
}
}
return null;
}
}
接下来我们需要对照需求,逐一检查和完善生成的代码。
核心功能实现
1、新增对话历史
对话历史的保存需要在 用户发送消息 和 AI 回复完成 这两个时机进行。无论 AI 回复成功还是失败,都要留下完整的对话记录,确保用户能够了解完整的交互历史。
可以在 ChatHistoryServiceImpl 中提供统一的保存接口:
@Override
public boolean addChatMessage(Long appId, String message, String messageType, Long userId) {
ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID不能为空");
ThrowUtils.throwIf(StrUtil.isBlank(message), ErrorCode.PARAMS_ERROR, "消息内容不能为空");
ThrowUtils.throwIf(StrUtil.isBlank(messageType), ErrorCode.PARAMS_ERROR, "消息类型不能为空");
ThrowUtils.throwIf(userId == null || userId <= 0, ErrorCode.PARAMS_ERROR, "用户ID不能为空");
// 验证消息类型是否有效
ChatHistoryMessageTypeEnum messageTypeEnum = ChatHistoryMessageTypeEnum.getEnumByValue(messageType);
ThrowUtils.throwIf(messageTypeEnum == null, ErrorCode.PARAMS_ERROR, "不支持的消息类型: " + messageType);
ChatHistory chatHistory = ChatHistory.builder()
.appId(appId)
.message(message)
.messageType(messageType)
.userId(userId)
.build();
return this.save(chatHistory);
}
然后在 AppServiceImpl 的 chatToGenCode 方法中集成对话历史保存逻辑。这里利用了 Flux 响应式编程的特性,可以在流式响应的过程中收集完整的 AI 回复:
@Resource
private ChatHistoryService chatHistoryService;
@Override
public Flux<String> chatToGenCode(Long appId, String message, User loginUser) {
// ... 前面省略
// 4. 获取应用的代码生成类型
String codeGenTypeStr = app.getCodeGenType();
CodeGenTypeEnum codeGenTypeEnum = CodeGenTypeEnum.getEnumByValue(codeGenTypeStr);
if (codeGenTypeEnum == null) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "不支持的代码生成类型");
}
// 5. 通过校验后,添加用户消息到对话历史
chatHistoryService.addChatMessage(appId, message, ChatHistoryMessageTypeEnum.USER.getValue(), loginUser.getId());
// 6. 调用 AI 生成代码(流式)
Flux<String> contentFlux = aiCodeGeneratorFacade.generateAndSaveCodeStream(message, codeGenTypeEnum, appId);
// 7. 收集AI响应内容并在完成后记录到对话历史
StringBuilder aiResponseBuilder = new StringBuilder();
return contentFlux
.map(chunk -> {
// 收集AI响应内容
aiResponseBuilder.append(chunk);
return chunk;
})
.doOnComplete(() -> {
// 流式响应完成后,添加AI消息到对话历史
String aiResponse = aiResponseBuilder.toString();
if (StrUtil.isNotBlank(aiResponse)) {
chatHistoryService.addChatMessage(appId, aiResponse, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
}
})
.doOnError(error -> {
// 如果AI回复失败,也要记录错误消息
String errorMessage = "AI回复失败: " + error.getMessage();
chatHistoryService.addChatMessage(appId, errorMessage, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
});
}
2、关联删除
当应用被删除时,需要同步清理对话历史数据。
在 ChatHistoryServiceImpl 中提供根据 appId 删除的方法:
@Override
public boolean deleteByAppId(Long appId) {
ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID不能为空");
QueryWrapper queryWrapper = QueryWrapper.create()
.eq("appId", appId);
return this.remove(queryWrapper);
}
然后在 AppServiceImpl 中重写 removeById 方法,实现关联删除:
/**
* 删除应用时关联删除对话历史
*
* @param id 应用ID
* @return 是否成功
*/
@Override
public boolean removeById(Serializable id) {
if (id == null) {
return false;
}
// 转换为 Long 类型
Long appId = Long.valueOf(id.toString());
if (appId <= 0) {
return false;
}
// 先删除关联的对话历史
try {
chatHistoryService.deleteByAppId(appId);
} catch (Exception e) {
// 记录日志但不阻止应用删除
log.error("删除应用关联对话历史失败: {}", e.getMessage());
}
// 删除应用
return super.removeById(id);
}
这里采用了容错设计,即使对话历史删除失败,也不会阻止应用的删除操作,只是记录错误日志,确保核心业务的稳定性。
3、游标查询
游标查询是本节的技术重点,开发时一定要仔细。
1)先在 model.dto.chathistory 包下新建包含游标字段的请求对象:
@EqualsAndHashCode(callSuper = true)
@Data
public class ChatHistoryQueryRequest extends PageRequest implements Serializable {
/**
* id
*/
private Long id;
/**
* 消息内容
*/
private String message;
/**
* 消息类型(user/ai)
*/
private String messageType;
/**
* 应用id
*/
private Long appId;
/**
* 创建用户id
*/
private Long userId;
/**
* 游标查询 - 最后一条记录的创建时间
* 用于分页查询,获取早于此时间的记录
*/
private LocalDateTime lastCreateTime;
private static final long serialVersionUID = 1L;
}
2)在 ChatHistoryServiceImpl 开发查询包装类的构造方法:
/**
* 获取查询包装类
*
* @param chatHistoryQueryRequest
* @return
*/
@Override
public QueryWrapper getQueryWrapper(ChatHistoryQueryRequest chatHistoryQueryRequest) {
QueryWrapper queryWrapper = QueryWrapper.create();
if (chatHistoryQueryRequest == null) {
return queryWrapper;
}
Long id = chatHistoryQueryRequest.getId();
String message = chatHistoryQueryRequest.getMessage();
String messageType = chatHistoryQueryRequest.getMessageType();
Long appId = chatHistoryQueryRequest.getAppId();
Long userId = chatHistoryQueryRequest.getUserId();
LocalDateTime lastCreateTime = chatHistoryQueryRequest.getLastCreateTime();
String sortField = chatHistoryQueryRequest.getSortField();
String sortOrder = chatHistoryQueryRequest.getSortOrder();
// 拼接查询条件
queryWrapper.eq("id", id)
.like("message", message)
.eq("messageType", messageType)
.eq("appId", appId)
.eq("userId", userId);
// 游标查询逻辑 - 只使用 createTime 作为游标
if (lastCreateTime != null) {
queryWrapper.lt("createTime", lastCreateTime);
}
// 排序
if (StrUtil.isNotBlank(sortField)) {
queryWrapper.orderBy(sortField, "ascend".equals(sortOrder));
} else {
// 默认按创建时间降序排列
queryWrapper.orderBy("createTime", false);
}
return queryWrapper;
}
3)在 ChatHistoryServiceImpl 编写核心的游标查询服务方法:
@Override
public Page<ChatHistory> listAppChatHistoryByPage(Long appId, int pageSize,
LocalDateTime lastCreateTime,
User loginUser) {
ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID不能为空");
ThrowUtils.throwIf(pageSize <= 0 || pageSize > 50, ErrorCode.PARAMS_ERROR, "页面大小必须在1-50之间");
ThrowUtils.throwIf(loginUser == null, ErrorCode.NOT_LOGIN_ERROR);
// 验证权限:只有应用创建者和管理员可以查看
App app = appService.getById(appId);
ThrowUtils.throwIf(app == null, ErrorCode.NOT_FOUND_ERROR, "应用不存在");
boolean isAdmin = UserConstant.ADMIN_ROLE.equals(loginUser.getUserRole());
boolean isCreator = app.getUserId().equals(loginUser.getId());
ThrowUtils.throwIf(!isAdmin && !isCreator, ErrorCode.NO_AUTH_ERROR, "无权查看该应用的对话历史");
// 构建查询条件
ChatHistoryQueryRequest queryRequest = new ChatHistoryQueryRequest();
queryRequest.setAppId(appId);
queryRequest.setLastCreateTime(lastCreateTime);
QueryWrapper queryWrapper = this.getQueryWrapper(queryRequest);
// 查询数据
return this.page(Page.of(1, pageSize), queryWrapper);
}
4)最后在 ChatHistoryController 开发游标查询接口:
/**
* 分页查询某个应用的对话历史(游标查询)
*
* @param appId 应用ID
* @param pageSize 页面大小
* @param lastCreateTime 最后一条记录的创建时间
* @param request 请求
* @return 对话历史分页
*/
@GetMapping("/app/{appId}")
public BaseResponse<Page<ChatHistory>> listAppChatHistory(@PathVariable Long appId,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) LocalDateTime lastCreateTime,
HttpServletRequest request) {
User loginUser = userService.getLoginUser(request);
Page<ChatHistory> result = chatHistoryService.listAppChatHistoryByPage(appId, pageSize, lastCreateTime, loginUser);
return ResultUtils.success(result);
}
4、管理员查询功能
管理员可以分页查询所有应用的对话历史消息列表,按照时间降序排序。
这个功能比较简单,直接开发分页查询接口就好:
/**
* 管理员分页查询所有对话历史
*
* @param chatHistoryQueryRequest 查询请求
* @return 对话历史分页
*/
@PostMapping("/admin/list/page/vo")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Page<ChatHistory>> listAllChatHistoryByPageForAdmin(@RequestBody ChatHistoryQueryRequest chatHistoryQueryRequest) {
ThrowUtils.throwIf(chatHistoryQueryRequest == null, ErrorCode.PARAMS_ERROR);
long pageNum = chatHistoryQueryRequest.getPageNum();
long pageSize = chatHistoryQueryRequest.getPageSize();
// 查询数据
QueryWrapper queryWrapper = chatHistoryService.getQueryWrapper(chatHistoryQueryRequest);
Page<ChatHistory> result = chatHistoryService.page(Page.of(pageNum, pageSize), queryWrapper);
return ResultUtils.success(result);
}
接下来就可以测试了,如果在开发过程中遇到循环依赖问题导致项目无法启动,可以使用 @Lazy 注解解决。比如在 ChatHistoryServiceImpl 引入 AppService 时添加注解:
@Resource
@Lazy
private AppService appService;
四、对话历史前端开发
后端开发完成后,记得先执行 openapi 命令,根据接口生成前端请求和数据模型代码。
功能实现 - Vibe Coding
这次的前端开发量并不大,核心就 2 点:
- 修改应用对话页面,加载对话历史
- 新增对话管理页面
可以直接交给 AI 来生成:
你是一位专业的前端开发,帮我根据下列信息,参考项目已有的代码风格,生成符合要求的完整代码。
## 需求
1)修改应用对话页面。
- 进入应用对话页面时,前端调用游标查询对话历史接口,根据应用 id 加载一次最近 10 条对话历史消息,按照消息创建时间的升序展示在对话区域(区分 AI 和用户消息)。
- 如果消息数量超过 10 条(10 条为一页),可以点消息上方的加载更多,利用游标加载下一页历史消息。(我想前端需要维护已加载的消息列表)
- 修改自动发送初始消息的逻辑。移除之前页面 url 的 view 参数,如果是自己的 app,并且没有对话历史,才自动将 initPrompt 作为第一条消息触发对话。
- 修改网站展示的逻辑。进入页面时,如果 app 有至少 2 条对话记录,也展示对应的网站。
2)新增对话管理页面。完全参考应用管理页面实现
## 后端接口
已经在 @api 目录下生成了后端请求代码和数据类型信息。
AI 生成的代码如下:
1)路由文件新增对话管理页:
{
path: '/admin/chatManage',
name: '对话管理',
component: ChatManagePage,
},
2)修改应用对话生成页面,完整代码如下:
3)新增对话管理页面,完整代码如下:
<template>
<div id="appChatPage">
<!-- 顶部栏 -->
<div class="header-bar">
<div class="header-left">
<h1 class="app-name">{{ appInfo?.appName || '网站生成器' }}</h1>
</div>
<div class="header-right">
<a-button type="default" @click="showAppDetail">
<template #icon>
<InfoCircleOutlined />
</template>
应用详情
</a-button>
<a-button type="primary" @click="deployApp" :loading="deploying">
<template #icon>
<CloudUploadOutlined />
</template>
部署按钮
</a-button>
</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<!-- 左侧对话区域 -->
<div class="chat-section">
<!-- 消息区域 -->
<div class="messages-container" ref="messagesContainer">
<!-- 加载更多按钮 -->
<div v-if="hasMoreHistory" class="load-more-container">
<a-button type="link" @click="loadMoreHistory" :loading="loadingHistory" size="small">
加载更多历史消息
</a-button>
</div>
<div v-for="(message, index) in messages" :key="index" class="message-item">
<div v-if="message.type === 'user'" class="user-message">
<div class="message-content">{{ message.content }}</div>
<div class="message-avatar">
<a-avatar :src="loginUserStore.loginUser.userAvatar" />
</div>
</div>
<div v-else class="ai-message">
<div class="message-avatar">
<a-avatar :src="aiAvatar" />
</div>
<div class="message-content">
<MarkdownRenderer v-if="message.content" :content="message.content" />
<div v-if="message.loading" class="loading-indicator">
<a-spin size="small" />
<span>AI 正在思考...</span>
</div>
</div>
</div>
</div>
</div>
<!-- 用户消息输入框 -->
<div class="input-container">
<div class="input-wrapper">
<a-tooltip v-if="!isOwner" title="无法在别人的作品下对话哦~" placement="top">
<a-textarea
v-model:value="userInput"
placeholder="请描述你想生成的网站,越详细效果越好哦"
:rows="4"
:maxlength="1000"
@keydown.enter.prevent="sendMessage"
:disabled="isGenerating || !isOwner"
/>
</a-tooltip>
<a-textarea
v-else
v-model:value="userInput"
placeholder="请描述你想生成的网站,越详细效果越好哦"
:rows="4"
:maxlength="1000"
@keydown.enter.prevent="sendMessage"
:disabled="isGenerating"
/>
<div class="input-actions">
<a-button
type="primary"
@click="sendMessage"
:loading="isGenerating"
:disabled="!isOwner"
>
<template #icon>
<SendOutlined />
</template>
</a-button>
</div>
</div>
</div>
</div>
<!-- 右侧网页展示区域 -->
<div class="preview-section">
<div class="preview-header">
<h3>生成后的网页展示</h3>
<div class="preview-actions">
<a-button v-if="previewUrl" type="link" @click="openInNewTab">
<template #icon>
<ExportOutlined />
</template>
新窗口打开
</a-button>
</div>
</div>
<div class="preview-content">
<div v-if="!previewUrl && !isGenerating" class="preview-placeholder">
<div class="placeholder-icon">🌐</div>
<p>网站文件生成完成后将在这里展示</p>
</div>
<div v-else-if="isGenerating" class="preview-loading">
<a-spin size="large" />
<p>正在生成网站...</p>
</div>
</div>
</div>
</div>
<!-- 应用详情弹窗 -->
<AppDetailModal
v-model:open="appDetailVisible"
:app="appInfo"
:show-actions="isOwner || isAdmin"
@edit="editApp"
@delete="deleteApp"
/>
<!-- 部署成功弹窗 -->
<DeploySuccessModal
v-model:open="deployModalVisible"
:deploy-url="deployUrl"
@open-site="openDeployedSite"
/>
</div>
</template>
import { ref, onMounted, nextTick, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { useLoginUserStore } from '@/stores/loginUser'
import {
getAppVoById,
deployApp as deployAppApi,
deleteApp as deleteAppApi,
} from '@/api/appController'
import { listAppChatHistory } from '@/api/chatHistoryController'
import { CodeGenTypeEnum } from '@/utils/codeGenTypes'
import request from '@/request'
import MarkdownRenderer from '@/components/MarkdownRenderer.vue'
import AppDetailModal from '@/components/AppDetailModal.vue'
import DeploySuccessModal from '@/components/DeploySuccessModal.vue'
import aiAvatar from '@/assets/aiAvatar.png'
import { API_BASE_URL, getStaticPreviewUrl } from '@/config/env'
import {
CloudUploadOutlined,
SendOutlined,
ExportOutlined,
InfoCircleOutlined,
} from '@ant-design/icons-vue'
const route = useRoute()
const router = useRouter()
const loginUserStore = useLoginUserStore()
// 应用信息
const appInfo = ref<API.AppVO>()
const appId = ref<any>()
// 对话相关
interface Message {
type: 'user' | 'ai'
content: string
loading?: boolean
createTime?: string
}
const messages = ref<Message[]>([])
const userInput = ref('')
const isGenerating = ref(false)
const messagesContainer = ref<HTMLElement>()
// 对话历史相关
const loadingHistory = ref(false)
const hasMoreHistory = ref(false)
const lastCreateTime = ref<string>()
const historyLoaded = ref(false)
// 预览相关
const previewUrl = ref('')
const previewReady = ref(false)
// 部署相关
const deploying = ref(false)
const deployModalVisible = ref(false)
const deployUrl = ref('')
// 权限相关
const isOwner = computed(() => {
return appInfo.value?.userId === loginUserStore.loginUser.id
})
const isAdmin = computed(() => {
return loginUserStore.loginUser.userRole === 'admin'
})
// 应用详情相关
const appDetailVisible = ref(false)
// 显示应用详情
const showAppDetail = () => {
appDetailVisible.value = true
}
// 加载对话历史
const loadChatHistory = async (isLoadMore = false) => {
if (!appId.value || loadingHistory.value) return
loadingHistory.value = true
try {
const params: API.listAppChatHistoryParams = {
appId: appId.value,
pageSize: 10,
}
// 如果是加载更多,传递最后一条消息的创建时间作为游标
if (isLoadMore && lastCreateTime.value) {
params.lastCreateTime = lastCreateTime.value
}
const res = await listAppChatHistory(params)
if (res.data.code === 0 && res.data.data) {
const chatHistories = res.data.data.records || []
if (chatHistories.length > 0) {
// 将对话历史转换为消息格式,并按时间正序排列(老消息在前)
const historyMessages: Message[] = chatHistories
.map((chat) => ({
type: (chat.messageType === 'user' ? 'user' : 'ai') as 'user' | 'ai',
content: chat.message || '',
createTime: chat.createTime,
}))
.reverse() // 反转数组,让老消息在前
if (isLoadMore) {
// 加载更多时,将历史消息添加到开头
messages.value.unshift(...historyMessages)
} else {
// 初始加载,直接设置消息列表
messages.value = historyMessages
}
// 更新游标
lastCreateTime.value = chatHistories[chatHistories.length - 1]?.createTime
// 检查是否还有更多历史
hasMoreHistory.value = chatHistories.length === 10
} else {
hasMoreHistory.value = false
}
historyLoaded.value = true
}
} catch (error) {
console.error('加载对话历史失败:', error)
message.error('加载对话历史失败')
} finally {
loadingHistory.value = false
}
}
// 加载更多历史消息
const loadMoreHistory = async () => {
await loadChatHistory(true)
}
// 获取应用信息
const fetchAppInfo = async () => {
const id = route.params.id as string
if (!id) {
message.error('应用ID不存在')
router.push('/')
return
}
appId.value = id
try {
const res = await getAppVoById({ id: id as unknown as number })
if (res.data.code === 0 && res.data.data) {
appInfo.value = res.data.data
// 先加载对话历史
await loadChatHistory()
// 如果有至少2条对话记录,展示对应的网站
if (messages.value.length >= 2) {
updatePreview()
}
// 检查是否需要自动发送初始提示词
// 只有在是自己的应用且没有对话历史时才自动发送
if (
appInfo.value.initPrompt &&
isOwner.value &&
messages.value.length === 0 &&
historyLoaded.value
) {
await sendInitialMessage(appInfo.value.initPrompt)
}
} else {
message.error('获取应用信息失败')
router.push('/')
}
} catch (error) {
console.error('获取应用信息失败:', error)
message.error('获取应用信息失败')
router.push('/')
}
}
// 发送初始消息
const sendInitialMessage = async (prompt: string) => {
// 添加用户消息
messages.value.push({
type: 'user',
content: prompt,
})
// 添加AI消息占位符
const aiMessageIndex = messages.value.length
messages.value.push({
type: 'ai',
content: '',
loading: true,
})
await nextTick()
scrollToBottom()
// 开始生成
isGenerating.value = true
await generateCode(prompt, aiMessageIndex)
}
// 发送消息
const sendMessage = async () => {
if (!userInput.value.trim() || isGenerating.value) {
return
}
const message = userInput.value.trim()
userInput.value = ''
// 添加用户消息
messages.value.push({
type: 'user',
content: message,
})
// 添加AI消息占位符
const aiMessageIndex = messages.value.length
messages.value.push({
type: 'ai',
content: '',
loading: true,
})
await nextTick()
scrollToBottom()
// 开始生成
isGenerating.value = true
await generateCode(message, aiMessageIndex)
}
// 生成代码 - 使用 EventSource 处理流式响应
const generateCode = async (userMessage: string, aiMessageIndex: number) => {
let eventSource: EventSource | null = null
let streamCompleted = false
try {
// 获取 axios 配置的 baseURL
const baseURL = request.defaults.baseURL || API_BASE_URL
// 构建URL参数
const params = new URLSearchParams({
appId: appId.value || '',
message: userMessage,
})
const url = `${baseURL}/app/chat/gen/code?${params}`
// 创建 EventSource 连接
eventSource = new EventSource(url, {
withCredentials: true,
})
let fullContent = ''
// 处理接收到的消息
eventSource.onmessage = function (event) {
if (streamCompleted) return
try {
// 解析JSON包装的数据
const parsed = JSON.parse(event.data)
const content = parsed.d
// 拼接内容
if (content !== undefined && content !== null) {
fullContent += content
messages.value[aiMessageIndex].content = fullContent
messages.value[aiMessageIndex].loading = false
scrollToBottom()
}
} catch (error) {
console.error('解析消息失败:', error)
handleError(error, aiMessageIndex)
}
}
// 处理done事件
eventSource.addEventListener('done', function () {
if (streamCompleted) return
streamCompleted = true
isGenerating.value = false
eventSource?.close()
// 延迟更新预览,确保后端已完成处理
setTimeout(async () => {
await fetchAppInfo()
updatePreview()
}, 1000)
})
// 处理错误
eventSource.onerror = function () {
if (streamCompleted || !isGenerating.value) return
// 检查是否是正常的连接关闭
if (eventSource?.readyState === EventSource.CONNECTING) {
streamCompleted = true
isGenerating.value = false
eventSource?.close()
setTimeout(async () => {
await fetchAppInfo()
updatePreview()
}, 1000)
} else {
handleError(new Error('SSE连接错误'), aiMessageIndex)
}
}
} catch (error) {
console.error('创建 EventSource 失败:', error)
handleError(error, aiMessageIndex)
}
}
// 错误处理函数
const handleError = (error: unknown, aiMessageIndex: number) => {
console.error('生成代码失败:', error)
messages.value[aiMessageIndex].content = '抱歉,生成过程中出现了错误,请重试。'
messages.value[aiMessageIndex].loading = false
message.error('生成失败,请重试')
isGenerating.value = false
}
// 更新预览
const updatePreview = () => {
if (appId.value) {
const codeGenType = appInfo.value?.codeGenType || CodeGenTypeEnum.HTML
const newPreviewUrl = getStaticPreviewUrl(codeGenType, appId.value)
previewUrl.value = newPreviewUrl
previewReady.value = true
}
}
// 滚动到底部
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
// 部署应用
const deployApp = async () => {
if (!appId.value) {
message.error('应用ID不存在')
return
}
deploying.value = true
try {
const res = await deployAppApi({
appId: appId.value as unknown as number,
})
if (res.data.code === 0 && res.data.data) {
deployUrl.value = res.data.data
deployModalVisible.value = true
message.success('部署成功')
} else {
message.error('部署失败:' + res.data.message)
}
} catch (error) {
console.error('部署失败:', error)
message.error('部署失败,请重试')
} finally {
deploying.value = false
}
}
// 在新窗口打开预览
const openInNewTab = () => {
if (previewUrl.value) {
window.open(previewUrl.value, '_blank')
}
}
// 打开部署的网站
const openDeployedSite = () => {
if (deployUrl.value) {
window.open(deployUrl.value, '_blank')
}
}
// iframe加载完成
const => {
previewReady.value = true
}
// 编辑应用
const editApp = () => {
if (appInfo.value?.id) {
router.push(`/app/edit/${appInfo.value.id}`)
}
}
// 删除应用
const deleteApp = async () => {
if (!appInfo.value?.id) return
try {
const res = await deleteAppApi({ id: appInfo.value.id })
if (res.data.code === 0) {
message.success('删除成功')
appDetailVisible.value = false
router.push('/')
} else {
message.error('删除失败:' + res.data.message)
}
} catch (error) {
console.error('删除失败:', error)
message.error('删除失败')
}
}
// 页面加载时获取应用信息
onMounted(() => {
fetchAppInfo()
})
// 清理资源
onUnmounted(() => {
// EventSource 会在组件卸载时自动清理
})
#appChatPage {
height: 100vh;
display: flex;
flex-direction: column;
padding: 16px;
background: #fdfdfd;
}
/* 顶部栏 */
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.app-name {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1a1a1a;
}
.header-right {
display: flex;
gap: 12px;
}
/* 主要内容区域 */
.main-content {
flex: 1;
display: flex;
gap: 16px;
padding: 8px;
overflow: hidden;
}
/* 左侧对话区域 */
.chat-section {
flex: 2;
display: flex;
flex-direction: column;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.messages-container {
flex: 1;
padding: 16px;
overflow-y: auto;
scroll-behavior: smooth;
}
.message-item {
margin-bottom: 12px;
}
.user-message {
display: flex;
justify-content: flex-end;
align-items: flex-start;
gap: 8px;
}
.ai-message {
display: flex;
justify-content: flex-start;
align-items: flex-start;
gap: 8px;
}
.message-content {
max-width: 70%;
padding: 12px 16px;
border-radius: 12px;
line-height: 1.5;
word-wrap: break-word;
}
.user-message .message-content {
background: #1890ff;
color: white;
}
.ai-message .message-content {
background: #f5f5f5;
color: #1a1a1a;
padding: 8px 12px;
}
.message-avatar {
flex-shrink: 0;
}
.loading-indicator {
display: flex;
align-items: center;
gap: 8px;
color: #666;
}
/* 加载更多按钮 */
.load-more-container {
text-align: center;
padding: 8px 0;
margin-bottom: 16px;
}
/* 输入区域 */
.input-container {
padding: 16px;
background: white;
}
.input-wrapper {
position: relative;
}
.input-wrapper .ant-input {
padding-right: 50px;
}
.input-actions {
position: absolute;
bottom: 8px;
right: 8px;
}
/* 右侧预览区域 */
.preview-section {
flex: 3;
display: flex;
flex-direction: column;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e8e8e8;
}
.preview-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.preview-actions {
display: flex;
gap: 8px;
}
.preview-content {
flex: 1;
position: relative;
overflow: hidden;
}
.preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
}
.placeholder-icon {
font-size: 48px;
margin-bottom: 16px;
}
.preview-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
}
.preview-loading p {
margin-top: 16px;
}
.preview-iframe {
width: 100%;
height: 100%;
border: none;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.main-content {
flex-direction: column;
}
.chat-section,
.preview-section {
flex: none;
height: 50vh;
}
}
@media (max-width: 768px) {
.header-bar {
padding: 12px 16px;
}
.app-name {
font-size: 16px;
}
.main-content {
padding: 8px;
gap: 8px;
}
.message-content {
max-width: 85%;
}
}
其中,游标查询和消息列表维护是前端的技术重点,代码如下:
// 核心状态定义
const messages = ref<Message[]>([]) // 消息列表
const loadingHistory = ref(false) // 加载状态
const hasMoreHistory = ref(false) // 是否还有更多历史
const lastCreateTime = ref<string>() // 游标:最后一条消息的创建时间
// 加载对话历史的核心逻辑
const loadChatHistory = async (isLoadMore = false) => {
if (loadingHistory.value) return
loadingHistory.value = true
try {
// 构建请求参数
const params = {
pageSize: 10,
// 关键:使用最后一条消息时间作为游标
lastCreateTime: isLoadMore ? lastCreateTime.value : undefined
}
const res = await api.getChatHistory(params)
const chatHistories = res.data.records || []
if (chatHistories.length > 0) {
// 将后端数据转换为前端消息格式
const historyMessages = chatHistories
.map(chat => ({
type: chat.messageType === 'user' ? 'user' : 'ai',
content: chat.message || '',
createTime: chat.createTime,
}))
.reverse() // 关键:反转让老消息在前
if (isLoadMore) {
// 加载更多:添加到列表开头
messages.value.unshift(...historyMessages)
} else {
// 初始加载:直接设置
messages.value = historyMessages
}
// 关键:更新游标为最老消息的时间
lastCreateTime.value = chatHistories[chatHistories.length - 1]?.createTime
// 判断是否还有更多数据
hasMoreHistory.value = chatHistories.length === 10
} else {
hasMoreHistory.value = false
}
} finally {
loadingHistory.value = false
}
}
这段代码的重点是:
- 状态管理:通过
lastCreateTime维护游标状态,确保分页的连续性 - 消息排序:后端返回的是时间降序,前端需要反转后再添加到列表
- 增量加载:新消息添加到数组开头,保持时间顺序的正确性
- 边界处理:通过返回记录数判断是否还有更多数据
常见问题 - 消息顺序相反
在开发过程中可能会遇到消息顺序相反的问题,比如下图:
可以让 AI 修复问题,轻轻松松~
目前加载后展示的消息顺序反了,应该像聊天记录一样上方展示老的,下方展示新的
通过调整数据处理逻辑,确保消息按照正确的时间顺序展示:
五、对话记忆
需求分析
很多时候 AI 生成的网站没办法一次性满足用户的需求,因此需要提供网站修改功能。
但是目前我们的 AI 对话会断片儿,无法记住之前的对话内容,每次修改实际上都是重新生成完整的网站,而不是在原有基础上进行修改。


这种情况下,用户不能进行迭代式的网站开发,每次都是开盲盒,极大地限制了平台的实用性。
方案设计
要解决这个问题,就要给 AI 增加对话记忆能力。
保存到哪儿?
LangChain4j 不仅提供了对话记忆能力,而且还能结合 Redis 持久化对话记忆,非常爽~
这里可能有同学会好奇 2 个问题:
1)为什么不直接用内存来存储会话记忆?
首先是重启后会丢失记忆;其次如果每个应用都在内存中维护对话历史,很容易出现 OOM 问题。
2)为什么不用 MySQL 来存储会话记忆?
一方面是因为 Redis 作为内存数据库,在读写对话记忆时性能更高;另一方面是数据库中的对话历史表包含其他业务字段,不适合直接交给 LangChain4j 的对话记忆组件管理。
加载历史
要注意,**Redis 的内存也不是无限的!**一般情况下要给存入 Redis 的每个 Key 都设置合理的过期时间,不能不过期。所以这就可能导致 Redis 的会话记忆被删除的情况。
怎么解决呢?
方案很简单,之前我们已经在数据库中保存了用户和 AI 的消息,只需要在初始化会话记忆时,加载最新的对话记录到 Redis 中,就能确保 AI 了解交互历史。
流程:AI 对话 => 从数据库中加载对话历史到 Redis => Redis 为 AI 提供对话记忆
对话隔离
此外,每个应用的对话记忆应该是相互隔离的,LangChain4j 也提供了对话记忆隔离的能力,下面开发实现中会讲解。
开发实现
1、引入依赖
参考 LangChain4j 官方文档,引入必要的依赖:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-redis-spring-boot-starter</artifactId>
<version>1.1.0-beta7</version>
</dependency>
这个依赖会引入 Redis 的 Jedis 客户端,以及与 LangChain4j 的整合组件。
2、配置 Redis
1)在配置文件中添加 Redis 连接信息:
spring:
# redis
data:
redis:
host: localhost
port: 6379
password:
ttl: 3600
注意,这里的 ttl 不是连接 Redis 的超时时间(timeout),而是过期时间(单位:秒),我这里设置 1 小时。前面也提到,如果消息数比较多,不设置的话 Redis 内存很容易占满。
2)在 config 下新建 Redis 对话记忆存储配置类,初始化 RedisChatMemoryStore 的 Bean:
@Configuration
@ConfigurationProperties(prefix = "spring.data.redis")
@Data
public class RedisChatMemoryStoreConfig {
private String host;
private int port;
private String password;
private long ttl;
@Bean
public RedisChatMemoryStore redisChatMemoryStore() {
return RedisChatMemoryStore.builder()
.host(host)
.port(port)
.password(password)
.ttl(ttl)
.build();
}
}
注意,如果你的 Redis 密码不为空,上述配置中还要添加 user 用户名配置:
RedisChatMemoryStore.builder()
.user("你的用户名")
如果你的密码为空,那么就不用添加了,根据情况选择就好。
3)在启动类中排除 embedding 的自动装配,因为本项目用不到:
@SpringBootApplication(exclude = {RedisEmbeddingStoreAutoConfiguration.class})
否则会报错:
3、使用对话记忆
不同 appId 的对话记忆是独立隔离的,利用 LangChain4j 可以有 2 种实现方案。
方案 1 - 内置隔离机制
参考 官方文档,可以给 AI 服务方法增加 memoryId 注解和参数,然后通过 chatMemoryProvider 为每个 appId 分配对话记忆。
由于我们目前使用的是同一个 AiService 实例,先采用这种方式,好像修改成本更低。
修改 AiService 方法:
interface AiCodeGeneratorService {
HtmlCodeResult generateHtmlCode(@MemoryId int memoryId, @UserMessage String userMessage);
}
在工厂类中创建 AI Service 时,我们必须通过 chatMemoryProvider 为每个 memoryId 来构造专属的 MessageWindowChatMemory。注意,必须为 MessageWindowChatMemory 设置 id,因为使用的是同一个 Redis 存储实例,否则 Redis 中的存储 key 都是 default,无法区分不同的对话。
private final RedisChatMemoryStore redisChatMemoryStore;
@Bean
public AiCodeGeneratorService aiCodeGeneratorService() {
return AiServices.builder(AiCodeGeneratorService.class)
.chatModel(chatModel)
.streamingChatModel(streamingChatModel)
// 根据 id 构建独立的对话记忆
.chatMemoryProvider(memoryId -> MessageWindowChatMemory
.builder()
.id(memoryId)
.chatMemoryStore(redisChatMemoryStore)
.maxMessages(20)
.build())
.build();
}
现在调用 AI 生成方法时,要多传一个 id 参数:
generateHtmlCode(1, "生成博客网站");
generateHtmlCode(2, "生成电商网站");
编写单元测试,测试 2 场不同的对话:
@Test
void testChatMemory() {
HtmlCodeResult result = aiCodeGeneratorService.generateHtmlCode(1, "做个程序员鱼皮的工具网站,总代码量不超过 20 行");
Assertions.assertNotNull(result);
result = aiCodeGeneratorService.generateHtmlCode(1, "不要生成网站,告诉我你刚刚做了什么?");
Assertions.assertNotNull(result);
result = aiCodeGeneratorService.generateHtmlCode(2, "做个程序员鱼皮的工具网站,总代码量不超过 20 行");
Assertions.assertNotNull(result);
result = aiCodeGeneratorService.generateHtmlCode(2, "不要生成网站,告诉我你刚刚做了什么?");
Assertions.assertNotNull(result);
}
执行单元测试,在 Redis 中能看到历史对话记忆,每个 id 对应了一个 key:
如果不传入 id,默认为 default:
Redis 中存储的内容结构:
[
{
"text": "你是一位资深的 Web 前端开发专家,精通 HTML、CSS 和原生 JavaScript。你擅长构建响应式、美观且代码整洁的单页面网站。\\n\\n你的任务是根据用户提供的网站描述,生成一个完整、独立的单页面网站。你需要一步步思考,并最终将所有代码整合到一个 HTML 文件中。\\n\\n约束:\\n1. 技术栈: 只能使用 HTML、CSS 和原生 JavaScript。\\n2. 禁止外部依赖: 绝对不允许使用任何外部 CSS 框架、JS 库或字体库。所有功能必须用原生代码实现。\\n3. 独立文件: 必须将所有的 CSS 代码都内联在 `<head>` 标签的 `` 标签内,并将所有的 JavaScript 代码都放在 `</body>` 标签之前的 `` 标签内。最终只输出一个 `.html` 文件,不包含任何外部文件引用。\\n4. 响应式设计: 网站必须是响应式的,能够在桌面和移动设备上良好显示。请优先使用 Flexbox 或 Grid 进行布局。\\n5. 内容填充: 如果用户描述中缺少具体文本或图片,请使用有意义的占位符。例如,文本可以使用 Lorem Ipsum,图片可以使用 <https://picsum.photos> 的服务 (例如 `<img src=\\"<https://picsum.photos/800/600\\>" alt=\\"Placeholder Image\\">`)。\\n6. 代码质量: 代码必须结构清晰、有适当的注释,易于阅读和维护。\\n7. 交互性: 如果用户描述了交互功能 (如 Tab 切换、图片轮播、表单提交提示等),请使用原生 JavaScript 来实现。\\n8. 安全性: 不要包含任何服务器端代码或逻辑。所有功能都是纯客户端的。\\n9. 输出格式: 你的最终输出必须包含 HTML 代码块,可以在代码块之外添加解释、标题或总结性文字。格式如下:\\n\\n```html\\n... HTML 代码 ...\\n```",
"type": "SYSTEM"
},
{
"contents": [
{
"text": "做个程序员鱼皮的工具网站,总代码量不超过 20 行\\nYou must answer strictly in the following JSON format: {\\n\\"htmlCode\\": (HTML代码; type: string),\\n\\"description\\": (生成代码的描述; type: string)\\n}",
"type": "TEXT"
}
],
"type": "USER"
},
{
"text": "{\\n\\"htmlCode\\": \\"<!DOCTYPE html>\\\\n<html lang=\\\\\\"en\\\\\\">\\\\n<head>\\\\n \\\\n \\\\n <title>鱼皮的工具网站</title>\\\\n \\\\n body {\\\\n font-family: Arial, sans-serif;\\\\n margin: 0;\\\\n padding: 20px;\\\\n display: flex;\\\\n flex-direction: column;\\\\n align-items: center;\\\\n justify-content: center;\\\\n height: 100vh;\\\\n background-color: #f0f0f0;\\\\n }\\\\n h1 {\\\\n color: #333;\\\\n }\\\\n .tool {\\\\n background-color: white;\\\\n padding: 20px;\\\\n border-radius: 5px;\\\\n box-shadow: 0 2px 4px rgba(0,0,0,0.1);\\\\n margin-top: 20px;\\\\n }\\\\n \\\\n</head>\\\\n<body>\\\\n <h1>鱼皮的工具网站</h1>\\\\n <div class=\\\\\\"tool\\\\\\">\\\\n <p>这是一个简单的工具网站,专为程序员鱼皮设计。</p>\\\\n </div>\\\\n</body>\\\\n</html>\\",\\n\\"description\\": \\"这是一个非常简洁的工具网站,专为程序员鱼皮设计。网站包含一个标题和一个简单的工具描述区域,使用了基本的HTML和CSS来实现响应式布局。整个代码量控制在20行以内,符合要求。\\"\\n}",
"toolExecutionRequests": [],
"type": "AI"
},
{
"contents": [
{
"text": "不要生成网站,告诉我你刚刚做了什么?\\nYou must answer strictly in the following JSON format: {\\n\\"htmlCode\\": (HTML代码; type: string),\\n\\"description\\": (生成代码的描述; type: string)\\n}",
"type": "TEXT"
}
],
"type": "USER"
},
{
"text": "{\\n\\"htmlCode\\": \\"\\",\\n\\"description\\": \\"我刚刚创建了一个非常简洁的工具网站HTML代码,专为程序员鱼皮设计。这个网站包含一个标题和一个简单的工具描述区域,使用了基本的HTML和CSS来实现响应式布局。整个代码量控制在20行以内,符合用户的要求。\\"\\n}",
"toolExecutionRequests": [],
"type": "AI"
}
]
方案 2 - AI Service 隔离
之前所有应用共用同一个 AI Service 实例,如果想隔离会话记忆,可以给每个应用分配一个专属的 AI Service,每个 AI Service 绑定独立的对话记忆。
修改 AI Service 工厂类,提供根据 appId 获取 AI Service 服务的方法:
@Configuration
public class AiCodeGeneratorServiceFactory {
@Resource
private ChatModel chatModel;
@Resource
private StreamingChatModel streamingChatModel;
@Resource
private RedisChatMemoryStore redisChatMemoryStore;
/**
* 根据 appId 获取服务
*/
public AiCodeGeneratorService getAiCodeGeneratorService(long appId) {
// 根据 appId 构建独立的对话记忆
MessageWindowChatMemory chatMemory = MessageWindowChatMemory
.builder()
.id(appId)
.chatMemoryStore(redisChatMemoryStore)
.maxMessages(20)
.build();
return AiServices.builder(AiCodeGeneratorService.class)
.chatModel(chatModel)
.streamingChatModel(streamingChatModel)
.chatMemory(chatMemory)
.build();
}
}
为了保证跟之前的代码兼容,仍然默认提供一个 AI Service 的 Bean:
/**
* 默认提供一个 Bean
*/
@Bean
public AiCodeGeneratorService aiCodeGeneratorService() {
return getAiCodeGeneratorService(0L);
}
4、本地缓存优化
基于方案 2,我们可以利用 Caffeine 本地缓存 进一步优化性能。
每次构造完 appId 对应的 AI 服务实例后,利用 Caffeine 缓存来存储,之后相同 appId 就能直接获取到 AI 服务实例,避免重复构造。注意,本地缓存占用的是内存,所以必须设置合理的过期策略防止内存泄漏。
先引入 Caffeine 依赖:
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
优化 AiCodeGeneratorServiceFactory,增加缓存逻辑:
/**
* AI 服务实例缓存
* 缓存策略:
* - 最大缓存 1000 个实例
* - 写入后 30 分钟过期
* - 访问后 10 分钟过期
*/
private final Cache<Long, AiCodeGeneratorService> serviceCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(30))
.expireAfterAccess(Duration.ofMinutes(10))
.removalListener((key, value, cause) -> {
log.debug("AI 服务实例被移除,appId: {}, 原因: {}", key, cause);
})
.build();
/**
* 根据 appId 获取服务(带缓存)
*/
public AiCodeGeneratorService getAiCodeGeneratorService(long appId) {
return serviceCache.get(appId, this::createAiCodeGeneratorService);
}
/**
* 创建新的 AI 服务实例
*/
private AiCodeGeneratorService createAiCodeGeneratorService(long appId) {
log.info("为 appId: {} 创建新的 AI 服务实例", appId);
// 根据 appId 构建独立的对话记忆
MessageWindowChatMemory chatMemory = MessageWindowChatMemory
.builder()
.id(appId)
.chatMemoryStore(redisChatMemoryStore)
.maxMessages(20)
.build();
return AiServices.builder(AiCodeGeneratorService.class)
.chatModel(chatModel)
.streamingChatModel(streamingChatModel)
.chatMemory(chatMemory)
.build();
}
最后修改 AiCodeGeneratorFacade,所有方法使用的 AI Service 改为通过工厂根据 appId 获取 AI Service:
@Resource
private AiCodeGeneratorServiceFactory aiCodeGeneratorServiceFactory;
// 根据 appId 获取对应的 AI 服务实例
AiCodeGeneratorService aiCodeGeneratorService = aiCodeGeneratorServiceFactory.getAiCodeGeneratorService(appId);
使用这种方案,不需要改动 AI Service 本身的代码,更符合开闭原则。
5、历史对话加载
根据方案,对话记忆初始化时,需要从数据库中加载对话历史到记忆中。
在 ChatHistoryService 中开发加载方法:
@Override
public int loadChatHistoryToMemory(Long appId, MessageWindowChatMemory chatMemory, int maxCount) {
try {
// 直接构造查询条件,起始点为 1 而不是 0,用于排除最新的用户消息
QueryWrapper queryWrapper = QueryWrapper.create()
.eq(ChatHistory::getAppId, appId)
.orderBy(ChatHistory::getCreateTime, false)
.limit(1, maxCount);
List<ChatHistory> historyList = this.list(queryWrapper);
if (CollUtil.isEmpty(historyList)) {
return 0;
}
// 反转列表,确保按时间正序(老的在前,新的在后)
historyList = historyList.reversed();
// 按时间顺序添加到记忆中
int loadedCount = 0;
// 先清理历史缓存,防止重复加载
chatMemory.clear();
for (ChatHistory history : historyList) {
if (ChatHistoryMessageTypeEnum.USER.getValue().equals(history.getMessageType())) {
chatMemory.add(UserMessage.from(history.getMessage()));
loadedCount++;
} else if (ChatHistoryMessageTypeEnum.AI.getValue().equals(history.getMessageType())) {
chatMemory.add(AiMessage.from(history.getMessage()));
loadedCount++;
}
}
log.info("成功为 appId: {} 加载了 {} 条历史对话", appId, loadedCount);
return loadedCount;
} catch (Exception e) {
log.error("加载历史对话失败,appId: {}, error: {}", appId, e.getMessage(), e);
// 加载失败不影响系统运行,只是没有历史上下文
return 0;
}
}
注意上述代码中的几个重要细节:
- 查询起始点设置为 1 而不是 0,这是为了排除最新的用户消息。因为在对话流程中,用户消息被添加到数据库后,AI 服务也会自动将用户消息添加到记忆中,如果不排除会导致消息重复。
- 注意反转从数据库中查到的消息列表,确保加载到记忆中的消息是按时间正序的。
- 加载前先清理 Redis 中的历史对话记忆,防止重复加载。
然后就可以在初始化 AI Service 的对话记忆时调用了,这相当于是懒加载,对话时才会加载记忆,节约内存。
private AiCodeGeneratorService createAiCodeGeneratorService(long appId) {
log.info("为 appId: {} 创建新的 AI 服务实例", appId);
// 根据 appId 构建独立的对话记忆
MessageWindowChatMemory chatMemory = MessageWindowChatMemory
.builder()
.id(appId)
.chatMemoryStore(redisChatMemoryStore)
.maxMessages(20)
.build();
// 从数据库加载历史对话到记忆中
chatHistoryService.loadChatHistoryToMemory(appId, chatMemory, 20);
return AiServices.builder(AiCodeGeneratorService.class)
.chatModel(chatModel)
.streamingChatModel(streamingChatModel)
.chatMemory(chatMemory)
.build();
}
数据加载肯定是耗时操作,所以我们引入本地缓存的含金量又提高了~
6、测试验证
直接通过前端测试验证:
1)重新创建应用,进行多轮对话,查看 Redis 中的记忆是否正确保存:
2)查看历史应用,进行对话,查看 Redis 中的对话记忆是否正确加载:
测试发现,对话记忆中的消息数是正确的,美中不足的是系统消息不在最开头,但这个影响不是很大。如果要解决这个问题,可以将系统消息作为每个应用的第一条消息保存到对话历史中,这样加载时直接一起加载;但是返回给前端时要过滤掉,因为系统消息不应该对用户可见。
总之,效果符合预期,AI 现在能够基于历史对话进行网站的迭代优化了。
六、Redis 分布式 Session
既然已经整合了 Redis,我们可以顺便优化一下用户登录态的管理。之前每次重启服务器都需要重新登录,现在可以使用 Redis 管理 Session 登录态,实现分布式会话管理。
操作方式也很简单,1 分钟就能完成。
1)先在 Maven 中引入 spring-session-data-redis 库:
<!-- Spring Session + Redis -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
2)修改 application.yml 配置文件,更改 Session 的存储方式和过期时间:
spring:
# session 配置
session:
store-type: redis
# session 30 天过期
timeout: 2592000
server:
port: 8123
servlet:
context-path: /api
# cookie 30 天过期
session:
cookie:
max-age: 2592000
这就搞定了,现在用户的登录状态会保存在 Redis 中。重启服务器后,不需要重新登陆,并且在 Redis 中可以看到登录相关的 key:
七、扩展思路
通过本期开发,平台已经具备了完整的对话记忆能力,但还有很多可以优化和扩展的方向。
1、记录应用对话总轮次
统计每个应用的对话轮数,这个数据可以用于分析用户使用习惯,也可以作为应用复杂度的参考指标。
2、对话历史导出功能
支持导出对话记录为 Markdown 文件,方便用户保存和分享开发过程。
3、智能记忆管理(较难)
利用 AI 分析对话次数较多的应用,智能总结过去的对话历史,节省 Token 的同时优化记忆效果。
4、多人协作对话(较难)
允许多个用户共同参与一个应用的对话,实现团队协作开发。在 编程导航的智能协同云图库项目 中给大家讲解过怎么实现多人协同操作。
项目分区导航:⬅️ 07-AI_零代码应用生成平台源码 | 08-对话历史模块 | ➡️ 09-工程项目生成
💬 评论