对话历史模块

本节重点

本节我们将为 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),数据库记录如下:

image-4b377b77

结果在查询‍第二页前,突然用户又⁡收到了 5 条新消息‎,数据库记录就变成了下‎面这样。原本的第一页,变成了当前的第二页!

image-3aac99f8

这样就导致‍查询出的第二页数据⁡,正好是之前已经查‎询出的第一页的数据‎,造成了消息重复加载。

此外,传统的 offset 分页方式在处理大量对话数据时存在严重的性能问题。假设一个热门应用积累了几万条对话记录后,如果用户想查看较早的历史消息,执行 LIMIT 10000, 10 这样的查询会非常缓慢。

这是因为数据库需要‍先扫描和跳过前面的 10000 条⁡记录,才能返回用户真正需要的 10 条‎数据。随着 offset 值的‎增大,查询性能会线性下降,在高并发场景下很容易成为系统瓶颈。

游标查询

为了解决这些问题,可以使用游标分页。使用一个游标来跟踪分页位置,而不是基于页码,每次请求从上一次请求的游标开始加载数据。

一般我们会选择数据记录的‍唯一标识符(主键)、时间戳、或者具有排序能力的字⁡段作为游标。比如即时通讯系统中的每个消息,通常都‎有一个唯一自增的 id,就可以作为游标。每次查询‎完当前页面的数据后,可以将最后一条消息记录的 id 作为游标值传递给前端(客户端)。

image-57909e6b

当要加载下一‍页时,前端携带游标值发起⁡查询,后端操作数据库从 ‎id 小于当前游标值的数‎据开始查询,这样查询结果就不会受到新增数据的影响。

image-812a3bf4

标准实践建议优先使用 id ‍作为游标,因为主键性能最优且不重复。但针对我们的场景,按⁡时间排序是核心需求,而且同一个 appId 下时间重复的‎可能性极低,所以直接使用对话历史的创建时间 create‎Time 作为游标是完全可行的。不需要额外带上对话历史的 id 作为复合游标,简化了游标查询的逻辑。

示例 SQL 语句如下:

SELECT * FROM chat_history
WHERE appId = 123 AND createTime < '2025-07-29 10:30:00'
ORDER BY createTime DESC
LIMIT 10;

而且还可以‍给 appId 和⁡ createTi‎me 增加复合索引‎,进一步提高检索效率。这样执行过程就变成了:

  1. 直接定位到 (appId=123, createTime<‘2025-07-29 10:30:00’) 的索引位置
  2. 顺序读取 10 条记录
  3. 完成查询

几乎只有 10 次读取成本!

再举个明显的‍对比例子,假设一个热门⁡聊天室有 10 万条对‎话记录,用户要查看 1‎ 个月前的消息(大约在第 3000 页):

查询方式 SQL语句 执行时间 资源消耗
Offset 分页 LIMIT 30000, 10 800ms 需要扫描 30000 条记录
游标查询 ‍ createTi⁡me < ‘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 生成器生成基础代码:

image-49e928ac

将生成的文件移动到对应的包下。

然后修改 ‍ChatHisto⁡ry 类,将主键 ‎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 了一个引导‎,让它为 mess‎ageType 创建一个枚举类。

生成过程如图:

image-4f57e1f4

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);
}

然后在 AppServiceImplchatToGenCode 方法中集成对话历史保存逻辑。这里利用了 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 点:

  1. 修改应用对话页面,加载对话历史
  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
  }
}

这段代码的重点是:

  1. 状态管理:通过 lastCreateTime 维护游标状态,确保分页的连续性
  2. 消息排序:后端返回的是时间降序,前端需要反转后再添加到列表
  3. 增量加载:新消息添加到数组开头,保持时间顺序的正确性
  4. 边界处理:通过返回记录数判断是否还有更多数据

常见问题 - 消息顺序相反

在开发过程中可能会遇到消息顺序相反的问题,比如下图:

image-74a076bf

可以让 AI 修复问题,轻轻松松~

