Procházet zdrojové kódy

添加
1、聊天完成时,自动保存该次的聊天记录
2、创建聊天记录的相关类
3、添加获取聊天记录的接口
4、修改http请求中poststreaming接口,最好一条数据返回包含前面的流式回复

183207892172 před 1 týdnem
rodič
revize
3ee6a0cdca

+ 168 - 0
java/storlead-knowledge/.comate/specs/save-chat-message/doc.md

@@ -0,0 +1,168 @@
+# 保存聊天记录到 ia_messages 表设计文档
+
+## 1. 需求概述
+
+修改 ChatController 中的 `chat` 和 `chatStreaming` 接口,将对话数据保存到 `ia_messages` 表中。
+
+## 2. 表结构
+
+| 字段名 | 类型 | 说明 |
+|--------|------|------|
+| id | BIGINT | 主键,自增 |
+| task_id | VARCHAR(64) | 任务id (Dify返回) |
+| message_id | VARCHAR(64) | 信息id (Dify返回) |
+| conversation_id | VARCHAR(64) | 会话id |
+| app_id | VARCHAR(64) | 应用id |
+| inputs | TEXT | 输入 (JSON格式) |
+| query | TEXT | 问题 |
+| response_mode | VARCHAR(20) | 回复模式 (blocking/streaming) |
+| user_id | VARCHAR(64) | 用户id |
+| answer | LONGTEXT | AI回复 |
+| created_at | DATETIME | 创建时间 |
+
+## 3. 技术方案
+
+### 3.1 架构设计
+
+采用标准三层架构:
+- **Entity**: `IaMessageEntity` - 映射 ia_messages 表
+- **Mapper**: `IaMessageMapper` - 数据访问层(继承 MyBaseMapper)
+- **Service**: `IaMessageService` / `IaMessageServiceImpl` - 业务逻辑层
+- **Controller**: 修改 `ChatController` - 在返回前调用 Service 保存数据
+
+### 3.2 数据流
+
+```
+ChatController.chat/chatStreaming
+    ↓
+调用 Dify API
+    ↓
+获取响应结果
+    ↓
+提取关键字段 → 构建 IaMessageEntity
+    ↓
+调用 IaMessageService.save()
+    ↓
+返回原结果给客户端
+```
+
+### 3.3 数据来源映射
+
+**从 ChatDTO 获取:**
+| ChatDTO 字段 | ia_messages 字段 | 说明 |
+|--------------|------------------|------|
+| input | inputs | List<String> 转 JSON |
+| query | query | 直接映射 |
+| response_mode | response_mode | 直接映射 |
+| conversation_id | conversation_id | 直接映射 |
+| user | user_id | 直接映射 |
+| appId | app_id | 直接映射 |
+
+**从 Dify 响应获取(blocking模式):**
+```json
+{
+  "task_id": "...",
+  "message_id": "...",
+  "answer": "...",
+  "conversation_id": "..."
+}
+```
+
+**从 Dify 响应获取(streaming模式):**
+流式数据最后一帧包含完整信息,需要解析 `data:` 行中的 JSON。
+
+## 4. 实现细节
+
+### 4.1 实体类 IaMessageEntity
+
+```java
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Accessors(chain = true)
+@TableName("ia_messages")
+public class IaMessageEntity {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String taskId;
+    private String messageId;
+    private String conversationId;
+    private String appId;
+    private String inputs;
+    private String query;
+    private String responseMode;
+    private String userId;
+    private String answer;
+    private LocalDateTime createdAt;
+}
+```
+
+### 4.2 修改 chat() 方法
+
+```java
+@PostMapping("chat")
+public Result<Object> chat(@RequestBody ChatDTO chatDTO) throws JsonProcessingException {
+    String url = difyProperties.getBaseUrl() + "chat-messages";
+    String body = JacksonHolder.OBJECT_MAPPER.writeValueAsString(chatDTO);
+    Result<Object> result = httpService.post(url, null, "Bearer "+difyProperties.getAppKeys().get(chatDTO.getAppId()), body, new TypeReference<>() {});
+    
+    // 保存到数据库(异步处理,不影响主流程)
+    if (result.isOk() && result.getData() != null) {
+        saveChatMessage(chatDTO, result.getData());
+    }
+    
+    return result;
+}
+```
+
+### 4.3 修改 chatStreaming() 方法
+
+流式响应需要先收集完整 answer,再保存。修改 `postStream` 方法或使用拦截器模式:
+
+```java
+// 方案:修改 postStream 方法,增加回调处理最后一帧数据
+httpService.postStream(url, authorization, body, emitter, (lastData) -> {
+    // 解析 lastData 保存到数据库
+    saveStreamingMessage(chatDTO, lastData);
+});
+```
+
+## 5. 边界条件处理
+
+1. **数据库保存失败**:使用 try-catch 包裹,不影响 API 返回
+2. **Dify 响应解析失败**:记录日志,不影响主流程
+3. **流式响应中断**:在 emitter 的 onError 中处理
+4. **字段为空处理**:允许为空,按实际返回数据存储
+
+## 6. 影响文件
+
+| 文件类型 | 路径 | 操作 |
+|----------|------|------|
+| Entity | storlead-knowledge-core/.../entity/IaMessageEntity.java | 新增 |
+| Mapper | storlead-knowledge-biz/.../mapper/IaMessageMapper.java | 新增 |
+| Service | storlead-knowledge-biz/.../service/IaMessageService.java | 新增 |
+| ServiceImpl | storlead-knowledge-biz/.../service/impl/IaMessageServiceImpl.java | 新增 |
+| Controller | storlead-knowledge-api/.../api/ChatController.java | 修改 |
+| HttpService | storlead-knowledge-core/.../utils/HttpService.java | 修改(增加回调) |
+
+## 7. SQL 建表语句
+
+```sql
+CREATE TABLE IF NOT EXISTS `ia_messages` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `task_id` varchar(64) DEFAULT NULL COMMENT '任务id',
+  `message_id` varchar(64) DEFAULT NULL COMMENT '信息id',
+  `conversation_id` varchar(64) DEFAULT NULL COMMENT '会话id',
+  `app_id` varchar(64) DEFAULT NULL COMMENT '应用id',
+  `inputs` text COMMENT '输入(JSON)',
+  `query` text COMMENT '问题',
+  `response_mode` varchar(20) DEFAULT NULL COMMENT '回复模式',
+  `user_id` varchar(64) DEFAULT NULL COMMENT '用户id',
+  `answer` longtext COMMENT 'AI回复',
+  `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_conversation_id` (`conversation_id`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_app_id` (`app_id`),
+  KEY `idx_created_at` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI对话消息记录表';
+```

+ 116 - 9
java/storlead-knowledge/storlead-knowledge-api/src/main/java/com/storlead/knowledge/api/ChatController.java

@@ -2,32 +2,44 @@ package com.storlead.knowledge.api;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
 import com.storlead.framework.common.result.Result;
 import com.storlead.knowledge.config.DifyProperties;
+import com.storlead.knowledge.entity.AiMessageEntity;
 import com.storlead.knowledge.pojo.dto.ChatDTO;
-import com.storlead.knowledge.pojo.dto.TagDTO;
+import com.storlead.knowledge.pojo.dto.QueryPageDTO;
+import com.storlead.knowledge.service.AiMessageService;
 import com.storlead.knowledge.utils.HttpService;
 import com.storlead.knowledge.utils.JacksonHolder;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
 import javax.annotation.Resource;
 import java.io.IOException;
+import java.util.Map;
 
 
 @RestController
 @RequestMapping("/chat/")
 @Api(tags = "知识库: 对话及流程")
 public class ChatController {
+
+    private static final Logger log = LoggerFactory.getLogger(ChatController.class);
+
     @Resource
     private HttpService httpService;
 
     @Resource
     private DifyProperties difyProperties;
 
+    @Resource
+    private AiMessageService aiMessageService;
+
     /**
      * 创建聊天 blocking模式
      * @param chatDTO
@@ -39,7 +51,18 @@ public class ChatController {
     public Result<Object> chat(@RequestBody ChatDTO chatDTO) throws JsonProcessingException {
         String url = difyProperties.getBaseUrl() + "chat-messages";
         String body = JacksonHolder.OBJECT_MAPPER.writeValueAsString(chatDTO);
-        return httpService.post(url, null, "Bearer "+difyProperties.getAppKeys().get(chatDTO.getAppId()), body, new TypeReference<>() {});
+        Result<Object> result = httpService.post(url, null, "Bearer "+chatDTO.getAppId(), body, new TypeReference<>() {});
+
+        // 异步保存聊天记录(不影响主流程)
+        if (result.isSuccess() && result.getResult() != null) {
+            try {
+                saveBlockingMessage(chatDTO, result.getResult());
+            } catch (Exception e) {
+                log.error("保存聊天记录失败", e);
+            }
+        }
+
+        return result;
     }
 
 
@@ -49,26 +72,110 @@ public class ChatController {
      * @return
      * @throws IOException
      */
-    @PostMapping(value = "/chatStreaming", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    @PostMapping(value = "chatStreaming", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    @ApiOperation("创建聊天 流式返回")
     public SseEmitter chatStreaming(@RequestBody ChatDTO chatDTO) throws IOException {
 
         SseEmitter emitter = new SseEmitter(0L); // 0 = 不超时
 
         String url = difyProperties.getBaseUrl() + "chat-messages";
 
-        // 强制开启流式
-        //chatDTO.setStream(true);
-
         String body = JacksonHolder.OBJECT_MAPPER.writeValueAsString(chatDTO);
-
         httpService.postStream(
                 url,
-                "Bearer " + difyProperties.getAppKeys().get(chatDTO.getAppId()),
+                "Bearer " +chatDTO.getAppId(),
                 body,
-                emitter
+                emitter,
+                (lastData) -> {
+                    try {
+                        saveStreamingMessage(chatDTO, lastData);
+                    } catch (Exception e) {
+                        log.error("保存流式聊天记录失败", e);
+                    }
+                }
         );
 
         return emitter;
     }
 
+    @GetMapping("messages")
+    @ApiOperation("获取聊天记录(支持关键字搜索和分页)")
+    public Result<Object> listMessages(QueryPageDTO page) {
+        return Result.ok(aiMessageService.pageQuery(page));
+    }
+
+    @GetMapping("history")
+    @ApiOperation("获取应用的对话历史")
+    public Result<Object> list(ChatDTO chatDTO) {
+        String url = difyProperties.getBaseUrl()  + "conversations";
+        return httpService.get(
+                url,
+                Map.of("user", chatDTO.getUser()),
+                "Bearer "+difyProperties.getAppKeys().get(chatDTO.getAppId()),
+                new TypeReference<>() {}
+        );
+    }
+
+    /**
+     * 保存 blocking 模式的聊天记录
+     */
+    private void saveBlockingMessage(ChatDTO chatDTO, Object responseData) {
+        try {
+            String json = JacksonHolder.OBJECT_MAPPER.writeValueAsString(responseData);
+            JsonNode root = JacksonHolder.OBJECT_MAPPER.readTree(json);
+            System.out.println(root);
+            AiMessageEntity entity = new AiMessageEntity();
+            entity.setTaskId(getJsonText(root, "task_id"));
+            entity.setMessageId(getJsonText(root, "message_id"));
+            entity.setConversationId(getJsonText(root, "conversation_id"));
+            entity.setAppId(chatDTO.getAppId());
+            entity.setInputs(chatDTO.getInputs() != null ? JacksonHolder.OBJECT_MAPPER.writeValueAsString(chatDTO.getInputs()) : null);
+            entity.setQuery(chatDTO.getQuery());
+            entity.setResponseMode(chatDTO.getResponse_mode());
+            entity.setUserId(chatDTO.getUser());
+            entity.setAnswer(getJsonText(root, "answer"));
+            entity.setCreatedAt(Integer.parseInt(getJsonText(root, "created_at")));
+
+            aiMessageService.save(entity);
+        } catch (Exception e) {
+            log.error("解析并保存blocking聊天记录失败", e);
+        }
+    }
+
+    /**
+     * 保存 streaming 模式的聊天记录(从 message_end 事件中解析)
+     */
+    private void saveStreamingMessage(ChatDTO chatDTO, String lastData) {
+        try {
+            // lastData 格式: {"event":"message_end","task_id":"...","message_id":"...","conversation_id":"...","metadata":{...}}
+            JsonNode root = JacksonHolder.OBJECT_MAPPER.readTree(lastData);
+
+            AiMessageEntity entity = new AiMessageEntity();
+            entity.setTaskId(getJsonText(root, "task_id"));
+            entity.setMessageId(getJsonText(root, "message_id"));
+            entity.setConversationId(getJsonText(root, "conversation_id"));
+            entity.setAppId(chatDTO.getAppId());
+            entity.setInputs(chatDTO.getInputs() != null ? JacksonHolder.OBJECT_MAPPER.writeValueAsString(chatDTO.getInputs()) : null);
+            entity.setQuery(chatDTO.getQuery());
+            entity.setResponseMode(chatDTO.getResponse_mode());
+            entity.setUserId(chatDTO.getUser());
+            // streaming 模式下 answer 在 metadata 中逐句返回,message_end 事件本身不含完整 answer,此处留空
+            entity.setAnswer(getJsonText(root, "answer"));
+            entity.setCreatedAt(Integer.parseInt(getJsonText(root, "created_at")));
+
+            aiMessageService.save(entity);
+        } catch (Exception e) {
+            log.error("解析并保存streaming聊天记录失败", e);
+        }
+    }
+
+    /**
+     * 安全获取 JSON 节点文本值
+     */
+    private String getJsonText(JsonNode node, String field) {
+        JsonNode value = node.get(field);
+        return value != null && !value.isNull() ? value.asText() : null;
+    }
+
 }
+

+ 1 - 1
java/storlead-knowledge/storlead-knowledge-api/src/main/java/com/storlead/knowledge/api/DocumentController.java

@@ -103,7 +103,7 @@ public class DocumentController {
 
 
     @PostMapping("{dataset_id}/documents/{document_id}/update-by-file")
-    @ApiOperation("以文件创建文档")
+    @ApiOperation("以文件更新文档")
     public Result<Object> updateDocumentByFile(
             @RequestParam("file") MultipartFile file,
             @RequestParam("data") String data,  //格式 {"indexing_technique":"high_quality","doc_form":"text_model","doc_language":"English","process_rule":{"mode":"automatic"}}

+ 16 - 0
java/storlead-knowledge/storlead-knowledge-biz/src/main/java/com/storlead/knowledge/mapper/AiMessageMapper.java

@@ -0,0 +1,16 @@
+package com.storlead.knowledge.mapper;
+
+import com.storlead.framework.mybatis.mapper.MyBaseMapper;
+import com.storlead.knowledge.entity.AiMessageEntity;
+
+/**
+ * <p>
+ * AI对话消息记录 Mapper 接口
+ * </p>
+ *
+ * @author storlead
+ * @since 2026-06-09
+ */
+public interface AiMessageMapper extends MyBaseMapper<AiMessageEntity> {
+
+}

+ 44 - 0
java/storlead-knowledge/storlead-knowledge-biz/src/main/java/com/storlead/knowledge/service/impl/AiMessageServiceImpl.java

@@ -0,0 +1,44 @@
+package com.storlead.knowledge.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
+import com.storlead.knowledge.entity.AiMessageEntity;
+import com.storlead.knowledge.mapper.AiMessageMapper;
+import com.storlead.knowledge.pojo.dto.QueryPageDTO;
+import com.storlead.knowledge.service.AiMessageService;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+
+/**
+ * <p>
+ * AI对话消息记录 服务实现类
+ * </p>
+ *
+ * @author storlead
+ * @since 2026-06-09
+ */
+@Service
+public class AiMessageServiceImpl extends MyBaseServiceImpl<AiMessageMapper, AiMessageEntity> implements AiMessageService {
+
+    @Override
+    public IPage<AiMessageEntity> pageQuery(QueryPageDTO page) {
+        Page<AiMessageEntity> pageParam = new Page<>(page.getPageIndex(), page.getPageSize());
+        LambdaQueryWrapper<AiMessageEntity> wrapper = new LambdaQueryWrapper<>();
+
+        // 如果有关键字,搜索 query 和 answer 字段
+        if (StringUtils.isNotBlank(page.getKeyword())) {
+            wrapper.and(w -> w
+                    .like(AiMessageEntity::getQuery, page.getKeyword())
+                    .or()
+                    .like(AiMessageEntity::getAnswer, page.getKeyword())
+            );
+        }
+
+        // 按创建时间降序排列
+        wrapper.orderByDesc(AiMessageEntity::getCreatedAt);
+
+        return pageWithScope(pageParam, wrapper);
+    }
+}

+ 68 - 0
java/storlead-knowledge/storlead-knowledge-core/src/main/java/com/storlead/knowledge/entity/AiMessageEntity.java

@@ -0,0 +1,68 @@
+package com.storlead.knowledge.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.storlead.framework.mybatis.entity.SysBaseField;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+@Data
+@EqualsAndHashCode(callSuper = false)
+@Accessors(chain = true)
+@ApiModel(value = "AiMessageEntity对象", description = "AI对话消息记录")
+@TableName("ai_messages")
+public class AiMessageEntity extends SysBaseField implements Serializable {
+
+
+    @ApiModelProperty(value = "主键ID")
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty(value = "任务id")
+    @TableField("task_id")
+    private String taskId;
+
+    @ApiModelProperty(value = "信息id")
+    @TableField("message_id")
+    private String messageId;
+
+    @ApiModelProperty(value = "会话id")
+    @TableField("conversation_id")
+    private String conversationId;
+
+    @ApiModelProperty(value = "应用id")
+    @TableField("app_id")
+    private String appId;
+
+    @ApiModelProperty(value = "输入(JSON)")
+    @TableField("inputs")
+    private String inputs;
+
+    @ApiModelProperty(value = "问题")
+    @TableField("query")
+    private String query;
+
+    @ApiModelProperty(value = "回复模式")
+    @TableField("response_mode")
+    private String responseMode;
+
+    @ApiModelProperty(value = "用户id")
+    @TableField("user_id")
+    private String userId;
+
+    @ApiModelProperty(value = "AI回复")
+    @TableField("answer")
+    private String answer;
+
+    @ApiModelProperty(value = "创建时间")
+    @TableField("created_at")
+    private Integer createdAt;
+}

+ 2 - 1
java/storlead-knowledge/storlead-knowledge-core/src/main/java/com/storlead/knowledge/pojo/dto/ChatDTO.java

@@ -4,11 +4,12 @@ import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
 
 import java.util.List;
+import java.util.Map;
 
 @Data
 public class ChatDTO {
     @ApiModelProperty(value = "默认输入")
-    private List<String> input;
+    private Map inputs;
     @ApiModelProperty(value = "查询内容")
     private String query;
     @ApiModelProperty(value = "返回模式 blocking/streaming")

+ 39 - 2
java/storlead-knowledge/storlead-knowledge-core/src/main/java/com/storlead/knowledge/utils/HttpService.java

@@ -1,7 +1,9 @@
 package com.storlead.knowledge.utils;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.storlead.framework.common.result.Result;
 import org.springframework.stereotype.Component;
 import org.springframework.web.multipart.MultipartFile;
@@ -19,6 +21,7 @@ import java.nio.file.Path;
 import java.time.Duration;
 import java.util.Map;
 import java.util.UUID;
+import java.util.function.Consumer;
 import java.util.function.Function;
 
 @Component
@@ -135,6 +138,16 @@ public class HttpService {
             String authorization,
             String body,
             SseEmitter emitter
+    ) {
+        postStream(url, authorization, body, emitter, null);
+    }
+
+    public void postStream(
+            String url,
+            String authorization,
+            String body,
+            SseEmitter emitter,
+            java.util.function.Consumer<String> onMessageEnd
     ) {
         try {
             HttpRequest request = HttpRequest.newBuilder()
@@ -148,20 +161,44 @@ public class HttpService {
 
             HttpResponse<InputStream> response =
                     httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
-
+            StringBuilder fullAnswerBuilder = new StringBuilder();
             try (BufferedReader reader = new BufferedReader(
                     new InputStreamReader(response.body(), StandardCharsets.UTF_8))) {
 
                 String line;
+
                 while ((line = reader.readLine()) != null) {
+
                     if (line.startsWith("data:")) {
                         String data = line.substring(5);
 
+                        if (!data.contains("message_end")) {
+                            try {
+                                JsonNode node = JacksonHolder.OBJECT_MAPPER.readTree(data);
+                                JsonNode answerNode = node.get("answer");
+                                if (answerNode != null && !answerNode.isNull()) {
+                                    fullAnswerBuilder.append(answerNode.asText());
+                                }
+                            } catch (Exception ignore) {
+                                // 非 JSON 或格式异常,跳过
+                            }
+                            //System.out.println(data);
+                            emitter.send(data);
+                        }
+
                         if (data.contains("message_end")) {
+                            // 触发消息结束回调,传递完整数据
+                            ObjectNode endNode = (ObjectNode) JacksonHolder.OBJECT_MAPPER.readTree(data);
+                            endNode.put("answer", fullAnswerBuilder.toString());
+
+                            String finalData = JacksonHolder.OBJECT_MAPPER.writeValueAsString(endNode);
+                            if (onMessageEnd != null) {
+                                onMessageEnd.accept(finalData.replace("/",""));
+                            }
+                            emitter.send(finalData);
                             emitter.complete();
                             return;
                         }
-                        emitter.send(data);
                     }
                 }
             }

+ 25 - 0
java/storlead-knowledge/storlead-knowledge-spi/src/main/java/com/storlead/knowledge/service/AiMessageService.java

@@ -0,0 +1,25 @@
+package com.storlead.knowledge.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.storlead.framework.mybatis.service.MyBaseService;
+import com.storlead.knowledge.entity.AiMessageEntity;
+import com.storlead.knowledge.pojo.dto.QueryPageDTO;
+
+/**
+ * <p>
+ * AI对话消息记录 服务类
+ * </p>
+ *
+ * @author storlead
+ * @since 2026-06-09
+ */
+public interface AiMessageService extends MyBaseService<AiMessageEntity> {
+
+    /**
+     * 分页查询聊天记录,keyword 可搜索 query 和 answer 字段
+     *
+     * @param page 分页参数
+     * @return 分页结果
+     */
+    IPage<AiMessageEntity> pageQuery(QueryPageDTO page);
+}