目前加载后展示的消息顺序反了,应该像聊天记录一样上方展示老的,下方展示新的

通过调整数据处理逻辑,确保消息按照正确的时间顺序展示:

image-95c508e0

五、对话记忆

需求分析

很多时候 ‍AI 生成的网站没⁡办法一次性满足用户‎的需求,因此需要提‎供网站修改功能。

但是目前我们‍的 AI 对话会断片儿⁡,无法记住之前的对话内‎容,每次修改实际上都是‎重新生成完整的网站,而不是在原有基础上进行修改。

image-77789b0aimage-52f16f75

这种情况下‍,用户不能进行迭代⁡式的网站开发,每次‎都是开盲盒,极大地‎限制了平台的实用性。

方案设计

要解决这个问题,就要给 AI 增加对话记忆能力。

保存到哪儿?

LangC‍hain4j 不仅⁡提供了对话记忆能力‎,而且还能结合 R‎edis 持久化对话记忆,非常爽~

这里可能有同学会好奇 2 个问题:

1)为什么不直接用内存来存储会话记忆?

首先是重启‍后会丢失记忆;其次⁡如果每个应用都在内‎存中维护对话历史,‎很容易出现 OOM 问题。

2)为什么不用 MySQL 来存储会话记忆?

一方面是因为 R‍edis 作为内存数据库,在读写⁡对话记忆时性能更高;另一方面是数‎据库中的对话历史表包含其他业务字‎段,不适合直接交给 LangChain4j 的对话记忆组件管理。

加载历史

要注意,**Redis 的内存也不是无限的!**一般情况下要给存入 Redis 的每个 Key 都设置合理的过期时间,不能不过期。所以这就可能导致 Redis 的会话记忆被删除的情况。

怎么解决呢?

方案很简单,之‍前我们已经在数据库中保存了用⁡户和 AI 的消息,只需要在‎初始化会话记忆时,加载最新的‎对话记录到 Redis 中,就能确保 AI 了解交互历史。

流程:AI‍ 对话 => 从数⁡据库中加载对话历史‎到 Redis =‎> Redis 为 AI 提供对话记忆

对话隔离

此外,每个‍应用的对话记忆应该是⁡相互隔离的,Lang‎Chain4j 也提‎供了对话记忆隔离的能力,下面开发实现中会讲解。

开发实现

1、引入依赖

参考 LangChain4j 官方文档,引入必要的依赖:

<dependency>
  <groupId>dev.langchain4j</groupId>
  <artifactId>langchain4j-community-redis-spring-boot-starter</artifactId>
  <version>1.1.0-beta7</version>
</dependency>

这个依赖会‍引入 Redis ⁡的 Jedis 客‎户端,以及与 La‎ngChain4j 的整合组件。

2、配置 Redis

1)在配置文件中添加 Redis 连接信息:

spring:
  # redis
  data:
    redis:
      host: localhost
      port: 6379
      password:
      ttl: 3600

注意,这里的 ttl 不是连接 Redis 的超时时间(timeout),而是过期时间(单位:秒),我这里设置 1 小时。前面也提到,如果消息数比较多,不设置的话 Redis 内存很容易占满。

image-4e80a8d0

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 ⁡密码不为空,上述配‎置中还要添加 us‎er 用户名配置:

RedisChatMemoryStore.builder()
    .user("你的用户名")

如果你的密码为空,那么就不用添加了,根据情况选择就好。

3)在启动‍类中排除 embe⁡dding 的自动‎装配,因为本项目用‎不到:

@SpringBootApplication(exclude = {RedisEmbeddingStoreAutoConfiguration.class})

否则会报错:

image-22666610

3、使用对话记忆

不同 ap‍pId 的对话记忆⁡是独立隔离的,利用‎ LangChai‎n4j 可以有 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:

image-b5e40091

如果不传入 id,默认为 default:

image-9b963fb8

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\\": \\"这是一个非常简洁的工具网站,专为程序员鱼皮设计。网站包含一个标题和一个简单的工具描述区域,使用了基本的HTMLCSS来实现响应式布局。整个代码量控制在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代码,专为程序员鱼皮设计。这个网站包含一个标题和一个简单的工具描述区域,使用了基本的HTMLCSS来实现响应式布局。整个代码量控制在20行以内,符合用户的要求。\\"\\n}",
    "toolExecutionRequests": [],
    "type": "AI"
  }
]

方案 2 - AI Service 隔离

之前所有应用共用‍同一个 AI Service 实⁡例,如果想隔离会话记忆,可以给每‎个应用分配一个专属的 AI Se‎rvice,每个 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();
    }
}

为了保证跟‍之前的代码兼容,仍⁡然默认提供一个 A‎I Service‎ 的 Bean:

/**
 * 默认提供一个 Bean
 */
@Bean
public AiCodeGeneratorService aiCodeGeneratorService() {
    return getAiCodeGeneratorService(0L);
}

4、本地缓存优化

基于方案 2,我们可以利用 Caffeine 本地缓存 进一步优化性能。

每次构造完 appId‍ 对应的 AI 服务实例后,利用 Caff⁡eine 缓存来存储,之后相同 appId‎ 就能直接获取到 AI 服务实例,避免重‎复构造。注意,本地缓存占用的是内存,所以必须设置合理的过期策略防止内存泄漏。

先引入 Caffeine 依赖:

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

优化 Ai‍CodeGener⁡atorServi‎ceFactory‎,增加缓存逻辑:

/**
 * 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 Serv⁡ice 本身的代码,更符合开闭‎原则。        ‎

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. 查询起始点设置为 1 而不是 0,这是为了排除最新的用户消息。因为在对话流程中,用户消息被添加到数据库后,AI 服务也会自动将用户消息添加到记忆中,如果不排除会导致消息重复。
  2. 注意反转从数据库中查到的消息列表,确保加载到记忆中的消息是按时间正序的。
  3. 加载前先清理 Redis 中的历史对话记忆,防止重复加载。

然后就可以‍在初始化 AI Se⁡rvice 的对话记‎忆时调用了,这相当于‎是懒加载,对话时才会加载记忆,节约内存。

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)重新创‍建应用,进行多轮对⁡话,查看 Redi‎s 中的记忆是否正‎确保存:

image-e9d0bdd4

2)查看历‍史应用,进行对话,⁡查看 Redis ‎中的对话记忆是否正‎确加载:

image-53a38d74

测试发现,对话记忆中的消‍息数是正确的,美中不足的是系统消息不在最开头,⁡但这个影响不是很大。如果要解决这个问题,可以将‎系统消息作为每个应用的第一条消息保存到对话历史‎中,这样加载时直接一起加载;但是返回给前端时要过滤掉,因为系统消息不应该对用户可见。

总之,效果‍符合预期,AI 现⁡在能够基于历史对话‎进行网站的迭代‎优化了。

image-9a07ed23

六、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:

image-4b2040bf

七、扩展思路

通过本期开‍发,平台已经具备了⁡完整的对话记忆能力‎,但还有很多可以优‎化和扩展的方向。

1、记录应用对话总轮次

统计每个应‍用的对话轮数,这个⁡数据可以用于分析用‎户使用习惯,也可以‎作为应用复杂度的参考指标。

2、对话历史导出功能

支持导出对‍话记录为 Mark⁡down 文件,方‎便用户保存和分享开‎发过程。

3、智能记忆管理(较难)

利用 AI‍ 分析对话次数较多⁡的应用,智能总结过‎去的对话历史,节省‎ Token 的同时优化记忆效果。

4、多人协作对话(较难)

允许多个用户共同参与一个应用的对话,实现团队协作开发。在 编程导航的智能协同云图库项目 中给大家讲解过怎么实现多人协同操作。


项目分区导航:⬅️ 07-AI_零代码应用生成平台源码 | 08-对话历史模块 | ➡️ 09-工程项目生成