Commit edffc81991441eef863618d779ed8358c13f90c2

Authored by YunaiV
1 parent 17917314

feat(ai1): 增加长期记忆并完善会话管理与生成控制

- 增加长期记忆管理、归档与租户 Job,支持记忆筛选和人工维护
- 支持跨 Agent 会话查询、消息分页和按状态筛选 Agent
- 完善停止生成、断线续传、Redis 恢复及技能进程回收
- 按项目风格整理相关实现与测试
Showing 52 changed files with 3885 additions and 103 deletions
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/Ai1AgentController.java
1 package cn.iocoder.yudao.module.ai1.controller.admin.agent; 1 package cn.iocoder.yudao.module.ai1.controller.admin.agent;
2 2
3 import cn.hutool.core.collection.CollUtil; 3 import cn.hutool.core.collection.CollUtil;
4 -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;  
5 import cn.iocoder.yudao.framework.common.pojo.CommonResult; 4 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
6 import cn.iocoder.yudao.framework.common.pojo.PageResult; 5 import cn.iocoder.yudao.framework.common.pojo.PageResult;
7 import cn.iocoder.yudao.framework.common.util.collection.MapUtils; 6 import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
@@ -97,9 +96,11 @@ public class Ai1AgentController { @@ -97,9 +96,11 @@ public class Ai1AgentController {
97 } 96 }
98 97
99 @GetMapping("/simple-list") 98 @GetMapping("/simple-list")
100 - @Operation(summary = "获得已开启的 Agent 精简列表", description = "用于会话页选择 Agent,仅返回已开启的 Agent")  
101 - public CommonResult<List<Ai1AgentRespVO>> getAgentSimpleList() {  
102 - List<Ai1AgentDO> list = agentService.getAgentListByStatus(CommonStatusEnum.ENABLE.getStatus()); 99 + @Operation(summary = "获得 Agent 精简列表", description = "按状态筛选,不传时返回全部状态;会话页传 0 仅选择已开启的 Agent")
  100 + @Parameter(name = "status", description = "状态,参见 CommonStatusEnum 枚举:0 开启,1 关闭;不传时返回全部状态", example = "0")
  101 + public CommonResult<List<Ai1AgentRespVO>> getAgentSimpleList(
  102 + @RequestParam(value = "status", required = false) Integer status) {
  103 + List<Ai1AgentDO> list = agentService.getAgentListByStatus(status);
103 Map<Long, Ai1ModelDO> modelMap = modelService.getModelMap(convertSet(list, Ai1AgentDO::getModelId)); 104 Map<Long, Ai1ModelDO> modelMap = modelService.getModelMap(convertSet(list, Ai1AgentDO::getModelId));
104 return success(convertList(list, agent -> { 105 return success(convertList(list, agent -> {
105 Ai1AgentRespVO respVO = new Ai1AgentRespVO().setId(agent.getId()).setName(agent.getName()) 106 Ai1AgentRespVO respVO = new Ai1AgentRespVO().setId(agent.getId()).setName(agent.getName())
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentRespVO.java
@@ -43,6 +43,9 @@ public class Ai1AgentRespVO { @@ -43,6 +43,9 @@ public class Ai1AgentRespVO {
43 @Schema(description = "SKILL 编号集合", example = "[1]") 43 @Schema(description = "SKILL 编号集合", example = "[1]")
44 private List<Long> skillIds; 44 private List<Long> skillIds;
45 45
  46 + @Schema(description = "是否开启长期记忆", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
  47 + private Boolean memoryEnabled;
  48 +
46 @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") 49 @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
47 private Integer status; 50 private Integer status;
48 51
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentSaveReqVO.java
@@ -44,4 +44,7 @@ public class Ai1AgentSaveReqVO { @@ -44,4 +44,7 @@ public class Ai1AgentSaveReqVO {
44 @Schema(description = "SKILL 编号集合", example = "[1]") 44 @Schema(description = "SKILL 编号集合", example = "[1]")
45 private List<Long> skillIds; 45 private List<Long> skillIds;
46 46
  47 + @Schema(description = "是否开启长期记忆", example = "false")
  48 + private Boolean memoryEnabled;
  49 +
47 } 50 }
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/memory/Ai1MemoryController.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.controller.admin.memory;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  5 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  6 +import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
  7 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  8 +import cn.iocoder.yudao.module.ai1.controller.admin.memory.vo.Ai1MemoryPageReqVO;
  9 +import cn.iocoder.yudao.module.ai1.controller.admin.memory.vo.Ai1MemoryRespVO;
  10 +import cn.iocoder.yudao.module.ai1.controller.admin.memory.vo.Ai1MemoryUpdateReqVO;
  11 +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO;
  12 +import cn.iocoder.yudao.module.ai1.dal.dataobject.memory.Ai1MemoryDO;
  13 +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService;
  14 +import cn.iocoder.yudao.module.ai1.service.memory.Ai1MemoryService;
  15 +import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
  16 +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
  17 +import io.swagger.v3.oas.annotations.Operation;
  18 +import io.swagger.v3.oas.annotations.Parameter;
  19 +import io.swagger.v3.oas.annotations.tags.Tag;
  20 +import jakarta.annotation.Resource;
  21 +import jakarta.validation.Valid;
  22 +import org.springframework.security.access.prepost.PreAuthorize;
  23 +import org.springframework.validation.annotation.Validated;
  24 +import org.springframework.web.bind.annotation.*;
  25 +
  26 +import java.util.Collections;
  27 +import java.util.List;
  28 +import java.util.Map;
  29 +
  30 +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  31 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
  32 +
  33 +@Tag(name = "管理后台 - AI1 长期记忆")
  34 +@RestController
  35 +@RequestMapping("/ai1/memory")
  36 +@Validated
  37 +public class Ai1MemoryController {
  38 +
  39 + @Resource
  40 + private Ai1MemoryService memoryService;
  41 + @Resource
  42 + private Ai1AgentService agentService;
  43 +
  44 + @Resource
  45 + private AdminUserApi adminUserApi;
  46 +
  47 + @PutMapping("/update")
  48 + @Operation(summary = "更新长期记忆")
  49 + @PreAuthorize("@ss.hasPermission('ai1:memory:update')")
  50 + public CommonResult<Boolean> updateMemory(@Valid @RequestBody Ai1MemoryUpdateReqVO updateReqVO) {
  51 + memoryService.updateMemory(updateReqVO);
  52 + return success(true);
  53 + }
  54 +
  55 + @DeleteMapping("/delete")
  56 + @Operation(summary = "删除长期记忆", description = "之后根据历史对话重新生成")
  57 + @Parameter(name = "id", description = "编号", required = true)
  58 + @PreAuthorize("@ss.hasPermission('ai1:memory:delete')")
  59 + public CommonResult<Boolean> deleteMemory(@RequestParam("id") Long id) {
  60 + memoryService.deleteMemory(id);
  61 + return success(true);
  62 + }
  63 +
  64 + @DeleteMapping("/delete-list")
  65 + @Operation(summary = "批量删除长期记忆", description = "之后根据历史对话重新生成")
  66 + @Parameter(name = "ids", description = "编号列表", required = true)
  67 + @PreAuthorize("@ss.hasPermission('ai1:memory:delete')")
  68 + public CommonResult<Boolean> deleteMemoryList(@RequestParam("ids") List<Long> ids) {
  69 + memoryService.deleteMemoryListByIds(ids);
  70 + return success(true);
  71 + }
  72 +
  73 + @GetMapping("/get")
  74 + @Operation(summary = "获得长期记忆")
  75 + @Parameter(name = "id", description = "编号", required = true, example = "1024")
  76 + @PreAuthorize("@ss.hasPermission('ai1:memory:query')")
  77 + public CommonResult<Ai1MemoryRespVO> getMemory(@RequestParam("id") Long id) {
  78 + Ai1MemoryDO memory = memoryService.getMemory(id);
  79 + return success(buildMemoryRespVO(memory));
  80 + }
  81 +
  82 + @GetMapping("/page")
  83 + @Operation(summary = "获得长期记忆分页")
  84 + @PreAuthorize("@ss.hasPermission('ai1:memory:query')")
  85 + public CommonResult<PageResult<Ai1MemoryRespVO>> getMemoryPage(@Valid Ai1MemoryPageReqVO pageReqVO) {
  86 + PageResult<Ai1MemoryDO> pageResult = memoryService.getMemoryPage(pageReqVO);
  87 + return success(new PageResult<>(buildMemoryRespVOList(pageResult.getList()), pageResult.getTotal()));
  88 + }
  89 +
  90 + // ==================== 拼接 VO ====================
  91 +
  92 + private Ai1MemoryRespVO buildMemoryRespVO(Ai1MemoryDO memory) {
  93 + if (memory == null) {
  94 + return null;
  95 + }
  96 + return CollUtil.getFirst(buildMemoryRespVOList(Collections.singletonList(memory)));
  97 + }
  98 +
  99 + private List<Ai1MemoryRespVO> buildMemoryRespVOList(List<Ai1MemoryDO> list) {
  100 + if (CollUtil.isEmpty(list)) {
  101 + return Collections.emptyList();
  102 + }
  103 + Map<Long, Ai1AgentDO> agentMap = agentService.getAgentMap(convertSet(list, Ai1MemoryDO::getAgentId));
  104 + Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(convertSet(list, Ai1MemoryDO::getUserId));
  105 + return BeanUtils.toBean(list, Ai1MemoryRespVO.class, respVO -> {
  106 + MapUtils.findAndThen(agentMap, respVO.getAgentId(), agent -> respVO.setAgentName(agent.getName()));
  107 + MapUtils.findAndThen(userMap, respVO.getUserId(), user -> respVO.setUserNickname(user.getNickname()));
  108 + });
  109 + }
  110 +
  111 +}
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/memory/vo/Ai1MemoryPageReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.controller.admin.memory.vo;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.PageParam;
  4 +import io.swagger.v3.oas.annotations.media.Schema;
  5 +import lombok.Data;
  6 +import lombok.EqualsAndHashCode;
  7 +import lombok.ToString;
  8 +
  9 +@Schema(description = "管理后台 - AI1 长期记忆分页 Request VO")
  10 +@Data
  11 +@EqualsAndHashCode(callSuper = true)
  12 +@ToString(callSuper = true)
  13 +public class Ai1MemoryPageReqVO extends PageParam {
  14 +
  15 + @Schema(description = "Agent 编号", example = "1")
  16 + private Long agentId;
  17 +
  18 + @Schema(description = "用户编号", example = "1")
  19 + private Long userId;
  20 +
  21 +}
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/memory/vo/Ai1MemoryRespVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.controller.admin.memory.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import lombok.Data;
  5 +
  6 +import java.time.LocalDateTime;
  7 +
  8 +@Schema(description = "管理后台 - AI1 长期记忆 Response VO")
  9 +@Data
  10 +public class Ai1MemoryRespVO {
  11 +
  12 + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
  13 + private Long id;
  14 +
  15 + @Schema(description = "Agent 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
  16 + private Long agentId;
  17 +
  18 + @Schema(description = "Agent 名称", example = "客服助手")
  19 + private String agentName;
  20 +
  21 + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
  22 + private Long userId;
  23 +
  24 + @Schema(description = "用户昵称", example = "芋道源码")
  25 + private String userNickname;
  26 +
  27 + @Schema(description = "记忆正文", requiredMode = Schema.RequiredMode.REQUIRED, example = "## 用户画像\n- 后端工程师")
  28 + private String content;
  29 +
  30 + @Schema(description = "已归档的最后一条消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
  31 + private Long lastMessageId;
  32 +
  33 + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
  34 + private LocalDateTime createTime;
  35 +
  36 + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED)
  37 + private LocalDateTime updateTime;
  38 +
  39 +}
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/memory/vo/Ai1MemoryUpdateReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.controller.admin.memory.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import jakarta.validation.constraints.NotBlank;
  5 +import jakarta.validation.constraints.NotNull;
  6 +import jakarta.validation.constraints.Size;
  7 +import lombok.Data;
  8 +
  9 +@Schema(description = "管理后台 - AI1 长期记忆修改 Request VO")
  10 +@Data
  11 +public class Ai1MemoryUpdateReqVO {
  12 +
  13 + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
  14 + @NotNull(message = "编号不能为空")
  15 + private Long id;
  16 +
  17 + @Schema(description = "记忆正文", requiredMode = Schema.RequiredMode.REQUIRED, example = "## 用户画像\n- 后端工程师")
  18 + @NotBlank(message = "记忆正文不能为空")
  19 + @Size(max = 65535, message = "记忆正文长度不能超过 65535 个字符")
  20 + private String content;
  21 +
  22 +}
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/Ai1SessionController.java
@@ -8,7 +8,9 @@ import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1Sessio @@ -8,7 +8,9 @@ import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1Sessio
8 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionPageReqVO; 8 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionPageReqVO;
9 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionRespVO; 9 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionRespVO;
10 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionUpdateMyReqVO; 10 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionUpdateMyReqVO;
  11 +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO;
11 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; 12 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO;
  13 +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService;
12 import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; 14 import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService;
13 import cn.iocoder.yudao.module.system.api.user.AdminUserApi; 15 import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
14 import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; 16 import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
@@ -36,6 +38,8 @@ public class Ai1SessionController { @@ -36,6 +38,8 @@ public class Ai1SessionController {
36 38
37 @Resource 39 @Resource
38 private Ai1SessionService sessionService; 40 private Ai1SessionService sessionService;
  41 + @Resource
  42 + private Ai1AgentService agentService;
39 43
40 @Resource 44 @Resource
41 private AdminUserApi adminUserApi; 45 private AdminUserApi adminUserApi;
@@ -78,13 +82,16 @@ public class Ai1SessionController { @@ -78,13 +82,16 @@ public class Ai1SessionController {
78 // ==================== 管理 ==================== 82 // ==================== 管理 ====================
79 83
80 @GetMapping("/page") 84 @GetMapping("/page")
81 - @Operation(summary = "获得会话分页", description = "管理端查看 Agent 下全部用户的会话") 85 + @Operation(summary = "获得会话分页", description = "管理端查看全部用户的会话,可按 Agent 筛选")
82 @PreAuthorize("@ss.hasPermission('ai1:session:query')") 86 @PreAuthorize("@ss.hasPermission('ai1:session:query')")
83 public CommonResult<PageResult<Ai1SessionRespVO>> getSessionPage(@Valid Ai1SessionPageReqVO pageReqVO) { 87 public CommonResult<PageResult<Ai1SessionRespVO>> getSessionPage(@Valid Ai1SessionPageReqVO pageReqVO) {
84 PageResult<Ai1SessionDO> pageResult = sessionService.getSessionPage(pageReqVO); 88 PageResult<Ai1SessionDO> pageResult = sessionService.getSessionPage(pageReqVO);
  89 + Map<Long, Ai1AgentDO> agentMap = agentService.getAgentMap(convertSet(pageResult.getList(), Ai1SessionDO::getAgentId));
85 Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(convertSet(pageResult.getList(), Ai1SessionDO::getUserId)); 90 Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(convertSet(pageResult.getList(), Ai1SessionDO::getUserId));
86 - return success(BeanUtils.toBean(pageResult, Ai1SessionRespVO.class, respVO ->  
87 - MapUtils.findAndThen(userMap, respVO.getUserId(), user -> respVO.setUserNickname(user.getNickname())))); 91 + return success(BeanUtils.toBean(pageResult, Ai1SessionRespVO.class, respVO -> {
  92 + MapUtils.findAndThen(agentMap, respVO.getAgentId(), agent -> respVO.setAgentName(agent.getName()));
  93 + MapUtils.findAndThen(userMap, respVO.getUserId(), user -> respVO.setUserNickname(user.getNickname()));
  94 + }));
88 } 95 }
89 96
90 } 97 }
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/Ai1SessionMessageController.java
1 package cn.iocoder.yudao.module.ai1.controller.admin.session; 1 package cn.iocoder.yudao.module.ai1.controller.admin.session;
2 2
3 import cn.iocoder.yudao.framework.common.pojo.CommonResult; 3 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  4 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
4 import cn.iocoder.yudao.framework.common.util.object.BeanUtils; 5 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  6 +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessagePageReqVO;
5 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageRespVO; 7 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageRespVO;
6 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO; 8 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO;
7 import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; 9 import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService;
@@ -63,8 +65,25 @@ public class Ai1SessionMessageController { @@ -63,8 +65,25 @@ public class Ai1SessionMessageController {
63 return sessionMessageService.resumeSessionMessageStream(getLoginUserId(), messageId, lastEventId); 65 return sessionMessageService.resumeSessionMessageStream(getLoginUserId(), messageId, lastEventId);
64 } 66 }
65 67
  68 + @PostMapping("/stop-my")
  69 + @Operation(summary = "停止【我的】会话生成", description = "终止会话中生成中的助手消息,保留已生成的内容")
  70 + @Parameter(name = "sessionId", description = "会话编号", required = true, example = "1024")
  71 + @PreAuthorize("@ss.hasPermission('ai1:session:my')")
  72 + public CommonResult<Boolean> stopSessionMessageMy(@RequestParam("sessionId") Long sessionId) {
  73 + sessionMessageService.stopSessionMessageMy(getLoginUserId(), sessionId);
  74 + return success(true);
  75 + }
  76 +
66 // ==================== 管理 ==================== 77 // ==================== 管理 ====================
67 78
  79 + @GetMapping("/page")
  80 + @Operation(summary = "获得会话消息分页", description = "按消息编号倒序")
  81 + @PreAuthorize("@ss.hasPermission('ai1:session:query')")
  82 + public CommonResult<PageResult<Ai1SessionMessageRespVO>> getSessionMessagePage(@Valid Ai1SessionMessagePageReqVO pageReqVO) {
  83 + return success(BeanUtils.toBean(sessionMessageService.getSessionMessagePage(pageReqVO),
  84 + Ai1SessionMessageRespVO.class));
  85 + }
  86 +
68 @GetMapping("/list") 87 @GetMapping("/list")
69 @Operation(summary = "获得会话消息列表", description = "管理端查看任意用户的会话明细") 88 @Operation(summary = "获得会话消息列表", description = "管理端查看任意用户的会话明细")
70 @Parameter(name = "sessionId", description = "会话编号", required = true, example = "1024") 89 @Parameter(name = "sessionId", description = "会话编号", required = true, example = "1024")
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/message/Ai1SessionMessagePageReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.PageParam;
  4 +import io.swagger.v3.oas.annotations.media.Schema;
  5 +import jakarta.validation.constraints.NotNull;
  6 +import lombok.Data;
  7 +import lombok.EqualsAndHashCode;
  8 +import lombok.ToString;
  9 +
  10 +@Schema(description = "管理后台 - AI1 会话消息分页 Request VO")
  11 +@Data
  12 +@EqualsAndHashCode(callSuper = true)
  13 +@ToString(callSuper = true)
  14 +public class Ai1SessionMessagePageReqVO extends PageParam {
  15 +
  16 + @Schema(description = "会话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
  17 + @NotNull(message = "会话编号不能为空")
  18 + private Long sessionId;
  19 +
  20 +}
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionPageReqVO.java
@@ -2,7 +2,6 @@ package cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session; @@ -2,7 +2,6 @@ package cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session;
2 2
3 import cn.iocoder.yudao.framework.common.pojo.PageParam; 3 import cn.iocoder.yudao.framework.common.pojo.PageParam;
4 import io.swagger.v3.oas.annotations.media.Schema; 4 import io.swagger.v3.oas.annotations.media.Schema;
5 -import jakarta.validation.constraints.NotNull;  
6 import lombok.Data; 5 import lombok.Data;
7 import lombok.EqualsAndHashCode; 6 import lombok.EqualsAndHashCode;
8 import lombok.ToString; 7 import lombok.ToString;
@@ -13,8 +12,7 @@ import lombok.ToString; @@ -13,8 +12,7 @@ import lombok.ToString;
13 @ToString(callSuper = true) 12 @ToString(callSuper = true)
14 public class Ai1SessionPageReqVO extends PageParam { 13 public class Ai1SessionPageReqVO extends PageParam {
15 14
16 - @Schema(description = "Agent 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")  
17 - @NotNull(message = "Agent 编号不能为空") 15 + @Schema(description = "Agent 编号,为空时查询全部 Agent", example = "1")
18 private Long agentId; 16 private Long agentId;
19 17
20 @Schema(description = "标题,模糊匹配", example = "三体") 18 @Schema(description = "标题,模糊匹配", example = "三体")
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionRespVO.java
@@ -15,6 +15,9 @@ public class Ai1SessionRespVO { @@ -15,6 +15,9 @@ public class Ai1SessionRespVO {
15 @Schema(description = "Agent 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") 15 @Schema(description = "Agent 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
16 private Long agentId; 16 private Long agentId;
17 17
  18 + @Schema(description = "Agent 名称", example = "客服助手")
  19 + private String agentName;
  20 +
18 @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") 21 @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
19 private Long userId; 22 private Long userId;
20 23
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/agent/Ai1AgentDO.java
@@ -82,6 +82,12 @@ public class Ai1AgentDO extends TenantBaseDO { @@ -82,6 +82,12 @@ public class Ai1AgentDO extends TenantBaseDO {
82 @TableField(typeHandler = LongListTypeHandler.class) 82 @TableField(typeHandler = LongListTypeHandler.class)
83 private List<Long> skillIds; 83 private List<Long> skillIds;
84 /** 84 /**
  85 + * 是否开启长期记忆
  86 + *
  87 + * 开启后,对话时注入记忆,并由 Job 归档已完成的消息
  88 + */
  89 + private Boolean memoryEnabled;
  90 + /**
85 * 状态 91 * 状态
86 * 92 *
87 * 枚举 {@link CommonStatusEnum};开启后才可在「Agent 会话」中使用 93 * 枚举 {@link CommonStatusEnum};开启后才可在「Agent 会话」中使用
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/memory/Ai1MemoryDO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.dal.dataobject.memory;
  2 +
  3 +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
  4 +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO;
  5 +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO;
  6 +import com.baomidou.mybatisplus.annotation.KeySequence;
  7 +import com.baomidou.mybatisplus.annotation.TableId;
  8 +import com.baomidou.mybatisplus.annotation.TableName;
  9 +import lombok.*;
  10 +
  11 +/**
  12 + * AI1 长期记忆 DO
  13 + *
  14 + * 每个用户在每个 Agent 下一条未删除的记忆,由归档 Job 根据已完成的消息增量生成;
  15 + * 删除为逻辑删除,保留历史记录便于追溯,之后的归档会根据历史消息生成新的记录
  16 + *
  17 + * @author 芋道源码
  18 + */
  19 +@TableName("ai1_memory")
  20 +@KeySequence("ai1_memory_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
  21 +@Data
  22 +@EqualsAndHashCode(callSuper = true)
  23 +@ToString(callSuper = true)
  24 +@Builder
  25 +@NoArgsConstructor
  26 +@AllArgsConstructor
  27 +public class Ai1MemoryDO extends TenantBaseDO {
  28 +
  29 + /**
  30 + * 编号
  31 + */
  32 + @TableId
  33 + private Long id;
  34 + /**
  35 + * Agent 编号
  36 + *
  37 + * 关联 {@link Ai1AgentDO#getId()}
  38 + */
  39 + private Long agentId;
  40 + /**
  41 + * 用户编号
  42 + *
  43 + * 关联 AdminUserDO 的 id 编号
  44 + */
  45 + private Long userId;
  46 + /**
  47 + * 记忆正文,Markdown 格式,按维度分节
  48 + */
  49 + private String content;
  50 + /**
  51 + * 已归档的最后一条消息编号,即增量归档的水位线
  52 + *
  53 + * 关联 {@link Ai1SessionMessageDO#getId()}
  54 + */
  55 + private Long lastMessageId;
  56 +
  57 +}
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/agent/Ai1AgentMapper.java
@@ -28,7 +28,7 @@ public interface Ai1AgentMapper extends BaseMapperX&lt;Ai1AgentDO&gt; { @@ -28,7 +28,7 @@ public interface Ai1AgentMapper extends BaseMapperX&lt;Ai1AgentDO&gt; {
28 28
29 default List<Ai1AgentDO> selectListByStatusOrderById(Integer status) { 29 default List<Ai1AgentDO> selectListByStatusOrderById(Integer status) {
30 return selectList(new LambdaQueryWrapperX<Ai1AgentDO>() 30 return selectList(new LambdaQueryWrapperX<Ai1AgentDO>()
31 - .eq(Ai1AgentDO::getStatus, status) 31 + .eqIfPresent(Ai1AgentDO::getStatus, status)
32 .orderByAsc(Ai1AgentDO::getId)); 32 .orderByAsc(Ai1AgentDO::getId));
33 } 33 }
34 34
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/memory/Ai1MemoryMapper.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.dal.mysql.memory;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  4 +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  5 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  6 +import cn.iocoder.yudao.module.ai1.controller.admin.memory.vo.Ai1MemoryPageReqVO;
  7 +import cn.iocoder.yudao.module.ai1.dal.dataobject.memory.Ai1MemoryDO;
  8 +import org.apache.ibatis.annotations.Mapper;
  9 +
  10 +/**
  11 + * AI1 长期记忆 Mapper
  12 + *
  13 + * @author 芋道源码
  14 + */
  15 +@Mapper
  16 +public interface Ai1MemoryMapper extends BaseMapperX<Ai1MemoryDO> {
  17 +
  18 + default PageResult<Ai1MemoryDO> selectPage(Ai1MemoryPageReqVO reqVO) {
  19 + return selectPage(reqVO, new LambdaQueryWrapperX<Ai1MemoryDO>()
  20 + .eqIfPresent(Ai1MemoryDO::getAgentId, reqVO.getAgentId())
  21 + .eqIfPresent(Ai1MemoryDO::getUserId, reqVO.getUserId())
  22 + .orderByDesc(Ai1MemoryDO::getId));
  23 + }
  24 +
  25 + /**
  26 + * 未加唯一索引,极端并发下可能存在重复记录,取第一条
  27 + */
  28 + default Ai1MemoryDO selectByAgentIdAndUserId(Long agentId, Long userId) {
  29 + return selectFirstOne(Ai1MemoryDO::getAgentId, agentId, Ai1MemoryDO::getUserId, userId);
  30 + }
  31 +
  32 +}
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/session/Ai1SessionMapper.java
@@ -10,6 +10,8 @@ import org.apache.ibatis.annotations.Mapper; @@ -10,6 +10,8 @@ import org.apache.ibatis.annotations.Mapper;
10 import java.util.Collection; 10 import java.util.Collection;
11 import java.util.List; 11 import java.util.List;
12 12
  13 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
  14 +
13 /** 15 /**
14 * AI1 会话 Mapper 16 * AI1 会话 Mapper
15 * 17 *
@@ -20,7 +22,7 @@ public interface Ai1SessionMapper extends BaseMapperX&lt;Ai1SessionDO&gt; { @@ -20,7 +22,7 @@ public interface Ai1SessionMapper extends BaseMapperX&lt;Ai1SessionDO&gt; {
20 22
21 default PageResult<Ai1SessionDO> selectPage(Ai1SessionPageReqVO reqVO) { 23 default PageResult<Ai1SessionDO> selectPage(Ai1SessionPageReqVO reqVO) {
22 return selectPage(reqVO, new LambdaQueryWrapperX<Ai1SessionDO>() 24 return selectPage(reqVO, new LambdaQueryWrapperX<Ai1SessionDO>()
23 - .eq(Ai1SessionDO::getAgentId, reqVO.getAgentId()) 25 + .eqIfPresent(Ai1SessionDO::getAgentId, reqVO.getAgentId())
24 .likeIfPresent(Ai1SessionDO::getTitle, reqVO.getTitle()) 26 .likeIfPresent(Ai1SessionDO::getTitle, reqVO.getTitle())
25 .eqIfPresent(Ai1SessionDO::getUserId, reqVO.getUserId()) 27 .eqIfPresent(Ai1SessionDO::getUserId, reqVO.getUserId())
26 .orderByDesc(Ai1SessionDO::getId)); 28 .orderByDesc(Ai1SessionDO::getId));
@@ -33,6 +35,14 @@ public interface Ai1SessionMapper extends BaseMapperX&lt;Ai1SessionDO&gt; { @@ -33,6 +35,14 @@ public interface Ai1SessionMapper extends BaseMapperX&lt;Ai1SessionDO&gt; {
33 .orderByDesc(Ai1SessionDO::getId)); 35 .orderByDesc(Ai1SessionDO::getId));
34 } 36 }
35 37
  38 + default List<Long> selectUserIdListByAgentId(Long agentId) {
  39 + return convertList(selectList(new LambdaQueryWrapperX<Ai1SessionDO>()
  40 + .eq(Ai1SessionDO::getAgentId, agentId)
  41 + .select(Ai1SessionDO::getUserId)
  42 + .groupBy(Ai1SessionDO::getUserId)
  43 + .orderByAsc(Ai1SessionDO::getUserId)), Ai1SessionDO::getUserId);
  44 + }
  45 +
36 default List<Ai1SessionDO> selectListByAgentIds(Collection<Long> agentIds) { 46 default List<Ai1SessionDO> selectListByAgentIds(Collection<Long> agentIds) {
37 return selectList(new LambdaQueryWrapperX<Ai1SessionDO>().in(Ai1SessionDO::getAgentId, agentIds)); 47 return selectList(new LambdaQueryWrapperX<Ai1SessionDO>().in(Ai1SessionDO::getAgentId, agentIds));
38 } 48 }
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/session/Ai1SessionMessageMapper.java
1 package cn.iocoder.yudao.module.ai1.dal.mysql.session; 1 package cn.iocoder.yudao.module.ai1.dal.mysql.session;
2 2
  3 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
3 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; 4 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
4 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; 5 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
5 import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; 6 import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX;
  7 +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessagePageReqVO;
6 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; 8 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO;
7 import org.apache.ibatis.annotations.Mapper; 9 import org.apache.ibatis.annotations.Mapper;
8 10
9 import java.util.Collection; 11 import java.util.Collection;
10 import java.util.List; 12 import java.util.List;
11 13
  14 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
  15 +
12 /** 16 /**
13 * AI1 会话消息 Mapper 17 * AI1 会话消息 Mapper
14 * 18 *
@@ -17,6 +21,12 @@ import java.util.List; @@ -17,6 +21,12 @@ import java.util.List;
17 @Mapper 21 @Mapper
18 public interface Ai1SessionMessageMapper extends BaseMapperX<Ai1SessionMessageDO> { 22 public interface Ai1SessionMessageMapper extends BaseMapperX<Ai1SessionMessageDO> {
19 23
  24 + default PageResult<Ai1SessionMessageDO> selectPage(Ai1SessionMessagePageReqVO reqVO) {
  25 + return selectPage(reqVO, new LambdaQueryWrapperX<Ai1SessionMessageDO>()
  26 + .eq(Ai1SessionMessageDO::getSessionId, reqVO.getSessionId())
  27 + .orderByDesc(Ai1SessionMessageDO::getId));
  28 + }
  29 +
20 default List<Ai1SessionMessageDO> selectListBySessionId(Long sessionId) { 30 default List<Ai1SessionMessageDO> selectListBySessionId(Long sessionId) {
21 return selectList(new LambdaQueryWrapperX<Ai1SessionMessageDO>() 31 return selectList(new LambdaQueryWrapperX<Ai1SessionMessageDO>()
22 .eq(Ai1SessionMessageDO::getSessionId, sessionId) 32 .eq(Ai1SessionMessageDO::getSessionId, sessionId)
@@ -24,6 +34,18 @@ public interface Ai1SessionMessageMapper extends BaseMapperX&lt;Ai1SessionMessageDO @@ -24,6 +34,18 @@ public interface Ai1SessionMessageMapper extends BaseMapperX&lt;Ai1SessionMessageDO
24 } 34 }
25 35
26 /** 36 /**
  37 + * 只查询编号,不读取正文、思考过程
  38 + */
  39 + default List<Long> selectIdListBySessionIdAndRoleAndStatus(Long sessionId, String role, Integer status) {
  40 + return convertList(selectList(new LambdaQueryWrapperX<Ai1SessionMessageDO>()
  41 + .select(Ai1SessionMessageDO::getId)
  42 + .eq(Ai1SessionMessageDO::getSessionId, sessionId)
  43 + .eq(Ai1SessionMessageDO::getRole, role)
  44 + .eq(Ai1SessionMessageDO::getStatus, status)
  45 + .orderByAsc(Ai1SessionMessageDO::getId)), Ai1SessionMessageDO::getId);
  46 + }
  47 +
  48 + /**
27 * 按编号倒序,取指定消息之前最近的 limit 条 49 * 按编号倒序,取指定消息之前最近的 limit 条
28 */ 50 */
29 default List<Ai1SessionMessageDO> selectListBySessionIdAndIdLessThan(Long sessionId, Long id, Integer limit) { 51 default List<Ai1SessionMessageDO> selectListBySessionIdAndIdLessThan(Long sessionId, Long id, Integer limit) {
@@ -34,6 +56,32 @@ public interface Ai1SessionMessageMapper extends BaseMapperX&lt;Ai1SessionMessageDO @@ -34,6 +56,32 @@ public interface Ai1SessionMessageMapper extends BaseMapperX&lt;Ai1SessionMessageDO
34 .orderByDesc("id")); 56 .orderByDesc("id"));
35 } 57 }
36 58
  59 + /**
  60 + * 获得多个会话中,编号大于 id 的第一条指定状态的消息
  61 + */
  62 + default Ai1SessionMessageDO selectFirstBySessionIdsAndStatusAndIdGt(Collection<Long> sessionIds, Integer status, Long id) {
  63 + return selectOne(new QueryWrapperX<Ai1SessionMessageDO>()
  64 + .limitN(1)
  65 + .in("session_id", sessionIds)
  66 + .eq("status", status)
  67 + .gt("id", id)
  68 + .orderByAsc("id"));
  69 + }
  70 +
  71 + /**
  72 + * 获得多个会话中,编号在 (minId, maxId) 之间的指定状态消息,按编号升序取前 limit 条;maxId 为空时不限上界
  73 + */
  74 + default List<Ai1SessionMessageDO> selectListBySessionIdsAndStatusAndIdBetween(Collection<Long> sessionIds, Integer status,
  75 + Long minId, Long maxId, Integer limit) {
  76 + return selectList(new QueryWrapperX<Ai1SessionMessageDO>()
  77 + .limitN(limit)
  78 + .in("session_id", sessionIds)
  79 + .eq("status", status)
  80 + .gt("id", minId)
  81 + .lt(maxId != null, "id", maxId)
  82 + .orderByAsc("id"));
  83 + }
  84 +
37 default Long selectCountBySessionId(Long sessionId) { 85 default Long selectCountBySessionId(Long sessionId) {
38 return selectCount(Ai1SessionMessageDO::getSessionId, sessionId); 86 return selectCount(Ai1SessionMessageDO::getSessionId, sessionId);
39 } 87 }
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/redis/Ai1RedisKeyConstants.java
@@ -39,4 +39,31 @@ public interface Ai1RedisKeyConstants { @@ -39,4 +39,31 @@ public interface Ai1RedisKeyConstants {
39 */ 39 */
40 String SESSION_RESULT_STREAM = "ai1:session:result:%d"; 40 String SESSION_RESULT_STREAM = "ai1:session:result:%d";
41 41
  42 + /**
  43 + * 会话停止意图,按助手消息编号隔离;覆盖排队中、其他节点执行中的任务
  44 + * <p>
  45 + * KEY 格式:ai1:session:stop:{messageId}
  46 + * VALUE 数据类型:String,过期时间与结果流一致
  47 + */
  48 + String SESSION_STOP = "ai1:session:stop:%d";
  49 +
  50 + /**
  51 + * 会话停止通知,消息内容为助手消息编号,用于即时唤醒执行节点
  52 + */
  53 + String SESSION_STOP_TOPIC = "ai1:session:stop";
  54 +
  55 + /**
  56 + * 长期记忆归档锁,按租户互斥;使用 Redisson 看门狗续期
  57 + * <p>
  58 + * KEY 格式:ai1:memory:archive-lock:{tenantId}
  59 + */
  60 + String MEMORY_ARCHIVE_LOCK = "ai1:memory:archive-lock:%s";
  61 +
  62 + /**
  63 + * 长期记忆快照锁:消息落库事务与归档读取互斥,避免低编号消息晚提交后落在水位线之前
  64 + * <p>
  65 + * KEY 格式:ai1:memory:snapshot-lock:{agentId}:{userId};Agent 编号全局唯一,KEY 不带 tenantId
  66 + */
  67 + String MEMORY_SNAPSHOT_LOCK = "ai1:memory:snapshot-lock:%d:%d";
  68 +
42 } 69 }
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/redis/memory/Ai1MemoryLockRedisDAO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.dal.redis.memory;
  2 +
  3 +import jakarta.annotation.Resource;
  4 +import org.redisson.api.RLock;
  5 +import org.redisson.api.RedissonClient;
  6 +import org.springframework.stereotype.Repository;
  7 +
  8 +import java.util.function.Supplier;
  9 +
  10 +import static cn.iocoder.yudao.module.ai1.dal.redis.Ai1RedisKeyConstants.MEMORY_ARCHIVE_LOCK;
  11 +import static cn.iocoder.yudao.module.ai1.dal.redis.Ai1RedisKeyConstants.MEMORY_SNAPSHOT_LOCK;
  12 +
  13 +/**
  14 + * AI1 长期记忆的锁 Redis DAO
  15 + *
  16 + * 不设置租期,由 Redisson 看门狗续期
  17 + *
  18 + * @author 芋道源码
  19 + */
  20 +@Repository
  21 +public class Ai1MemoryLockRedisDAO {
  22 +
  23 + @Resource
  24 + private RedissonClient redissonClient;
  25 +
  26 + /**
  27 + * 按租户尝试获取归档锁并执行;其他节点归档中时不等待、不执行
  28 + *
  29 + * @param tenantId 租户编号
  30 + * @param supplier 归档逻辑
  31 + * @return 执行结果;未获取到锁时返回 null
  32 + */
  33 + public <V> V tryLockArchive(Long tenantId, Supplier<V> supplier) {
  34 + RLock lock = redissonClient.getLock(formatArchiveKey(tenantId));
  35 + if (!lock.tryLock()) {
  36 + return null;
  37 + }
  38 + try {
  39 + return supplier.get();
  40 + } finally {
  41 + if (lock.isHeldByCurrentThread()) {
  42 + lock.unlock();
  43 + }
  44 + }
  45 + }
  46 +
  47 + /**
  48 + * 获取用户在 Agent 下的快照锁并执行:消息落库事务与归档读取新消息互斥
  49 + *
  50 + * @param agentId Agent 编号
  51 + * @param userId 用户编号
  52 + * @param supplier 加锁执行的逻辑
  53 + * @return 执行结果
  54 + */
  55 + public <V> V lockSnapshot(Long agentId, Long userId, Supplier<V> supplier) {
  56 + RLock lock = redissonClient.getLock(formatSnapshotKey(agentId, userId));
  57 + lock.lock();
  58 + try {
  59 + return supplier.get();
  60 + } finally {
  61 + lock.unlock();
  62 + }
  63 + }
  64 +
  65 + private static String formatArchiveKey(Long tenantId) {
  66 + return String.format(MEMORY_ARCHIVE_LOCK, tenantId);
  67 + }
  68 +
  69 + private static String formatSnapshotKey(Long agentId, Long userId) {
  70 + return String.format(MEMORY_SNAPSHOT_LOCK, agentId, userId);
  71 + }
  72 +
  73 +}
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/Ai1ErrorCodeConstants.java
@@ -77,4 +77,7 @@ public interface Ai1ErrorCodeConstants { @@ -77,4 +77,7 @@ public interface Ai1ErrorCodeConstants {
77 // ========== 会话消息 1-041-009-000 ========== 77 // ========== 会话消息 1-041-009-000 ==========
78 ErrorCode SESSION_MESSAGE_NOT_EXISTS = new ErrorCode(1_041_009_000, "会话消息不存在"); 78 ErrorCode SESSION_MESSAGE_NOT_EXISTS = new ErrorCode(1_041_009_000, "会话消息不存在");
79 79
  80 + // ========== 长期记忆 1-041-010-000 ==========
  81 + ErrorCode MEMORY_NOT_EXISTS = new ErrorCode(1_041_010_000, "长期记忆不存在");
  82 +
80 } 83 }
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/config/YudaoAi1Properties.java
@@ -27,6 +27,11 @@ public class YudaoAi1Properties { @@ -27,6 +27,11 @@ public class YudaoAi1Properties {
27 */ 27 */
28 private Skill skill = new Skill(); 28 private Skill skill = new Skill();
29 29
  30 + /**
  31 + * 长期记忆
  32 + */
  33 + private Memory memory = new Memory();
  34 +
30 @Data 35 @Data
31 public static class Milvus { 36 public static class Milvus {
32 37
@@ -143,4 +148,19 @@ public class YudaoAi1Properties { @@ -143,4 +148,19 @@ public class YudaoAi1Properties {
143 148
144 } 149 }
145 150
  151 + @Data
  152 + public static class Memory {
  153 +
  154 + /**
  155 + * 默认记忆正文最大长度(字符)
  156 + */
  157 + public static final int DEFAULT_MAX_LENGTH = 2000;
  158 +
  159 + /**
  160 + * 记忆正文最大长度(字符),超出时截断
  161 + */
  162 + private Integer maxLength = DEFAULT_MAX_LENGTH;
  163 +
  164 + }
  165 +
146 } 166 }
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatTool.java
@@ -90,15 +90,15 @@ public class Ai1LlmChatTool { @@ -90,15 +90,15 @@ public class Ai1LlmChatTool {
90 // 2.1 思考过程:Spring AI 1.x 的 reasoning_content 是增量分片,直接转发(重复文本也可能是有效增量) 90 // 2.1 思考过程:Spring AI 1.x 的 reasoning_content 是增量分片,直接转发(重复文本也可能是有效增量)
91 Object reasoningValue = generation.getOutput().getMetadata().get(METADATA_REASONING); 91 Object reasoningValue = generation.getOutput().getMetadata().get(METADATA_REASONING);
92 String delta = reasoningValue instanceof String value ? value : null; 92 String delta = reasoningValue instanceof String value ? value : null;
93 - if (StrUtil.isNotBlank(delta)) { 93 + if (StrUtil.isNotEmpty(delta)) {
94 thinkingText.append(delta); 94 thinkingText.append(delta);
95 if (onThinking != null) { 95 if (onThinking != null) {
96 onThinking.accept(delta); 96 onThinking.accept(delta);
97 } 97 }
98 } 98 }
99 - // 2.2 回复内容 99 + // 2.2 回复内容:只跳过空串,空格、换行分片也是有效内容
100 String chunk = generation.getOutput().getText(); 100 String chunk = generation.getOutput().getText();
101 - if (StrUtil.isNotBlank(chunk)) { 101 + if (StrUtil.isNotEmpty(chunk)) {
102 contentText.append(chunk); 102 contentText.append(chunk);
103 if (onContent != null) { 103 if (onContent != null) {
104 onContent.accept(chunk); 104 onContent.accept(chunk);
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/memory/Ai1MemoryArchiveTool.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.harness.memory;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.util.ObjUtil;
  5 +import cn.hutool.core.util.StrUtil;
  6 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  7 +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
  8 +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO;
  9 +import cn.iocoder.yudao.module.ai1.dal.dataobject.memory.Ai1MemoryDO;
  10 +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO;
  11 +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO;
  12 +import cn.iocoder.yudao.module.ai1.dal.redis.memory.Ai1MemoryLockRedisDAO;
  13 +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum;
  14 +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties;
  15 +import cn.iocoder.yudao.module.ai1.harness.session.Ai1SessionStreamTool;
  16 +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService;
  17 +import cn.iocoder.yudao.module.ai1.service.memory.Ai1MemoryService;
  18 +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService;
  19 +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService;
  20 +import jakarta.annotation.Resource;
  21 +import lombok.extern.slf4j.Slf4j;
  22 +import org.springframework.stereotype.Component;
  23 +
  24 +import java.util.Collections;
  25 +import java.util.List;
  26 +
  27 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
  28 +
  29 +/**
  30 + * AI1 长期记忆归档工具(harness 运行时)
  31 + *
  32 + * 【归档流程】由 Ai1MemoryArchiveJob 按租户调用 {@link #archive}
  33 + * 1. 竞争:按租户 tryLock 归档锁,抢不到直接跳过
  34 + * 2. 检测:遍历开启且开启记忆的 Agent,对其下每个用户,读取水位线 lastMessageId 之后的已完成消息
  35 + * 3. 生成:「历史记忆 + 新消息」经模型生成最新记忆,写入并推进水位线
  36 + *
  37 + * 【关键约定】
  38 + * 1. 新消息只取「安全前缀」:截止到水位线之后第一条生成中的消息,避免水位线越过尚未完成的回复
  39 + * 2. 超时且无 worker 执行的孤儿生成先按中断收尾,避免永久阻塞安全前缀
  40 + * 3. 单次最多归档 {@link #BATCH_SIZE} 条,剩余的由下次执行继续
  41 + * 4. 写入前校验:记忆被人工修改、删除,或 Agent 已删除时放弃本次结果
  42 + * 5. 锁顺序固定为「归档锁 → 消息执行锁(tryLock)」,快照锁只用于读取新消息,持有期间不收尾、不调用模型
  43 + *
  44 + * @author 芋道源码
  45 + */
  46 +@Component
  47 +@Slf4j
  48 +public class Ai1MemoryArchiveTool {
  49 +
  50 + /**
  51 + * 单个用户单次归档的最大消息数
  52 + */
  53 + static final int BATCH_SIZE = 100;
  54 +
  55 + @Resource
  56 + private Ai1MemoryLockRedisDAO memoryLockRedisDAO;
  57 + @Resource
  58 + private YudaoAi1Properties ai1Properties;
  59 +
  60 + @Resource
  61 + private Ai1AgentService agentService;
  62 + @Resource
  63 + private Ai1SessionService sessionService;
  64 + @Resource
  65 + private Ai1SessionMessageService sessionMessageService;
  66 + @Resource
  67 + private Ai1MemoryService memoryService;
  68 +
  69 + @Resource
  70 + private Ai1MemoryTool memoryTool;
  71 + @Resource
  72 + private Ai1SessionStreamTool sessionStreamTool;
  73 +
  74 + /**
  75 + * 归档当前租户下全部开启记忆的 Agent
  76 + *
  77 + * @return 归档成功的用户数
  78 + */
  79 + public int archive() {
  80 + // 1. 按租户竞争归档锁,其他节点归档中时直接跳过
  81 + Integer count = memoryLockRedisDAO.tryLockArchive(TenantContextHolder.getTenantId(), this::archiveAll);
  82 + if (count == null) {
  83 + log.info("[archive][租户({}) 归档进行中,跳过本次]", TenantContextHolder.getTenantId());
  84 + return 0;
  85 + }
  86 + return count;
  87 + }
  88 +
  89 + private int archiveAll() {
  90 + // 2. 逐个 Agent、逐个用户归档;单个用户失败不影响其他用户
  91 + int count = 0;
  92 + int maxLength = ai1Properties.getMemory().getMaxLength();
  93 + for (Ai1AgentDO agent : agentService.getAgentListByStatus(CommonStatusEnum.ENABLE.getStatus())) {
  94 + if (!Boolean.TRUE.equals(agent.getMemoryEnabled())) {
  95 + continue;
  96 + }
  97 + for (Long userId : sessionService.getUserIdListByAgentId(agent.getId())) {
  98 + if (Thread.currentThread().isInterrupted()) {
  99 + return count;
  100 + }
  101 + try {
  102 + if (archiveOne(agent, userId, maxLength)) {
  103 + count++;
  104 + }
  105 + } catch (Exception e) {
  106 + log.warn("[archive][Agent({}) 用户({}) 归档失败]", agent.getId(), userId, e);
  107 + }
  108 + }
  109 + }
  110 + return count;
  111 + }
  112 +
  113 + /**
  114 + * 归档单个用户:读取新消息 → 生成记忆 → 校验后写入并推进水位线
  115 + *
  116 + * @return 是否写入成功
  117 + */
  118 + boolean archiveOne(Ai1AgentDO agent, Long userId, int maxLength) {
  119 + // 1.1 读取水位线,并收尾水位线之后超时的孤儿生成
  120 + Ai1MemoryDO memory = memoryService.getMemoryByAgentIdAndUserId(agent.getId(), userId);
  121 + Long lastMessageId = memory != null ? ObjUtil.defaultIfNull(memory.getLastMessageId(), 0L) : 0L;
  122 + failStaleMessages(agent.getId(), userId, lastMessageId);
  123 + // 1.2 读取水位线之后的新消息,无则跳过
  124 + List<Ai1SessionMessageDO> messages = getNewMessageList(agent.getId(), userId, lastMessageId);
  125 + if (CollUtil.isEmpty(messages)) {
  126 + return false;
  127 + }
  128 +
  129 + // 2. 生成最新记忆
  130 + String content = memoryTool.generate(agent, memory != null ? memory.getContent() : null, messages, maxLength);
  131 + if (StrUtil.isBlank(content)) {
  132 + return false;
  133 + }
  134 +
  135 + // 3.1 首次归档(含删除后重新生成):Agent 已删除,或记忆已存在时放弃,由下次执行基于新记忆继续
  136 + Long newLastMessageId = CollUtil.getLast(messages).getId();
  137 + if (memory == null) {
  138 + if (!memoryService.createMemoryIfAgentExists(Ai1MemoryDO.builder().agentId(agent.getId()).userId(userId)
  139 + .content(content).lastMessageId(newLastMessageId).build())) {
  140 + log.info("[archiveOne][Agent({}) 用户({}) Agent 已删除或记忆已存在,放弃本次结果]", agent.getId(), userId);
  141 + return false;
  142 + }
  143 + return true;
  144 + }
  145 + // 3.2 再次归档:记忆被人工修改或删除时放弃
  146 + if (!memoryService.updateMemoryIfUnchanged(memory, content, newLastMessageId)) {
  147 + log.info("[archiveOne][Agent({}) 用户({}) 记忆已变更,放弃本次结果]", agent.getId(), userId);
  148 + return false;
  149 + }
  150 + return true;
  151 + }
  152 +
  153 + /**
  154 + * 收尾水位线之后超时的孤儿生成,直到遇到仍在执行或未超时的生成
  155 + *
  156 + * 在快照锁外执行:收尾需抢消息执行锁并读写结果流,避免持有快照锁期间阻塞发送消息
  157 + */
  158 + private void failStaleMessages(Long agentId, Long userId, Long lastMessageId) {
  159 + List<Ai1SessionDO> sessions = sessionService.getSessionListByAgentIdAndUserId(agentId, userId);
  160 + if (CollUtil.isEmpty(sessions)) {
  161 + return;
  162 + }
  163 + List<Long> sessionIds = convertList(sessions, Ai1SessionDO::getId);
  164 + for (int i = 0; i < BATCH_SIZE; i++) {
  165 + Ai1SessionMessageDO generatingMessage = sessionMessageService.getFirstSessionMessageBySessionIdsAndStatusAndIdGt(
  166 + sessionIds, Ai1SessionMessageStatusEnum.GENERATING.getStatus(), lastMessageId);
  167 + if (generatingMessage == null || !sessionStreamTool.failStaleMessage(generatingMessage)) {
  168 + return;
  169 + }
  170 + }
  171 + }
  172 +
  173 + /**
  174 + * 获得水位线之后的新消息:截止到第一条生成中的消息,按编号升序,最多 {@link #BATCH_SIZE} 条
  175 + *
  176 + * 持有与消息创建相同的快照锁读取:创建在锁内提交,读取时不存在编号更小、尚未提交的消息
  177 + */
  178 + private List<Ai1SessionMessageDO> getNewMessageList(Long agentId, Long userId, Long lastMessageId) {
  179 + return memoryLockRedisDAO.lockSnapshot(agentId, userId, () -> {
  180 + List<Ai1SessionDO> sessions = sessionService.getSessionListByAgentIdAndUserId(agentId, userId);
  181 + if (CollUtil.isEmpty(sessions)) {
  182 + return Collections.emptyList();
  183 + }
  184 + List<Long> sessionIds = convertList(sessions, Ai1SessionDO::getId);
  185 + Ai1SessionMessageDO generatingMessage = sessionMessageService.getFirstSessionMessageBySessionIdsAndStatusAndIdGt(
  186 + sessionIds, Ai1SessionMessageStatusEnum.GENERATING.getStatus(), lastMessageId);
  187 + return sessionMessageService.getSessionMessageListBySessionIdsAndStatusAndIdBetween(sessionIds,
  188 + Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), lastMessageId,
  189 + generatingMessage != null ? generatingMessage.getId() : null, BATCH_SIZE);
  190 + });
  191 + }
  192 +
  193 +}
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/memory/Ai1MemoryTool.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.harness.memory;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.util.StrUtil;
  5 +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO;
  6 +import cn.iocoder.yudao.module.ai1.dal.dataobject.memory.Ai1MemoryDO;
  7 +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO;
  8 +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum;
  9 +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum;
  10 +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties;
  11 +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool;
  12 +import cn.iocoder.yudao.module.ai1.service.memory.Ai1MemoryService;
  13 +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService;
  14 +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO;
  15 +import cn.iocoder.yudao.module.ai1.util.Ai1Utils;
  16 +import jakarta.annotation.Resource;
  17 +import org.springframework.stereotype.Component;
  18 +
  19 +import java.util.Collections;
  20 +import java.util.List;
  21 +
  22 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  23 +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.MODEL_TYPE_NOT_CHAT;
  24 +
  25 +/**
  26 + * AI1 长期记忆工具(harness 运行时)
  27 + *
  28 + * 1. {@link #loadMemory}:读取用户在 Agent 下的记忆
  29 + * 2. {@link #mergePrompt}:把记忆追加到系统指令,供对话注入
  30 + * 3. {@link #generate}:以「历史记忆 + 最新对话」为输入,经模型按固定维度生成最新记忆
  31 + *
  32 + * 记忆的持久化由归档 {@link Ai1MemoryArchiveTool} 完成
  33 + *
  34 + * @author 芋道源码
  35 + */
  36 +@Component
  37 +public class Ai1MemoryTool {
  38 +
  39 + /**
  40 + * 记忆维度小节标题
  41 + */
  42 + private static final List<String> MEMORY_SECTIONS = List.of("用户画像", "偏好习惯", "关键事实", "目标与意图", "近期进展");
  43 +
  44 + /**
  45 + * 记忆维度与生成约束(系统指令,{maxLen} 为长度上限占位符)
  46 + */
  47 + private static final String MEMORY_SYSTEM_PROMPT = """
  48 + 你是长期记忆归档助手。请基于【历史记忆】与【最新对话】,按下列维度抽取并更新用户长期记忆。
  49 +
  50 + 输出格式(务必严格遵守):
  51 + 1. 只输出 Markdown 记忆正文,不要任何解释、前言或代码块围栏;
  52 + 2. 每个维度标题独占一行,形如“## 维度名”(井号后有且仅有一个空格);
  53 + 3. 每个要点独占一行,以“- ”开头;
  54 + 4. 标题与要点之间、要点与要点之间都必须换行,禁止把标题与内容写在同一行;
  55 + 5. 不同维度之间空一行。
  56 +
  57 + 维度(无内容的维度整节省略):
  58 + ## 用户画像
  59 + - 用户的身份、称呼、职业、所在地等基本画像
  60 + ## 偏好习惯
  61 + - 用户的沟通偏好、回答风格、常用语言与工具等
  62 + ## 关键事实
  63 + - 用户明确告知、需要长期记住的事实性信息
  64 + ## 目标与意图
  65 + - 用户的核心诉求、长期目标与待办事项
  66 + ## 近期进展
  67 + - 最近对话中已推进的事项、结论与下一步
  68 +
  69 + 要求:合并历史记忆与新对话信息,删除重复与已过时内容,保持精炼;总长度不超过 {maxLen} 字。""";
  70 +
  71 + private static final String PLACEHOLDER_MAX_LEN = "{maxLen}";
  72 +
  73 + /**
  74 + * 合并进系统指令时,记忆正文前的引导语
  75 + */
  76 + private static final String MEMORY_PROMPT_PREFIX = "\n\n以下是该用户的历史长期记忆(Markdown),请在回答时结合这些信息;"
  77 + + "若与用户当前表述冲突,以当前表述为准:\n";
  78 +
  79 + @Resource
  80 + private Ai1MemoryService memoryService;
  81 + @Resource
  82 + private Ai1ModelService modelService;
  83 +
  84 + @Resource
  85 + private Ai1LlmChatTool llmChatTool;
  86 +
  87 + /**
  88 + * 读取用户在 Agent 下的长期记忆
  89 + *
  90 + * @param agentId Agent 编号
  91 + * @param userId 用户编号
  92 + * @return 记忆正文;不存在时返回 null
  93 + */
  94 + public String loadMemory(Long agentId, Long userId) {
  95 + if (agentId == null || userId == null) {
  96 + return null;
  97 + }
  98 + Ai1MemoryDO memory = memoryService.getMemoryByAgentIdAndUserId(agentId, userId);
  99 + return memory != null ? memory.getContent() : null;
  100 + }
  101 +
  102 + /**
  103 + * 把长期记忆合并进系统指令;记忆为空时原样返回
  104 + *
  105 + * @param basePrompt 系统指令
  106 + * @param memory 记忆正文
  107 + * @return 合并后的系统指令
  108 + */
  109 + public String mergePrompt(String basePrompt, String memory) {
  110 + String prompt = StrUtil.nullToEmpty(basePrompt);
  111 + if (StrUtil.isBlank(memory)) {
  112 + return prompt;
  113 + }
  114 + return prompt + MEMORY_PROMPT_PREFIX + memory.trim();
  115 + }
  116 +
  117 + /**
  118 + * 经模型生成最新记忆:不装配历史、工具、RAG,也不注入已有记忆
  119 + *
  120 + * @param agent 记忆所属 Agent,使用其对话模型
  121 + * @param oldMemory 历史记忆,可为空
  122 + * @param newMessages 自上次归档以来的新消息,按编号升序
  123 + * @param maxLength 记忆长度上限(字符)
  124 + * @return 最新记忆;模型无输出时返回空
  125 + */
  126 + public String generate(Ai1AgentDO agent, String oldMemory, List<Ai1SessionMessageDO> newMessages, int maxLength) {
  127 + // 1. 校验对话模型
  128 + Ai1ModelRespBO model = modelService.getModelRespBO(agent.getProviderId(), agent.getModelId());
  129 + if (!Ai1ModelTypeEnum.isChat(model.getModelType())) {
  130 + throw exception(MODEL_TYPE_NOT_CHAT);
  131 + }
  132 +
  133 + // 2. 单轮会话,聚合为完整文本
  134 + int limit = maxLength > 0 ? maxLength : YudaoAi1Properties.Memory.DEFAULT_MAX_LENGTH;
  135 + String systemPrompt = MEMORY_SYSTEM_PROMPT.replace(PLACEHOLDER_MAX_LEN, String.valueOf(limit));
  136 + Ai1LlmChatTool.ChatText chatText = llmChatTool.chat(model, systemPrompt, Collections.emptyList(),
  137 + buildUserPrompt(oldMemory, newMessages), Collections.emptyList(), Collections.emptyList(),
  138 + null, null, null);
  139 +
  140 + // 3. 规范化格式并截断
  141 + return Ai1Utils.limitLength(Ai1Utils.normalizeMemoryMarkdown(chatText != null ? chatText.getContent() : null,
  142 + MEMORY_SECTIONS), limit);
  143 + }
  144 +
  145 + /**
  146 + * 组装归档的用户提示:历史记忆 + 最新对话
  147 + */
  148 + private static String buildUserPrompt(String oldMemory, List<Ai1SessionMessageDO> newMessages) {
  149 + StringBuilder prompt = new StringBuilder();
  150 + prompt.append("【历史记忆】\n");
  151 + prompt.append(StrUtil.isBlank(oldMemory) ? "(暂无)" : oldMemory.trim()).append("\n\n");
  152 + prompt.append("【最新对话】\n");
  153 + if (CollUtil.isNotEmpty(newMessages)) {
  154 + for (Ai1SessionMessageDO message : newMessages) {
  155 + String role = Ai1SessionMessageRoleEnum.isAssistant(message.getRole()) ? "助手" : "用户";
  156 + prompt.append(role).append(":").append(StrUtil.nullToEmpty(message.getContent())).append("\n");
  157 + }
  158 + }
  159 + return prompt.toString();
  160 + }
  161 +
  162 +}
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamTool.java
@@ -6,6 +6,7 @@ import cn.hutool.core.map.MapUtil; @@ -6,6 +6,7 @@ import cn.hutool.core.map.MapUtil;
6 import cn.hutool.core.thread.ExecutorBuilder; 6 import cn.hutool.core.thread.ExecutorBuilder;
7 import cn.hutool.core.thread.ThreadUtil; 7 import cn.hutool.core.thread.ThreadUtil;
8 import cn.hutool.core.util.NumberUtil; 8 import cn.hutool.core.util.NumberUtil;
  9 +import cn.hutool.core.util.ObjUtil;
9 import cn.hutool.core.util.StrUtil; 10 import cn.hutool.core.util.StrUtil;
10 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; 11 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
11 import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil; 12 import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil;
@@ -13,6 +14,7 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils; @@ -13,6 +14,7 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
13 import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; 14 import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
14 import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; 15 import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
15 import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; 16 import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO;
  17 +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO;
16 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; 18 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO;
17 import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; 19 import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO;
18 import cn.iocoder.yudao.module.ai1.dal.redis.Ai1RedisKeyConstants; 20 import cn.iocoder.yudao.module.ai1.dal.redis.Ai1RedisKeyConstants;
@@ -22,6 +24,8 @@ import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; @@ -22,6 +24,8 @@ import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum;
22 import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; 24 import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum;
23 import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; 25 import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum;
24 import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; 26 import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties;
  27 +import cn.iocoder.yudao.module.ai1.harness.memory.Ai1MemoryTool;
  28 +import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillShellTracker;
25 import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; 29 import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory;
26 import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; 30 import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService;
27 import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; 31 import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService;
@@ -33,12 +37,15 @@ import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool; @@ -33,12 +37,15 @@ import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool;
33 import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory; 37 import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory;
34 import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; 38 import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool;
35 import jakarta.annotation.Resource; 39 import jakarta.annotation.Resource;
  40 +import lombok.Getter;
36 import lombok.extern.slf4j.Slf4j; 41 import lombok.extern.slf4j.Slf4j;
37 import org.springframework.ai.chat.client.advisor.api.Advisor; 42 import org.springframework.ai.chat.client.advisor.api.Advisor;
38 import org.springframework.context.SmartLifecycle; 43 import org.springframework.context.SmartLifecycle;
39 import org.springframework.context.annotation.Lazy; 44 import org.springframework.context.annotation.Lazy;
  45 +import org.redisson.api.RBucket;
40 import org.redisson.api.RStream; 46 import org.redisson.api.RStream;
41 import org.redisson.api.RLock; 47 import org.redisson.api.RLock;
  48 +import org.redisson.api.RTopic;
42 import org.redisson.api.RedissonClient; 49 import org.redisson.api.RedissonClient;
43 import org.redisson.api.stream.*; 50 import org.redisson.api.stream.*;
44 import org.redisson.client.codec.StringCodec; 51 import org.redisson.client.codec.StringCodec;
@@ -46,10 +53,14 @@ import org.springframework.stereotype.Component; @@ -46,10 +53,14 @@ import org.springframework.stereotype.Component;
46 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; 53 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
47 54
48 import java.time.Duration; 55 import java.time.Duration;
  56 +import java.time.LocalDateTime;
49 import java.util.*; 57 import java.util.*;
  58 +import java.util.concurrent.CancellationException;
50 import java.util.concurrent.ConcurrentHashMap; 59 import java.util.concurrent.ConcurrentHashMap;
51 import java.util.concurrent.ExecutorService; 60 import java.util.concurrent.ExecutorService;
  61 +import java.util.concurrent.Executors;
52 import java.util.concurrent.RejectedExecutionException; 62 import java.util.concurrent.RejectedExecutionException;
  63 +import java.util.concurrent.ScheduledExecutorService;
53 import java.util.concurrent.TimeUnit; 64 import java.util.concurrent.TimeUnit;
54 import java.util.concurrent.atomic.AtomicBoolean; 65 import java.util.concurrent.atomic.AtomicBoolean;
55 import java.util.function.Consumer; 66 import java.util.function.Consumer;
@@ -62,6 +73,7 @@ import java.util.function.Consumer; @@ -62,6 +73,7 @@ import java.util.function.Consumer;
62 * 2. 生成(worker 侧,{@link #start} 拉起的消费线程):{@link #consumeLoop} → {@link #handleTask} → {@link #generate}; 73 * 2. 生成(worker 侧,{@link #start} 拉起的消费线程):{@link #consumeLoop} → {@link #handleTask} → {@link #generate};
63 * 增量写入结果流,结束后回填助手消息并 XACK 74 * 增量写入结果流,结束后回填助手消息并 XACK
64 * 3. 下发与续传(连接侧):{@link #streamResult} 从结果流 XREAD 转发为 SSE;携带 lastEventId 时从断点之后重放,不重新生成 75 * 3. 下发与续传(连接侧):{@link #streamResult} 从结果流 XREAD 转发为 SSE;携带 lastEventId 时从断点之后重放,不重新生成
  76 + * 4. 停止(请求侧):{@link #cancel} 按助手消息写入停止意图并广播,执行节点中断生成;排队中的任务直接收尾
65 * 77 *
66 * 【关键约定】 78 * 【关键约定】
67 * 1. 助手消息编号同时作为结果流标识;生成与连接解耦,任意节点都可以转发、续传,worker 可独立扩容 79 * 1. 助手消息编号同时作为结果流标识;生成与连接解耦,任意节点都可以转发、续传,worker 可独立扩容
@@ -86,6 +98,14 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -86,6 +98,14 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
86 * 生成 worker 线程名前缀,完整线程名为「前缀 + 消费者名」 98 * 生成 worker 线程名前缀,完整线程名为「前缀 + 消费者名」
87 */ 99 */
88 private static final String WORKER_THREAD_NAME_PREFIX = "ai1-session-"; 100 private static final String WORKER_THREAD_NAME_PREFIX = "ai1-session-";
  101 + /**
  102 + * 停止意图检查线程名
  103 + */
  104 + private static final String STOP_THREAD_NAME = "ai1-session-stop-";
  105 + /**
  106 + * Shell 进程回收线程名前缀,完整线程名为「前缀 + worker 线程名」
  107 + */
  108 + private static final String REAPER_THREAD_NAME_PREFIX = "ai1-session-reap-";
89 109
90 /** 110 /**
91 * 任务队列最大长度(近似裁剪) 111 * 任务队列最大长度(近似裁剪)
@@ -120,6 +140,10 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -120,6 +140,10 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
120 */ 140 */
121 private static final long WORKER_JOIN_TIMEOUT_MILLIS = 3000; 141 private static final long WORKER_JOIN_TIMEOUT_MILLIS = 3000;
122 /** 142 /**
  143 + * worker 停止时,等待全部 Shell 进程回收线程退出的最长时长,单位:毫秒;单个进程回收最多等待 5s
  144 + */
  145 + private static final long REAPER_JOIN_TIMEOUT_MILLIS = 6000;
  146 + /**
123 * 结果流连续读取失败的最大次数,达到后中断转发 147 * 结果流连续读取失败的最大次数,达到后中断转发
124 */ 148 */
125 private static final int READ_ERROR_MAX_COUNT = 3; 149 private static final int READ_ERROR_MAX_COUNT = 3;
@@ -129,6 +153,11 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -129,6 +153,11 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
129 private static final long READ_ERROR_BACKOFF_MILLIS = 500; 153 private static final long READ_ERROR_BACKOFF_MILLIS = 500;
130 154
131 /** 155 /**
  156 + * 停止意图的检查间隔,单位:毫秒;兜底停止通知在订阅断开期间丢失
  157 + */
  158 + private static final long STOP_CHECK_INTERVAL_MILLIS = 1000;
  159 +
  160 + /**
132 * 结果流丢失判定:转发到连接超时前该时长,结果流仍不存在时,判定任务丢失(预留两个阻塞读窗口,保证 error 能在连接超时前送达) 161 * 结果流丢失判定:转发到连接超时前该时长,结果流仍不存在时,判定任务丢失(预留两个阻塞读窗口,保证 error 能在连接超时前送达)
133 */ 162 */
134 private static final long LOST_CHECK_ADVANCE_MILLIS = READ_BLOCK_MILLIS * 2; 163 private static final long LOST_CHECK_ADVANCE_MILLIS = READ_BLOCK_MILLIS * 2;
@@ -143,6 +172,21 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -143,6 +172,21 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
143 */ 172 */
144 static final String INTERRUPTED_MESSAGE = "生成任务已中断,请重新发送"; 173 static final String INTERRUPTED_MESSAGE = "生成任务已中断,请重新发送";
145 174
  175 + /**
  176 + * 人工终止、生成失败时追加到正文的提示;失败不拼接原始异常
  177 + */
  178 + static final String TERMINATED_NOTICE = "本条回复已被人工终止";
  179 + static final String FAILED_NOTICE = "本条回复生成失败";
  180 + /**
  181 + * 正文与提示之间的分隔
  182 + */
  183 + private static final String NOTICE_PREFIX = "\n\n> ";
  184 +
  185 + /**
  186 + * SSE 转发异常的提示;只是连接异常,后台生成不受影响,可刷新后续传
  187 + */
  188 + static final String TRANSPORT_ERROR_MESSAGE = "会话连接异常,请刷新后重试";
  189 +
146 private static final String FIELD_MESSAGE_ID = "messageId"; 190 private static final String FIELD_MESSAGE_ID = "messageId";
147 private static final String FIELD_AGENT_ID = "agentId"; 191 private static final String FIELD_AGENT_ID = "agentId";
148 private static final String FIELD_SESSION_ID = "sessionId"; 192 private static final String FIELD_SESSION_ID = "sessionId";
@@ -173,6 +217,8 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -173,6 +217,8 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
173 private Ai1LlmChatTool llmChatTool; 217 private Ai1LlmChatTool llmChatTool;
174 @Resource 218 @Resource
175 private Ai1RagTool ragTool; 219 private Ai1RagTool ragTool;
  220 + @Resource
  221 + private Ai1MemoryTool memoryTool;
176 222
177 @Resource 223 @Resource
178 private Ai1McpToolFactory mcpToolFactory; 224 private Ai1McpToolFactory mcpToolFactory;
@@ -184,6 +230,14 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -184,6 +230,14 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
184 */ 230 */
185 private final Map<Long, Long> resultExpireTimes = new ConcurrentHashMap<>(); 231 private final Map<Long, Long> resultExpireTimes = new ConcurrentHashMap<>();
186 /** 232 /**
  233 + * 本节点执行中的生成:助手消息编号 → 运行上下文,供停止时中断生成线程、回收 Shell 进程
  234 + */
  235 + private final Map<Long, RunContext> runContexts = new ConcurrentHashMap<>();
  236 + /**
  237 + * 未结束的 Shell 进程回收线程:生成结束后可能仍在回收,停止时统一等待;线程结束时自行移除
  238 + */
  239 + private final Set<Thread> reapers = ConcurrentHashMap.newKeySet();
  240 + /**
187 * 实例标识,消费者名按「实例 + 序号」隔离 241 * 实例标识,消费者名按「实例 + 序号」隔离
188 */ 242 */
189 private final String instanceId = UUID.randomUUID().toString().substring(0, 8); 243 private final String instanceId = UUID.randomUUID().toString().substring(0, 8);
@@ -199,6 +253,14 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -199,6 +253,14 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
199 * SSE 转发线程池:单个转发阻塞于 XREAD 直到流结束,用有界池控制并发 253 * SSE 转发线程池:单个转发阻塞于 XREAD 直到流结束,用有界池控制并发
200 */ 254 */
201 private ExecutorService sseExecutor; 255 private ExecutorService sseExecutor;
  256 + /**
  257 + * 停止通知的订阅编号
  258 + */
  259 + private Integer stopListenerId;
  260 + /**
  261 + * 停止意图的定时检查线程
  262 + */
  263 + private ScheduledExecutorService stopChecker;
202 264
203 // ==================== 生命周期 ==================== 265 // ==================== 生命周期 ====================
204 266
@@ -224,7 +286,15 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -224,7 +286,15 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
224 // 2. 初始化任务消费组 286 // 2. 初始化任务消费组
225 initTaskGroup(); 287 initTaskGroup();
226 288
227 - // 3. 拉起 worker 消费线程,消费者名按「实例 + 序号」隔离 289 + // 3. 订阅停止通知,即时中断本节点的生成;通知不持久,另定时检查停止意图兜底
  290 + // 中断交给检查线程执行,避免 Redisson 订阅线程等待增量回调
  291 + stopChecker = Executors.newSingleThreadScheduledExecutor(ThreadUtil.newNamedThreadFactory(STOP_THREAD_NAME, true));
  292 + stopChecker.scheduleWithFixedDelay(this::checkStopRequests,
  293 + STOP_CHECK_INTERVAL_MILLIS, STOP_CHECK_INTERVAL_MILLIS, TimeUnit.MILLISECONDS);
  294 + stopListenerId = getStopTopic().addListener(String.class,
  295 + (channel, messageId) -> stopChecker.execute(() -> cancelRunContext(Convert.toLong(messageId))));
  296 +
  297 + // 4. 拉起 worker 消费线程,消费者名按「实例 + 序号」隔离
228 running = true; 298 running = true;
229 int workerCount = ai1Properties.getSession().getWorker().getCount(); 299 int workerCount = ai1Properties.getSession().getWorker().getCount();
230 for (int i = 0; i < workerCount; i++) { 300 for (int i = 0; i < workerCount; i++) {
@@ -237,11 +307,33 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -237,11 +307,33 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
237 } 307 }
238 308
239 /** 309 /**
240 - * 停止:置停止标志,等待各消费线程退出(每个最多 3s),最后关闭 SSE 转发线程池 310 + * 停止:置停止标志,取消停止订阅,中断本节点执行中的生成,等待各消费线程退出(每个最多 3s)、
  311 + * Shell 进程回收(共最多 6s),最后关闭 SSE 转发线程池
  312 + *
  313 + * 中断的生成保留部分内容并标记失败,不会被其他节点认领后重新生成
241 */ 314 */
242 @Override 315 @Override
243 public void stop() { 316 public void stop() {
  317 + // 1. 先置停止标志,再在 runContexts 锁内取快照:与 registerRunContext 互斥,
  318 + // 之后登记的上下文由登记方自行中断,不会漏掉阻塞读取刚返回、尚未登记的任务
244 running = false; 319 running = false;
  320 + if (stopListenerId != null) {
  321 + try {
  322 + getStopTopic().removeListener(stopListenerId);
  323 + } catch (Exception e) {
  324 + log.warn("[stop][停止通知取消订阅失败]", e);
  325 + }
  326 + }
  327 + if (stopChecker != null) {
  328 + stopChecker.shutdownNow();
  329 + }
  330 + List<RunContext> contexts;
  331 + synchronized (runContexts) {
  332 + contexts = new ArrayList<>(runContexts.values());
  333 + }
  334 + // 中断只启动各自的回收线程,单个进程回收慢不影响其他上下文
  335 + contexts.forEach(context -> context.cancel(INTERRUPTED_MESSAGE));
  336 + // 2. 等待消费线程退出
245 for (Thread worker : workers) { 337 for (Thread worker : workers) {
246 try { 338 try {
247 worker.join(WORKER_JOIN_TIMEOUT_MILLIS); 339 worker.join(WORKER_JOIN_TIMEOUT_MILLIS);
@@ -250,6 +342,19 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -250,6 +342,19 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
250 } 342 }
251 } 343 }
252 workers.clear(); 344 workers.clear();
  345 + // 3. 等待全部未结束的 Shell 进程回收,包括此前人工停止、生成已结束的回收;各回收线程并行,共用一个截止时刻
  346 + long reaperDeadline = System.currentTimeMillis() + REAPER_JOIN_TIMEOUT_MILLIS;
  347 + try {
  348 + for (Thread reaper : reapers) {
  349 + long remaining = reaperDeadline - System.currentTimeMillis();
  350 + if (remaining <= 0) {
  351 + break;
  352 + }
  353 + reaper.join(remaining);
  354 + }
  355 + } catch (InterruptedException e) {
  356 + Thread.currentThread().interrupt();
  357 + }
253 if (sseExecutor != null) { 358 if (sseExecutor != null) {
254 sseExecutor.shutdown(); 359 sseExecutor.shutdown();
255 } 360 }
@@ -268,15 +373,15 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -268,15 +373,15 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
268 try { 373 try {
269 getTaskStream().createGroup(StreamCreateGroupArgs.name(Ai1RedisKeyConstants.SESSION_TASK_GROUP).id(StreamMessageId.ALL).makeStream()); 374 getTaskStream().createGroup(StreamCreateGroupArgs.name(Ai1RedisKeyConstants.SESSION_TASK_GROUP).id(StreamMessageId.ALL).makeStream());
270 } catch (Exception e) { 375 } catch (Exception e) {
271 - if (!isBusyGroup(e)) { 376 + if (!isRedisError(e, "BUSYGROUP")) {
272 log.warn("[initTaskGroup][Chat 任务消费组初始化失败]", e); 377 log.warn("[initTaskGroup][Chat 任务消费组初始化失败]", e);
273 } 378 }
274 } 379 }
275 } 380 }
276 381
277 - private static boolean isBusyGroup(Throwable e) { 382 + private static boolean isRedisError(Throwable e, String code) {
278 for (Throwable cause = e; cause != null; cause = cause.getCause()) { 383 for (Throwable cause = e; cause != null; cause = cause.getCause()) {
279 - if (StrUtil.contains(cause.getMessage(), "BUSYGROUP")) { 384 + if (StrUtil.contains(cause.getMessage(), code)) {
280 return true; 385 return true;
281 } 386 }
282 } 387 }
@@ -331,8 +436,9 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -331,8 +436,9 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
331 emitter.complete(); 436 emitter.complete();
332 } 437 }
333 } catch (Exception e) { 438 } catch (Exception e) {
  439 + // 只是转发异常,不改动消息状态,也不按生成失败提示
334 log.warn("[open][助手消息({}) 会话流转发异常]", messageId, e); 440 log.warn("[open][助手消息({}) 会话流转发异常]", messageId, e);
335 - sendErrorAndComplete(emitter, e.getMessage()); 441 + sendErrorAndComplete(emitter, TRANSPORT_ERROR_MESSAGE);
336 } 442 }
337 }); 443 });
338 } catch (RejectedExecutionException e) { 444 } catch (RejectedExecutionException e) {
@@ -354,6 +460,151 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -354,6 +460,151 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
354 return emitter; 460 return emitter;
355 } 461 }
356 462
  463 + // ==================== 停止 ====================
  464 +
  465 + /**
  466 + * 停止会话中生成中的助手消息
  467 + *
  468 + * 按助手消息写入停止意图并广播,覆盖其他节点执行中、排队中的任务
  469 + *
  470 + * @param sessionId 会话编号
  471 + */
  472 + public void cancel(Long sessionId) {
  473 + // 1. 按请求时刻定位生成中的助手消息;之后发送的新一轮不受影响
  474 + List<Long> messageIds = sessionMessageService.getGeneratingAssistantMessageIdList(sessionId);
  475 + Duration ttl = Duration.ofSeconds(ai1Properties.getSession().getStream().getTtlSeconds());
  476 + for (Long messageId : messageIds) {
  477 + // 2.1 写入停止意图:worker 执行前、执行中都会检查,与结果流的过期时间一致
  478 + getStopBucket(messageId).set("1", ttl);
  479 + // 2.2 广播停止通知,执行节点即时中断
  480 + getStopTopic().publish(String.valueOf(messageId));
  481 + // 2.3 没有 worker 执行时(排队中,或 worker 已退出),直接收尾
  482 + cancelQueuedTask(messageId);
  483 + }
  484 + }
  485 +
  486 + /**
  487 + * 收尾无人执行的任务:抢到执行锁说明没有 worker 在生成,保留已有结果并标记终止
  488 + *
  489 + * 任务记录仍在队列中,之后由 worker 按「已回填」补齐终态并确认,不再调用模型
  490 + */
  491 + private void cancelQueuedTask(Long messageId) {
  492 + RLock lock = redissonClient.getLock(String.format(Ai1RedisKeyConstants.SESSION_TASK_LOCK, messageId));
  493 + if (!lock.tryLock()) {
  494 + return;
  495 + }
  496 + try {
  497 + Ai1SessionMessageDO message = sessionMessageService.getSessionMessage(messageId);
  498 + if (message == null || !Ai1SessionMessageStatusEnum.isGenerating(message.getStatus())) {
  499 + return;
  500 + }
  501 + saveAssistantMessage(messageId, message.getSessionId(), withNotice(readPartialResult(messageId), TERMINATED_NOTICE),
  502 + Ai1SessionMessageStatusEnum.FAILED.getStatus());
  503 + appendResult(messageId, Ai1SessionStreamEventEnum.DONE.getEvent(), "");
  504 + } finally {
  505 + if (lock.isHeldByCurrentThread()) {
  506 + lock.unlock();
  507 + }
  508 + }
  509 + }
  510 +
  511 + /**
  512 + * 收尾超时的孤儿生成:任务记录被裁剪或丢失时,助手消息会一直停在生成中,阻塞长期记忆归档
  513 + *
  514 + * 超时阈值与超时任务认领一致,仍有任务记录的消息此前已被认领收尾;
  515 + * 先判定超时再抢执行锁,避免短暂持锁使 worker 把排队中的新任务误判为在途而跳过
  516 + *
  517 + * @param message 生成中的助手消息
  518 + * @return 是否已不在生成中;仍在执行或未超时时返回 false
  519 + * @throws IllegalStateException 收尾期间执行锁失效,由归档按单个用户失败处理
  520 + */
  521 + public boolean failStaleMessage(Ai1SessionMessageDO message) {
  522 + // 1. 未超时直接保留,不加锁
  523 + if (!isStaleMessage(message)) {
  524 + return false;
  525 + }
  526 + // 2. 抢不到执行锁说明仍有 worker 在生成
  527 + Long messageId = message.getId();
  528 + RLock lock = redissonClient.getLock(String.format(Ai1RedisKeyConstants.SESSION_TASK_LOCK, messageId));
  529 + if (!lock.tryLock()) {
  530 + return false;
  531 + }
  532 + long ownerThreadId = Thread.currentThread().threadId();
  533 + try {
  534 + // 3. 锁内重新读取,期间可能已被 worker 完成
  535 + Ai1SessionMessageDO current = sessionMessageService.getSessionMessage(messageId);
  536 + if (current == null || !Ai1SessionMessageStatusEnum.isGenerating(current.getStatus())) {
  537 + return true;
  538 + }
  539 + if (!isStaleMessage(current)) {
  540 + return false;
  541 + }
  542 + // 4. 与接管任务的收尾一致:保留部分结果并标记失败,再写入 error、done;
  543 + // 每次写入前校验执行权,锁失效后由新持有者处理,不再改动消息或结果流
  544 + log.warn("[failStaleMessage][助手消息({}) 生成超时且无 worker 执行,标记中断]", messageId);
  545 + Ai1LlmChatTool.ChatText partialText = readPartialResult(messageId);
  546 + checkTaskLock(lock, ownerThreadId);
  547 + saveAssistantMessage(messageId, current.getSessionId(), withNotice(partialText, INTERRUPTED_MESSAGE),
  548 + Ai1SessionMessageStatusEnum.FAILED.getStatus());
  549 + checkTaskLock(lock, ownerThreadId);
  550 + appendResult(messageId, Ai1SessionStreamEventEnum.ERROR.getEvent(), INTERRUPTED_MESSAGE);
  551 + checkTaskLock(lock, ownerThreadId);
  552 + appendResult(messageId, Ai1SessionStreamEventEnum.DONE.getEvent(), "");
  553 + return true;
  554 + } finally {
  555 + if (lock.isHeldByThread(ownerThreadId)) {
  556 + lock.unlock();
  557 + }
  558 + }
  559 + }
  560 +
  561 + private boolean isStaleMessage(Ai1SessionMessageDO message) {
  562 + if (!Ai1SessionMessageRoleEnum.isAssistant(message.getRole()) || message.getCreateTime() == null) {
  563 + return false;
  564 + }
  565 + long staleMillis = ai1Properties.getSession().getStream().getTimeoutMs() + RECLAIM_IDLE_BUFFER_MILLIS;
  566 + return message.getCreateTime().isBefore(LocalDateTime.now().minus(Duration.ofMillis(staleMillis)));
  567 + }
  568 +
  569 + /**
  570 + * 登记运行上下文;节点已停机时立即中断
  571 + *
  572 + * 与 {@link #stop} 的快照在同一把锁内:要么被快照中断,要么在此看到停止标志
  573 + */
  574 + private void registerRunContext(Long messageId, RunContext context) {
  575 + synchronized (runContexts) {
  576 + runContexts.put(messageId, context);
  577 + if (!running) {
  578 + context.cancel(INTERRUPTED_MESSAGE);
  579 + }
  580 + }
  581 + }
  582 +
  583 + /**
  584 + * 中断本节点执行中的生成;不在本节点执行时忽略
  585 + */
  586 + private void cancelRunContext(Long messageId) {
  587 + RunContext context = messageId != null ? runContexts.get(messageId) : null;
  588 + if (context != null) {
  589 + context.cancel(TERMINATED_NOTICE);
  590 + }
  591 + }
  592 +
  593 + /**
  594 + * 定时检查本节点执行中的生成是否有停止意图,兜底订阅断开期间丢失的停止通知
  595 + */
  596 + private void checkStopRequests() {
  597 + runContexts.keySet().forEach(messageId -> {
  598 + try {
  599 + if (getStopBucket(messageId).isExists()) {
  600 + cancelRunContext(messageId);
  601 + }
  602 + } catch (Exception e) {
  603 + log.warn("[checkStopRequests][助手消息({}) 停止意图检查失败]", messageId, e);
  604 + }
  605 + });
  606 + }
  607 +
357 // ==================== worker 消费 ==================== 608 // ==================== worker 消费 ====================
358 609
359 /** 610 /**
@@ -382,6 +633,10 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -382,6 +633,10 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
382 return; 633 return;
383 } 634 }
384 log.warn("[consumeLoop][Chat worker({}) 消费异常]", consumer, e); 635 log.warn("[consumeLoop][Chat worker({}) 消费异常]", consumer, e);
  636 + // 任务队列被删除(如 Redis 清库)后消费组随之丢失,重建后继续消费
  637 + if (isRedisError(e, "NOGROUP")) {
  638 + initTaskGroup();
  639 + }
385 ThreadUtil.sleep(CONSUME_ERROR_BACKOFF_MILLIS); 640 ThreadUtil.sleep(CONSUME_ERROR_BACKOFF_MILLIS);
386 } 641 }
387 } 642 }
@@ -477,7 +732,8 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -477,7 +732,8 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
477 Ai1LlmChatTool.ChatText partialText = readPartialResult(messageId); 732 Ai1LlmChatTool.ChatText partialText = readPartialResult(messageId);
478 // 1.3.2 回填部分结果并标记失败;每次写入前校验执行权,防止旧 worker 继续写入 733 // 1.3.2 回填部分结果并标记失败;每次写入前校验执行权,防止旧 worker 继续写入
479 checkTaskLock(lock, workerThreadId); 734 checkTaskLock(lock, workerThreadId);
480 - saveAssistantMessage(messageId, sessionId, partialText, Ai1SessionMessageStatusEnum.FAILED.getStatus()); 735 + saveAssistantMessage(messageId, sessionId, withNotice(partialText, INTERRUPTED_MESSAGE),
  736 + Ai1SessionMessageStatusEnum.FAILED.getStatus());
481 // 1.3.3 写入错误提示和结束事件,续传连接可及时退出 737 // 1.3.3 写入错误提示和结束事件,续传连接可及时退出
482 checkTaskLock(lock, workerThreadId); 738 checkTaskLock(lock, workerThreadId);
483 appendResult(messageId, Ai1SessionStreamEventEnum.ERROR.getEvent(), INTERRUPTED_MESSAGE); 739 appendResult(messageId, Ai1SessionStreamEventEnum.ERROR.getEvent(), INTERRUPTED_MESSAGE);
@@ -493,40 +749,81 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -493,40 +749,81 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
493 Long agentId = Convert.toLong(fields.get(FIELD_AGENT_ID)); 749 Long agentId = Convert.toLong(fields.get(FIELD_AGENT_ID));
494 String content = fields.get(FIELD_CONTENT); 750 String content = fields.get(FIELD_CONTENT);
495 751
496 - // 3. 生成并回填;增量回调可能在模型线程执行,因此按 worker 线程编号校验锁归属 752 + // 3. 登记运行上下文,供停止时中断生成线程、回收 Shell 进程
  753 + RunContext context = new RunContext(Thread.currentThread(), skillToolFactory.newShellTracker(agentId), reapers);
  754 + registerRunContext(messageId, context);
  755 + // 增量回调可能在模型线程执行,因此按 worker 线程编号校验锁归属;停止后不再写入增量
497 StringBuilder contentText = new StringBuilder(); 756 StringBuilder contentText = new StringBuilder();
498 StringBuilder thinkingText = new StringBuilder(); 757 StringBuilder thinkingText = new StringBuilder();
499 Consumer<String> onThinking = delta -> { 758 Consumer<String> onThinking = delta -> {
500 - checkTaskLock(lock, workerThreadId);  
501 - thinkingText.append(delta);  
502 - appendResult(messageId, Ai1SessionStreamEventEnum.THINKING.getEvent(), delta); 759 + synchronized (context) {
  760 + context.checkActive();
  761 + checkTaskLock(lock, workerThreadId);
  762 + thinkingText.append(delta);
  763 + appendResult(messageId, Ai1SessionStreamEventEnum.THINKING.getEvent(), delta);
  764 + }
503 }; 765 };
504 Consumer<String> onContent = delta -> { 766 Consumer<String> onContent = delta -> {
505 - checkTaskLock(lock, workerThreadId);  
506 - contentText.append(delta);  
507 - appendResult(messageId, Ai1SessionStreamEventEnum.MESSAGE.getEvent(), delta); 767 + synchronized (context) {
  768 + context.checkActive();
  769 + checkTaskLock(lock, workerThreadId);
  770 + contentText.append(delta);
  771 + appendResult(messageId, Ai1SessionStreamEventEnum.MESSAGE.getEvent(), delta);
  772 + }
508 }; 773 };
509 - // 3.1 标记生成开始,再调用模型并回填完整结果 774 +
  775 + // 4. 标记生成开始,再调用模型
  776 + Ai1LlmChatTool.ChatText chatText = null;
  777 + Exception failure = null;
  778 + String stopNotice;
510 try { 779 try {
  780 + // 4.1 节点已停机,或排队期间已被停止时,不再调用模型
  781 + context.checkActive();
  782 + if (getStopBucket(messageId).isExists()) {
  783 + context.cancel(TERMINATED_NOTICE);
  784 + }
  785 + context.checkActive();
  786 + // 4.2 标记生成已开始;长思考没有增量时,续传也不会误判结果流丢失
511 checkTaskLock(lock, workerThreadId); 787 checkTaskLock(lock, workerThreadId);
512 - // 标记生成已开始;长思考没有增量时,续传也不会误判结果流丢失  
513 appendResult(messageId, Ai1SessionStreamEventEnum.PING.getEvent(), ""); 788 appendResult(messageId, Ai1SessionStreamEventEnum.PING.getEvent(), "");
514 - Ai1LlmChatTool.ChatText chatText = generate(messageId, agentId, sessionId, content, onThinking, onContent);  
515 - checkTaskLock(lock, workerThreadId);  
516 - saveAssistantMessage(messageId, sessionId, chatText, Ai1SessionMessageStatusEnum.SUCCESS.getStatus()); 789 + chatText = generate(context, messageId, agentId, sessionId, content, onThinking, onContent);
517 } catch (Exception e) { 790 } catch (Exception e) {
518 - // 失去锁的旧 worker 不得再写结果、终态或 XACK,留给新持有者处理 791 + failure = e;
  792 + } finally {
  793 + // 4.3 结束上下文并清除中断标志,之后的停止不再生效
  794 + stopNotice = context.finish();
  795 + runContexts.remove(messageId, context);
  796 + context.getShellTracker().cancel();
  797 + }
  798 +
  799 + // 5. 先回填终态,再下发 error / done;每次写入前校验执行权
  800 + if (stopNotice != null) {
  801 + // 5.1 停止:保留已生成的内容并追加提示;人工终止只写 done,节点停机中断另写 error
  802 + log.info("[handleTask][助手消息({}) 生成已停止:{}]", messageId, stopNotice);
  803 + Ai1LlmChatTool.ChatText partialText = chatText != null ? chatText
  804 + : new Ai1LlmChatTool.ChatText(contentText.toString(), thinkingText.toString());
  805 + checkTaskLock(lock, workerThreadId);
  806 + saveAssistantMessage(messageId, sessionId, withNotice(partialText, stopNotice), Ai1SessionMessageStatusEnum.FAILED.getStatus());
  807 + if (!TERMINATED_NOTICE.equals(stopNotice)) {
  808 + checkTaskLock(lock, workerThreadId);
  809 + appendResult(messageId, Ai1SessionStreamEventEnum.ERROR.getEvent(), stopNotice);
  810 + }
  811 + } else if (failure != null) {
  812 + // 5.2 失败:失去锁的旧 worker 不得再写结果、终态或 XACK,留给新持有者处理
519 if (!lock.isHeldByThread(workerThreadId)) { 813 if (!lock.isHeldByThread(workerThreadId)) {
520 return; 814 return;
521 } 815 }
522 - // 3.2 生成失败时下发错误,保留本轮已产生的增量并回填失败状态  
523 - log.warn("[handleTask][助手消息({}) 生成失败]", messageId, e);  
524 - appendResult(messageId, Ai1SessionStreamEventEnum.ERROR.getEvent(), StrUtil.blankToDefault(e.getMessage(), "生成失败")); 816 + log.warn("[handleTask][助手消息({}) 生成失败]", messageId, failure);
  817 + saveAssistantMessage(messageId, sessionId, withNotice(new Ai1LlmChatTool.ChatText(contentText.toString(),
  818 + thinkingText.toString()), FAILED_NOTICE), Ai1SessionMessageStatusEnum.FAILED.getStatus());
525 checkTaskLock(lock, workerThreadId); 819 checkTaskLock(lock, workerThreadId);
526 - saveAssistantMessage(messageId, sessionId, new Ai1LlmChatTool.ChatText(contentText.toString(),  
527 - thinkingText.toString()), Ai1SessionMessageStatusEnum.FAILED.getStatus()); 820 + appendResult(messageId, Ai1SessionStreamEventEnum.ERROR.getEvent(), FAILED_NOTICE);
  821 + } else {
  822 + // 5.3 成功:回填完整结果
  823 + checkTaskLock(lock, workerThreadId);
  824 + saveAssistantMessage(messageId, sessionId, chatText, Ai1SessionMessageStatusEnum.SUCCESS.getStatus());
528 } 825 }
529 - // 4. 回填后写终态并确认;失败时由后续接管清理,不能在 finally 中无条件确认 826 + // 6. 回填后写终态并确认;失败时由后续接管清理,不能在 finally 中无条件确认
530 checkTaskLock(lock, workerThreadId); 827 checkTaskLock(lock, workerThreadId);
531 appendResult(messageId, Ai1SessionStreamEventEnum.DONE.getEvent(), ""); 828 appendResult(messageId, Ai1SessionStreamEventEnum.DONE.getEvent(), "");
532 checkTaskLock(lock, workerThreadId); 829 checkTaskLock(lock, workerThreadId);
@@ -546,11 +843,15 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -546,11 +843,15 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
546 if (Ai1SessionStreamEventEnum.DONE.getEvent().equals(lastType)) { 843 if (Ai1SessionStreamEventEnum.DONE.getEvent().equals(lastType)) {
547 return; 844 return;
548 } 845 }
549 - // 2. 失败消息缺少 error 时补写错误提示,末尾已有 error 时不重复写入 846 + // 2. 失败消息缺少 error 时,按正文中已回填的提示补写;人工终止只写 done,末尾已有 error 时不重复写入
550 if (Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus()) 847 if (Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus())
551 && !Ai1SessionStreamEventEnum.ERROR.getEvent().equals(lastType)) { 848 && !Ai1SessionStreamEventEnum.ERROR.getEvent().equals(lastType)) {
552 - checkTaskLock(lock, workerThreadId);  
553 - appendResult(message.getId(), Ai1SessionStreamEventEnum.ERROR.getEvent(), INTERRUPTED_MESSAGE); 849 + String notice = getSavedNotice(message.getContent());
  850 + if (!TERMINATED_NOTICE.equals(notice)) {
  851 + checkTaskLock(lock, workerThreadId);
  852 + appendResult(message.getId(), Ai1SessionStreamEventEnum.ERROR.getEvent(),
  853 + ObjUtil.defaultIfNull(notice, INTERRUPTED_MESSAGE));
  854 + }
554 } 855 }
555 // 3. 补写 done,使续传连接能够结束;调用方在补齐后再确认任务 856 // 3. 补写 done,使续传连接能够结束;调用方在补齐后再确认任务
556 checkTaskLock(lock, workerThreadId); 857 checkTaskLock(lock, workerThreadId);
@@ -593,12 +894,12 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -593,12 +894,12 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
593 // ==================== 生成编排 ==================== 894 // ==================== 生成编排 ====================
594 895
595 /** 896 /**
596 - * 执行一次生成:校验 Agent → 解析模型 → 装配历史、工具、RAG → 流式会话 897 + * 执行一次生成:校验 Agent → 解析模型 → 装配历史、长期记忆、工具、RAG → 流式会话
597 * 898 *
598 * 历史只取当前助手占位之前最近 historyLimit 条,跳过未完成的助手占位,并移除末尾的当前提问(由 LLM 工具另行追加) 899 * 历史只取当前助手占位之前最近 historyLimit 条,跳过未完成的助手占位,并移除末尾的当前提问(由 LLM 工具另行追加)
599 */ 900 */
600 @SuppressWarnings("SequencedCollectionMethodCanBeUsed") 901 @SuppressWarnings("SequencedCollectionMethodCanBeUsed")
601 - private Ai1LlmChatTool.ChatText generate(Long messageId, Long agentId, Long sessionId, String content, 902 + private Ai1LlmChatTool.ChatText generate(RunContext context, Long messageId, Long agentId, Long sessionId, String content,
602 Consumer<String> onThinking, Consumer<String> onContent) { 903 Consumer<String> onThinking, Consumer<String> onContent) {
603 // 1. 生成可能发生在其他节点,重新校验 Agent 与模型;后台会话不要求 Agent 已发布 904 // 1. 生成可能发生在其他节点,重新校验 Agent 与模型;后台会话不要求 Agent 已发布
604 Ai1AgentDO agent = agentService.validateAgentExists(agentId); 905 Ai1AgentDO agent = agentService.validateAgentExists(agentId);
@@ -631,10 +932,13 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -631,10 +932,13 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
631 String systemPrompt = StrUtil.isNotBlank(agent.getSystemPrompt()) ? agent.getSystemPrompt().trim() 932 String systemPrompt = StrUtil.isNotBlank(agent.getSystemPrompt()) ? agent.getSystemPrompt().trim()
632 : StrUtil.replace(ai1Properties.getSession().getDefaultSystemPrompt(), 933 : StrUtil.replace(ai1Properties.getSession().getDefaultSystemPrompt(),
633 YudaoAi1Properties.Session.AGENT_NAME_PLACEHOLDER, agent.getName()); 934 YudaoAi1Properties.Session.AGENT_NAME_PLACEHOLDER, agent.getName());
  935 + // 开启长期记忆时,合入用户在该 Agent 下的记忆
  936 + systemPrompt = injectMemory(agent, sessionId, systemPrompt);
634 937
635 // 4. 工具(MCP + SKILL)与 RAG Advisor;知识库不可用时降级跳过,并把提示写入思考流 938 // 4. 工具(MCP + SKILL)与 RAG Advisor;知识库不可用时降级跳过,并把提示写入思考流
  939 + // Shell 命令经运行上下文的跟踪器执行,停止或生成结束时统一回收
636 List<Object> tools = new ArrayList<>(mcpToolFactory.buildTools(agent)); 940 List<Object> tools = new ArrayList<>(mcpToolFactory.buildTools(agent));
637 - tools.addAll(skillToolFactory.buildTools(agent)); 941 + tools.addAll(skillToolFactory.buildTools(agent, context.getShellTracker()));
638 StringBuilder noticeText = new StringBuilder(); 942 StringBuilder noticeText = new StringBuilder();
639 List<Advisor> advisors = new ArrayList<>(); 943 List<Advisor> advisors = new ArrayList<>();
640 for (Ai1KnowledgeBaseDO knowledgeBase : knowledgeBaseService.getKnowledgeBaseList(agent.getKnowledgeBaseIds())) { 944 for (Ai1KnowledgeBaseDO knowledgeBase : knowledgeBaseService.getKnowledgeBaseList(agent.getKnowledgeBaseIds())) {
@@ -652,6 +956,7 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -652,6 +956,7 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
652 } 956 }
653 957
654 // 5. 流式会话;降级提示并入最终思考文本,保证刷新后内容一致 958 // 5. 流式会话;降级提示并入最终思考文本,保证刷新后内容一致
  959 + context.checkActive();
655 Ai1LlmChatTool.ChatText chatText = llmChatTool.chat(model, systemPrompt, histories, content, tools, advisors, 960 Ai1LlmChatTool.ChatText chatText = llmChatTool.chat(model, systemPrompt, histories, content, tools, advisors,
656 sessionId, onThinking, onContent); 961 sessionId, onThinking, onContent);
657 if (!noticeText.isEmpty()) { 962 if (!noticeText.isEmpty()) {
@@ -661,6 +966,27 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -661,6 +966,27 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
661 } 966 }
662 967
663 /** 968 /**
  969 + * 长期记忆注入
  970 + *
  971 + * 查询失败时降级为原系统指令,保证对话可用
  972 + */
  973 + private String injectMemory(Ai1AgentDO agent, Long sessionId, String systemPrompt) {
  974 + if (!Boolean.TRUE.equals(agent.getMemoryEnabled())) {
  975 + return systemPrompt;
  976 + }
  977 + try {
  978 + Ai1SessionDO session = sessionService.getSession(sessionId);
  979 + if (session == null) {
  980 + return systemPrompt;
  981 + }
  982 + return memoryTool.mergePrompt(systemPrompt, memoryTool.loadMemory(agent.getId(), session.getUserId()));
  983 + } catch (Exception e) {
  984 + log.warn("[injectMemory][Agent({}) 会话({}) 长期记忆注入失败,已降级跳过]", agent.getId(), sessionId, e);
  985 + return systemPrompt;
  986 + }
  987 + }
  988 +
  989 + /**
664 * 回填助手消息(占位记录)的内容、思考过程与生成状态,并刷新会话活跃时间 990 * 回填助手消息(占位记录)的内容、思考过程与生成状态,并刷新会话活跃时间
665 */ 991 */
666 private void saveAssistantMessage(Long messageId, Long sessionId, Ai1LlmChatTool.ChatText chatText, Integer status) { 992 private void saveAssistantMessage(Long messageId, Long sessionId, Ai1LlmChatTool.ChatText chatText, Integer status) {
@@ -830,6 +1156,33 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -830,6 +1156,33 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
830 return redissonClient.getStream(String.format(Ai1RedisKeyConstants.SESSION_RESULT_STREAM, messageId), StringCodec.INSTANCE); 1156 return redissonClient.getStream(String.format(Ai1RedisKeyConstants.SESSION_RESULT_STREAM, messageId), StringCodec.INSTANCE);
831 } 1157 }
832 1158
  1159 + private RBucket<String> getStopBucket(Long messageId) {
  1160 + return redissonClient.getBucket(String.format(Ai1RedisKeyConstants.SESSION_STOP, messageId), StringCodec.INSTANCE);
  1161 + }
  1162 +
  1163 + private RTopic getStopTopic() {
  1164 + return redissonClient.getTopic(Ai1RedisKeyConstants.SESSION_STOP_TOPIC, StringCodec.INSTANCE);
  1165 + }
  1166 +
  1167 + /**
  1168 + * 正文末尾追加提示
  1169 + */
  1170 + static Ai1LlmChatTool.ChatText withNotice(Ai1LlmChatTool.ChatText chatText, String notice) {
  1171 + return new Ai1LlmChatTool.ChatText(StrUtil.nullToEmpty(chatText.getContent()) + NOTICE_PREFIX + notice, chatText.getThinking());
  1172 + }
  1173 +
  1174 + /**
  1175 + * 解析正文末尾已回填的提示;没有时返回空
  1176 + */
  1177 + static String getSavedNotice(String content) {
  1178 + for (String notice : List.of(TERMINATED_NOTICE, FAILED_NOTICE, INTERRUPTED_MESSAGE)) {
  1179 + if (StrUtil.endWith(content, NOTICE_PREFIX + notice)) {
  1180 + return notice;
  1181 + }
  1182 + }
  1183 + return null;
  1184 + }
  1185 +
833 /** 1186 /**
834 * 解析结果流条目编号(格式为 毫秒时间戳-序号);为空或非法时从头读取 1187 * 解析结果流条目编号(格式为 毫秒时间戳-序号);为空或非法时从头读取
835 */ 1188 */
@@ -844,4 +1197,94 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -844,4 +1197,94 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
844 return new StreamMessageId(Long.parseLong(parts.get(0)), Long.parseLong(parts.get(1))); 1197 return new StreamMessageId(Long.parseLong(parts.get(0)), Long.parseLong(parts.get(1)));
845 } 1198 }
846 1199
  1200 + /**
  1201 + * 生成的运行上下文
  1202 + *
  1203 + * 停止与结束互斥,结束后迟到的停止不再中断线程
  1204 + */
  1205 + static class RunContext {
  1206 +
  1207 + /**
  1208 + * 生成线程,即 worker 线程
  1209 + */
  1210 + private final Thread thread;
  1211 + /**
  1212 + * Shell 进程跟踪器
  1213 + */
  1214 + @Getter
  1215 + private final Ai1SkillShellTracker shellTracker;
  1216 + /**
  1217 + * 是否已结束
  1218 + */
  1219 + private boolean finished;
  1220 + /**
  1221 + * 停止提示;为空表示未停止
  1222 + */
  1223 + private String stopNotice;
  1224 +
  1225 + /**
  1226 + * 未结束的 Shell 进程回收线程,与运行上下文的生命周期无关,由节点停止时统一等待
  1227 + */
  1228 + private final Set<Thread> reapers;
  1229 +
  1230 + RunContext(Thread thread, Ai1SkillShellTracker shellTracker, Set<Thread> reapers) {
  1231 + this.thread = thread;
  1232 + this.shellTracker = shellTracker;
  1233 + this.reapers = reapers;
  1234 + }
  1235 +
  1236 + /**
  1237 + * 停止生成:中断生成线程,并由独立线程回收在册的后台进程;已结束、已停止时忽略
  1238 + *
  1239 + * 单个进程回收最多等待 5s:不在调用方执行,避免阻塞停止检查线程或其他上下文的停止;
  1240 + * 不依赖 worker 响应中断,模型或工具库吞掉中断时进程也会被回收;
  1241 + * 回收可能晚于生成结束,结束时 worker 拿不到已被回收线程认领的进程,因此回收线程登记到 reapers,不随上下文移除
  1242 + *
  1243 + * @param notice 停止提示
  1244 + */
  1245 + synchronized void cancel(String notice) {
  1246 + if (finished || stopNotice != null) {
  1247 + return;
  1248 + }
  1249 + stopNotice = notice;
  1250 + thread.interrupt();
  1251 + Thread reaper = ThreadUtil.newThread(() -> {
  1252 + try {
  1253 + shellTracker.cancel();
  1254 + } finally {
  1255 + reapers.remove(Thread.currentThread());
  1256 + }
  1257 + }, REAPER_THREAD_NAME_PREFIX + thread.getName(), true);
  1258 + // 先登记再启动,结束时必能移除自身
  1259 + reapers.add(reaper);
  1260 + try {
  1261 + reaper.start();
  1262 + } catch (Throwable e) {
  1263 + reapers.remove(reaper);
  1264 + throw e;
  1265 + }
  1266 + }
  1267 +
  1268 + /**
  1269 + * 校验生成仍在进行,已停止或已结束时中止后续步骤
  1270 + */
  1271 + synchronized void checkActive() {
  1272 + if (finished || stopNotice != null) {
  1273 + throw new CancellationException(StrUtil.blankToDefault(stopNotice, "生成已结束"));
  1274 + }
  1275 + }
  1276 +
  1277 + /**
  1278 + * 结束生成,并清除 worker 线程的中断标志;需在 worker 线程调用
  1279 + *
  1280 + * @return 停止提示;未停止时为空
  1281 + */
  1282 + synchronized String finish() {
  1283 + finished = true;
  1284 + Thread.interrupted();
  1285 + return stopNotice;
  1286 + }
  1287 +
  1288 + }
  1289 +
847 } 1290 }
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillShellTracker.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.harness.skill;
  2 +
  3 +import lombok.extern.slf4j.Slf4j;
  4 +import org.springaicommunity.agent.common.exec.ExecBackend;
  5 +import org.springaicommunity.agent.common.exec.ExecHandle;
  6 +import org.springaicommunity.agent.common.exec.ExecResult;
  7 +import org.springaicommunity.agent.common.exec.ExecSpec;
  8 +
  9 +import java.util.Set;
  10 +import java.util.concurrent.ConcurrentHashMap;
  11 +
  12 +/**
  13 + * AI1 SKILL Shell 进程跟踪器
  14 + *
  15 + * 登记本次生成启动的后台进程,生成结束或终止时回收
  16 + *
  17 + * @author 芋道源码
  18 + */
  19 +@Slf4j
  20 +public class Ai1SkillShellTracker implements ExecBackend {
  21 +
  22 + private final ExecBackend delegate;
  23 + /**
  24 + * 在册的后台进程
  25 + */
  26 + private final Set<ExecHandle> handles = ConcurrentHashMap.newKeySet();
  27 + /**
  28 + * 是否已终止
  29 + */
  30 + private volatile boolean cancelled = false;
  31 +
  32 + public Ai1SkillShellTracker(ExecBackend delegate) {
  33 + this.delegate = delegate;
  34 + }
  35 +
  36 + @Override
  37 + public ExecResult run(ExecSpec spec) {
  38 + return delegate.run(spec);
  39 + }
  40 +
  41 + @Override
  42 + public ExecHandle start(ExecSpec spec) {
  43 + if (cancelled) {
  44 + throw new IllegalStateException("生成已终止");
  45 + }
  46 + ExecHandle handle = delegate.start(spec);
  47 + handles.add(handle);
  48 + // 登记后才被终止时,立即回收;先置标志再遍历的 cancel 与此处必有一方看到该进程
  49 + if (cancelled && handles.remove(handle)) {
  50 + handle.kill();
  51 + }
  52 + return handle;
  53 + }
  54 +
  55 + /**
  56 + * 终止并回收全部在册的后台进程(幂等,可并发调用)
  57 + *
  58 + * 按「移出集合」认领进程,每个进程只回收一次
  59 + */
  60 + public void cancel() {
  61 + cancelled = true;
  62 + for (ExecHandle handle : handles) {
  63 + if (!handles.remove(handle)) {
  64 + continue;
  65 + }
  66 + try {
  67 + handle.kill();
  68 + } catch (Exception e) {
  69 + log.warn("[cancel][Shell 进程({}) 回收失败]", handle.id(), e);
  70 + }
  71 + }
  72 + }
  73 +
  74 +}
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactory.java
@@ -18,6 +18,7 @@ import jakarta.annotation.Resource; @@ -18,6 +18,7 @@ import jakarta.annotation.Resource;
18 import lombok.AllArgsConstructor; 18 import lombok.AllArgsConstructor;
19 import lombok.Data; 19 import lombok.Data;
20 import lombok.extern.slf4j.Slf4j; 20 import lombok.extern.slf4j.Slf4j;
  21 +import org.springaicommunity.agent.exec.LocalExecBackend;
21 import org.springaicommunity.agent.tools.*; 22 import org.springaicommunity.agent.tools.*;
22 import org.springframework.ai.tool.ToolCallback; 23 import org.springframework.ai.tool.ToolCallback;
23 import org.springframework.stereotype.Component; 24 import org.springframework.stereotype.Component;
@@ -87,10 +88,11 @@ public class Ai1SkillToolFactory { @@ -87,10 +88,11 @@ public class Ai1SkillToolFactory {
87 /** 88 /**
88 * 构建 Agent 绑定 SKILL 所需的全部工具:SkillsTool(注入 SKILL.md 内容)+ 执行类工具(限定于 Agent 物化根目录) 89 * 构建 Agent 绑定 SKILL 所需的全部工具:SkillsTool(注入 SKILL.md 内容)+ 执行类工具(限定于 Agent 物化根目录)
89 * 90 *
90 - * @param agent Agent 91 + * @param agent Agent
  92 + * @param tracker Shell 进程跟踪器,Shell 命令经其执行,生成终止或结束时统一回收
91 * @return 工具列表;未绑定有效 SKILL 时返回空列表 93 * @return 工具列表;未绑定有效 SKILL 时返回空列表
92 */ 94 */
93 - public List<Object> buildTools(Ai1AgentDO agent) { 95 + public List<Object> buildTools(Ai1AgentDO agent, Ai1SkillShellTracker tracker) {
94 // 1. 同步物化快照;未绑定有效 SKILL 时,不提供任何工具 96 // 1. 同步物化快照;未绑定有效 SKILL 时,不提供任何工具
95 SkillSnapshot snapshot = sync(agent); 97 SkillSnapshot snapshot = sync(agent);
96 if (snapshot == null) { 98 if (snapshot == null) {
@@ -105,7 +107,7 @@ public class Ai1SkillToolFactory { @@ -105,7 +107,7 @@ public class Ai1SkillToolFactory {
105 107
106 // 3. 执行类工具:ShellTools(bash)、FileSystemTools(Read/Write/Edit)、Glob、Grep、ListDirectory 108 // 3. 执行类工具:ShellTools(bash)、FileSystemTools(Read/Write/Edit)、Glob、Grep、ListDirectory
107 Path root = snapshot.getRoot(); 109 Path root = snapshot.getRoot();
108 - tools.add(ShellTools.builder().workingDirectory(root).build()); 110 + tools.add(ShellTools.builder().execBackend(tracker).build());
109 tools.add(FileSystemTools.builder().allowedDirectory(root).build()); 111 tools.add(FileSystemTools.builder().allowedDirectory(root).build());
110 tools.add(GlobTool.builder().workingDirectory(root).allowedDirectory(root).build()); 112 tools.add(GlobTool.builder().workingDirectory(root).allowedDirectory(root).build());
111 tools.add(GrepTool.builder().workingDirectory(root).allowedDirectory(root).build()); 113 tools.add(GrepTool.builder().workingDirectory(root).allowedDirectory(root).build());
@@ -114,6 +116,16 @@ public class Ai1SkillToolFactory { @@ -114,6 +116,16 @@ public class Ai1SkillToolFactory {
114 } 116 }
115 117
116 /** 118 /**
  119 + * 创建 Agent 的 Shell 进程跟踪器:工作目录限定于 Agent 物化根目录
  120 + *
  121 + * @param agentId Agent 编号
  122 + * @return Shell 进程跟踪器
  123 + */
  124 + public Ai1SkillShellTracker newShellTracker(Long agentId) {
  125 + return new Ai1SkillShellTracker(LocalExecBackend.builder().workingDirectory(buildAgentRoot(agentId)).build());
  126 + }
  127 +
  128 + /**
117 * 清理 Agent 物化沙箱:摘除快照缓存并删除本地目录(Agent 删除时调用) 129 * 清理 Agent 物化沙箱:摘除快照缓存并删除本地目录(Agent 删除时调用)
118 * 130 *
119 * @param agentId Agent 编号 131 * @param agentId Agent 编号
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/job/Ai1MemoryArchiveJob.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.job;
  2 +
  3 +import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler;
  4 +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob;
  5 +import cn.iocoder.yudao.module.ai1.harness.memory.Ai1MemoryArchiveTool;
  6 +import jakarta.annotation.Resource;
  7 +import org.springframework.stereotype.Component;
  8 +
  9 +/**
  10 + * AI1 长期记忆归档 Job
  11 + *
  12 + * @author 芋道源码
  13 + */
  14 +@Component
  15 +public class Ai1MemoryArchiveJob implements JobHandler {
  16 +
  17 + @Resource
  18 + private Ai1MemoryArchiveTool memoryArchiveTool;
  19 +
  20 + @Override
  21 + @TenantJob
  22 + public String execute(String param) {
  23 + int count = memoryArchiveTool.archive();
  24 + return String.format("AI1 长期记忆归档:归档 %s 个用户", count);
  25 + }
  26 +
  27 +}
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentService.java
@@ -81,9 +81,9 @@ public interface Ai1AgentService { @@ -81,9 +81,9 @@ public interface Ai1AgentService {
81 Ai1AgentDO validateAgentEnabled(Long id); 81 Ai1AgentDO validateAgentEnabled(Long id);
82 82
83 /** 83 /**
84 - * 获得指定状态的 Agent 列表,用于会话页选择 Agent 84 + * 获得指定状态的 Agent 列表,按编号升序,用于选择 Agent
85 * 85 *
86 - * @param status 状态 86 + * @param status 状态;为空时返回全部状态
87 * @return Agent 列表 87 * @return Agent 列表
88 */ 88 */
89 List<Ai1AgentDO> getAgentListByStatus(Integer status); 89 List<Ai1AgentDO> getAgentListByStatus(Integer status);
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentServiceImpl.java
@@ -10,6 +10,7 @@ import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; @@ -10,6 +10,7 @@ import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO;
10 import cn.iocoder.yudao.module.ai1.dal.mysql.agent.Ai1AgentMapper; 10 import cn.iocoder.yudao.module.ai1.dal.mysql.agent.Ai1AgentMapper;
11 import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; 11 import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum;
12 import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; 12 import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory;
  13 +import cn.iocoder.yudao.module.ai1.service.memory.Ai1MemoryService;
13 import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; 14 import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService;
14 import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; 15 import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService;
15 import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; 16 import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO;
@@ -45,6 +46,9 @@ public class Ai1AgentServiceImpl implements Ai1AgentService { @@ -45,6 +46,9 @@ public class Ai1AgentServiceImpl implements Ai1AgentService {
45 @Resource 46 @Resource
46 @Lazy // 延迟加载,避免循环依赖 47 @Lazy // 延迟加载,避免循环依赖
47 private Ai1SessionService sessionService; 48 private Ai1SessionService sessionService;
  49 + @Resource
  50 + @Lazy // 延迟加载,避免循环依赖
  51 + private Ai1MemoryService memoryService;
48 52
49 @Resource 53 @Resource
50 private Ai1SkillToolFactory skillToolFactory; 54 private Ai1SkillToolFactory skillToolFactory;
@@ -82,9 +86,10 @@ public class Ai1AgentServiceImpl implements Ai1AgentService { @@ -82,9 +86,10 @@ public class Ai1AgentServiceImpl implements Ai1AgentService {
82 // 1. 校验存在 86 // 1. 校验存在
83 ids.forEach(this::validateAgentExists); 87 ids.forEach(this::validateAgentExists);
84 88
85 - // 2. 删除 Agent,同一事务内级联删除会话与消息 89 + // 2. 删除 Agent,同一事务内级联删除会话、消息与长期记忆
86 agentMapper.deleteByIds(ids); 90 agentMapper.deleteByIds(ids);
87 sessionService.deleteSessionListByAgentIds(ids); 91 sessionService.deleteSessionListByAgentIds(ids);
  92 + memoryService.deleteMemoryListByAgentIds(ids);
88 93
89 // 3. 清理 SKILL 物化沙箱 94 // 3. 清理 SKILL 物化沙箱
90 ids.forEach(skillToolFactory::evict); 95 ids.forEach(skillToolFactory::evict);
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/memory/Ai1MemoryService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.service.memory;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  4 +import cn.iocoder.yudao.module.ai1.controller.admin.memory.vo.Ai1MemoryPageReqVO;
  5 +import cn.iocoder.yudao.module.ai1.controller.admin.memory.vo.Ai1MemoryUpdateReqVO;
  6 +import cn.iocoder.yudao.module.ai1.dal.dataobject.memory.Ai1MemoryDO;
  7 +import jakarta.validation.Valid;
  8 +
  9 +import java.util.Collection;
  10 +import java.util.List;
  11 +
  12 +/**
  13 + * AI1 长期记忆 Service 接口
  14 + *
  15 + * @author 芋道源码
  16 + */
  17 +public interface Ai1MemoryService {
  18 +
  19 + /**
  20 + * 修改长期记忆正文
  21 + *
  22 + * @param updateReqVO 修改信息
  23 + */
  24 + void updateMemory(@Valid Ai1MemoryUpdateReqVO updateReqVO);
  25 +
  26 + /**
  27 + * 删除长期记忆(逻辑删除);之后的归档会根据历史消息生成新的记录
  28 + *
  29 + * @param id 编号
  30 + */
  31 + void deleteMemory(Long id);
  32 +
  33 + /**
  34 + * 批量删除长期记忆
  35 + *
  36 + * @param ids 编号列表
  37 + */
  38 + void deleteMemoryListByIds(List<Long> ids);
  39 +
  40 + /**
  41 + * 删除 Agent 下的全部长期记忆(Agent 删除时调用)
  42 + *
  43 + * @param agentIds Agent 编号集合
  44 + */
  45 + void deleteMemoryListByAgentIds(Collection<Long> agentIds);
  46 +
  47 + /**
  48 + * 获得长期记忆
  49 + *
  50 + * @param id 编号
  51 + * @return 长期记忆
  52 + */
  53 + Ai1MemoryDO getMemory(Long id);
  54 +
  55 + /**
  56 + * 获得用户在 Agent 下的长期记忆
  57 + *
  58 + * @param agentId Agent 编号
  59 + * @param userId 用户编号
  60 + * @return 长期记忆
  61 + */
  62 + Ai1MemoryDO getMemoryByAgentIdAndUserId(Long agentId, Long userId);
  63 +
  64 + /**
  65 + * 创建归档的长期记忆;写入前校验,Agent 已删除,或同一用户已有未删除的记忆时不创建
  66 + *
  67 + * @param memory 长期记忆
  68 + * @return 是否创建成功
  69 + */
  70 + boolean createMemoryIfAgentExists(Ai1MemoryDO memory);
  71 +
  72 + /**
  73 + * 更新归档的长期记忆;写入前校验,读取后被人工修改或删除时不更新
  74 + *
  75 + * @param old 归档读取时的记忆
  76 + * @param content 新的记忆正文
  77 + * @param lastMessageId 新的水位线
  78 + * @return 是否更新成功
  79 + */
  80 + boolean updateMemoryIfUnchanged(Ai1MemoryDO old, String content, Long lastMessageId);
  81 +
  82 + /**
  83 + * 获得长期记忆分页
  84 + *
  85 + * @param pageReqVO 分页查询
  86 + * @return 长期记忆分页
  87 + */
  88 + PageResult<Ai1MemoryDO> getMemoryPage(Ai1MemoryPageReqVO pageReqVO);
  89 +
  90 +}
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/memory/Ai1MemoryServiceImpl.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.service.memory;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.util.ObjUtil;
  5 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  6 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  7 +import cn.iocoder.yudao.module.ai1.controller.admin.memory.vo.Ai1MemoryPageReqVO;
  8 +import cn.iocoder.yudao.module.ai1.controller.admin.memory.vo.Ai1MemoryUpdateReqVO;
  9 +import cn.iocoder.yudao.module.ai1.dal.dataobject.memory.Ai1MemoryDO;
  10 +import cn.iocoder.yudao.module.ai1.dal.mysql.memory.Ai1MemoryMapper;
  11 +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService;
  12 +import jakarta.annotation.Resource;
  13 +import org.springframework.context.annotation.Lazy;
  14 +import org.springframework.stereotype.Service;
  15 +import org.springframework.validation.annotation.Validated;
  16 +
  17 +import java.util.Collection;
  18 +import java.util.Collections;
  19 +import java.util.List;
  20 +
  21 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  22 +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.MEMORY_NOT_EXISTS;
  23 +
  24 +/**
  25 + * AI1 长期记忆 Service 实现类
  26 + *
  27 + * @author 芋道源码
  28 + */
  29 +@Service
  30 +@Validated
  31 +public class Ai1MemoryServiceImpl implements Ai1MemoryService {
  32 +
  33 + @Resource
  34 + private Ai1MemoryMapper memoryMapper;
  35 +
  36 + @Resource
  37 + @Lazy // 延迟加载,避免循环依赖
  38 + private Ai1AgentService agentService;
  39 +
  40 + @Override
  41 + public void updateMemory(Ai1MemoryUpdateReqVO updateReqVO) {
  42 + // 1. 校验存在
  43 + validateMemoryExists(updateReqVO.getId());
  44 +
  45 + // 2. 更新正文;水位线不变,之后的归档写入前校验到正文变化时放弃
  46 + Ai1MemoryDO updateObj = BeanUtils.toBean(updateReqVO, Ai1MemoryDO.class);
  47 + memoryMapper.updateById(updateObj);
  48 + }
  49 +
  50 + @Override
  51 + public void deleteMemory(Long id) {
  52 + deleteMemoryListByIds(Collections.singletonList(id));
  53 + }
  54 +
  55 + @Override
  56 + public void deleteMemoryListByIds(List<Long> ids) {
  57 + // 1. 校验存在
  58 + if (CollUtil.isEmpty(ids)) {
  59 + return;
  60 + }
  61 + ids.forEach(this::validateMemoryExists);
  62 +
  63 + // 2. 逻辑删除,保留历史记录;进行中的归档写入会因记录已删除而放弃
  64 + memoryMapper.deleteByIds(ids);
  65 + }
  66 +
  67 + @Override
  68 + public void deleteMemoryListByAgentIds(Collection<Long> agentIds) {
  69 + if (CollUtil.isEmpty(agentIds)) {
  70 + return;
  71 + }
  72 + memoryMapper.deleteBatch(Ai1MemoryDO::getAgentId, agentIds);
  73 + }
  74 +
  75 + private void validateMemoryExists(Long id) {
  76 + if (memoryMapper.selectById(id) == null) {
  77 + throw exception(MEMORY_NOT_EXISTS);
  78 + }
  79 + }
  80 +
  81 + @Override
  82 + public Ai1MemoryDO getMemory(Long id) {
  83 + return memoryMapper.selectById(id);
  84 + }
  85 +
  86 + @Override
  87 + public Ai1MemoryDO getMemoryByAgentIdAndUserId(Long agentId, Long userId) {
  88 + return memoryMapper.selectByAgentIdAndUserId(agentId, userId);
  89 + }
  90 +
  91 + @Override
  92 + public boolean createMemoryIfAgentExists(Ai1MemoryDO memory) {
  93 + // 1. 前置校验 Agent 存在,且该用户没有未删除的记忆;同租户归档由归档锁串行,极端并发下的重复记录由按用户查询容忍
  94 + if (agentService.getAgent(memory.getAgentId()) == null
  95 + || memoryMapper.selectByAgentIdAndUserId(memory.getAgentId(), memory.getUserId()) != null) {
  96 + return false;
  97 + }
  98 +
  99 + // 2. 插入
  100 + memoryMapper.insert(memory);
  101 + return true;
  102 + }
  103 +
  104 + @Override
  105 + public boolean updateMemoryIfUnchanged(Ai1MemoryDO old, String content, Long lastMessageId) {
  106 + // 1. 校验记忆未删除,且水位线与正文都未变化
  107 + Ai1MemoryDO memory = memoryMapper.selectById(old.getId());
  108 + if (memory == null || ObjUtil.notEqual(memory.getLastMessageId(), old.getLastMessageId())
  109 + || ObjUtil.notEqual(memory.getContent(), old.getContent())) {
  110 + return false;
  111 + }
  112 +
  113 + // 2. 更新正文并推进水位线;校验之后的人工修改不再拦截,已删除的记录不会被更新
  114 + return memoryMapper.updateById(new Ai1MemoryDO().setId(old.getId())
  115 + .setContent(content).setLastMessageId(lastMessageId)) > 0;
  116 + }
  117 +
  118 + @Override
  119 + public PageResult<Ai1MemoryDO> getMemoryPage(Ai1MemoryPageReqVO pageReqVO) {
  120 + return memoryMapper.selectPage(pageReqVO);
  121 + }
  122 +
  123 +}
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageService.java
1 package cn.iocoder.yudao.module.ai1.service.session; 1 package cn.iocoder.yudao.module.ai1.service.session;
2 2
  3 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  4 +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessagePageReqVO;
3 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO; 5 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO;
4 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; 6 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO;
5 import jakarta.validation.Valid; 7 import jakarta.validation.Valid;
@@ -24,6 +26,14 @@ public interface Ai1SessionMessageService { @@ -24,6 +26,14 @@ public interface Ai1SessionMessageService {
24 List<Ai1SessionMessageDO> getSessionMessageListBySessionId(Long sessionId); 26 List<Ai1SessionMessageDO> getSessionMessageListBySessionId(Long sessionId);
25 27
26 /** 28 /**
  29 + * 获得会话消息分页,按编号倒序
  30 + *
  31 + * @param pageReqVO 分页查询
  32 + * @return 消息分页
  33 + */
  34 + PageResult<Ai1SessionMessageDO> getSessionMessagePage(Ai1SessionMessagePageReqVO pageReqVO);
  35 +
  36 + /**
27 * 获得会话中指定消息之前最近的若干条消息,按编号倒序 37 * 获得会话中指定消息之前最近的若干条消息,按编号倒序
28 * 38 *
29 * @param sessionId 会话编号 39 * @param sessionId 会话编号
@@ -34,6 +44,37 @@ public interface Ai1SessionMessageService { @@ -34,6 +44,37 @@ public interface Ai1SessionMessageService {
34 List<Ai1SessionMessageDO> getSessionMessageListBySessionIdAndIdLessThan(Long sessionId, Long id, Integer limit); 44 List<Ai1SessionMessageDO> getSessionMessageListBySessionIdAndIdLessThan(Long sessionId, Long id, Integer limit);
35 45
36 /** 46 /**
  47 + * 获得会话中生成中的助手消息编号列表
  48 + *
  49 + * @param sessionId 会话编号
  50 + * @return 消息编号列表
  51 + */
  52 + List<Long> getGeneratingAssistantMessageIdList(Long sessionId);
  53 +
  54 + /**
  55 + * 获得多个会话中,编号大于 id 的第一条指定状态的消息
  56 + *
  57 + * @param sessionIds 会话编号集合
  58 + * @param status 状态
  59 + * @param id 消息编号(不含)
  60 + * @return 消息
  61 + */
  62 + Ai1SessionMessageDO getFirstSessionMessageBySessionIdsAndStatusAndIdGt(Collection<Long> sessionIds, Integer status, Long id);
  63 +
  64 + /**
  65 + * 获得多个会话中,编号在 (minId, maxId) 之间的指定状态消息,按编号升序
  66 + *
  67 + * @param sessionIds 会话编号集合
  68 + * @param status 状态
  69 + * @param minId 最小编号(不含)
  70 + * @param maxId 最大编号(不含),为空时不限
  71 + * @param limit 数量上限
  72 + * @return 消息列表
  73 + */
  74 + List<Ai1SessionMessageDO> getSessionMessageListBySessionIdsAndStatusAndIdBetween(Collection<Long> sessionIds, Integer status,
  75 + Long minId, Long maxId, Integer limit);
  76 +
  77 + /**
37 * 发送消息(SSE 流式):校验会话归属 → 落库用户消息与助手占位 → 投递生成任务 → 打开 SSE 连接 78 * 发送消息(SSE 流式):校验会话归属 → 落库用户消息与助手占位 → 投递生成任务 → 打开 SSE 连接
38 * 79 *
39 * 校验、落库失败时不抛出异常,而是返回只包含 error 事件的 SSE 连接 80 * 校验、落库失败时不抛出异常,而是返回只包含 error 事件的 SSE 连接
@@ -55,6 +96,14 @@ public interface Ai1SessionMessageService { @@ -55,6 +96,14 @@ public interface Ai1SessionMessageService {
55 SseEmitter resumeSessionMessageStream(Long userId, Long messageId, String lastEventId); 96 SseEmitter resumeSessionMessageStream(Long userId, Long messageId, String lastEventId);
56 97
57 /** 98 /**
  99 + * 停止【我的】会话生成:校验会话归属后,终止会话中生成中的助手消息,跨节点生效
  100 + *
  101 + * @param userId 用户编号
  102 + * @param sessionId 会话编号
  103 + */
  104 + void stopSessionMessageMy(Long userId, Long sessionId);
  105 +
  106 + /**
58 * 获得消息 107 * 获得消息
59 * 108 *
60 * @param id 编号 109 * @param id 编号
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageServiceImpl.java
@@ -4,11 +4,14 @@ import cn.hutool.core.collection.CollUtil; @@ -4,11 +4,14 @@ import cn.hutool.core.collection.CollUtil;
4 import cn.hutool.core.util.StrUtil; 4 import cn.hutool.core.util.StrUtil;
5 import cn.hutool.extra.spring.SpringUtil; 5 import cn.hutool.extra.spring.SpringUtil;
6 import cn.iocoder.yudao.framework.common.exception.ServiceException; 6 import cn.iocoder.yudao.framework.common.exception.ServiceException;
  7 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
7 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; 8 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  9 +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessagePageReqVO;
8 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO; 10 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO;
9 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; 11 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO;
10 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; 12 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO;
11 import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMessageMapper; 13 import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMessageMapper;
  14 +import cn.iocoder.yudao.module.ai1.dal.redis.memory.Ai1MemoryLockRedisDAO;
12 import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; 15 import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum;
13 import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; 16 import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum;
14 import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; 17 import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService;
@@ -21,6 +24,7 @@ import org.springframework.validation.annotation.Validated; @@ -21,6 +24,7 @@ import org.springframework.validation.annotation.Validated;
21 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; 24 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
22 25
23 import java.util.Collection; 26 import java.util.Collection;
  27 +import java.util.Collections;
24 import java.util.List; 28 import java.util.List;
25 29
26 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 30 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -49,25 +53,57 @@ public class Ai1SessionMessageServiceImpl implements Ai1SessionMessageService { @@ -49,25 +53,57 @@ public class Ai1SessionMessageServiceImpl implements Ai1SessionMessageService {
49 @Resource 53 @Resource
50 private Ai1SessionStreamTool sessionStreamTool; 54 private Ai1SessionStreamTool sessionStreamTool;
51 55
  56 + @Resource
  57 + private Ai1MemoryLockRedisDAO memoryLockRedisDAO;
  58 +
52 @Override 59 @Override
53 public List<Ai1SessionMessageDO> getSessionMessageListBySessionId(Long sessionId) { 60 public List<Ai1SessionMessageDO> getSessionMessageListBySessionId(Long sessionId) {
54 return sessionMessageMapper.selectListBySessionId(sessionId); 61 return sessionMessageMapper.selectListBySessionId(sessionId);
55 } 62 }
56 63
57 @Override 64 @Override
  65 + public PageResult<Ai1SessionMessageDO> getSessionMessagePage(Ai1SessionMessagePageReqVO pageReqVO) {
  66 + return sessionMessageMapper.selectPage(pageReqVO);
  67 + }
  68 +
  69 + @Override
58 public List<Ai1SessionMessageDO> getSessionMessageListBySessionIdAndIdLessThan(Long sessionId, Long id, Integer limit) { 70 public List<Ai1SessionMessageDO> getSessionMessageListBySessionIdAndIdLessThan(Long sessionId, Long id, Integer limit) {
59 return sessionMessageMapper.selectListBySessionIdAndIdLessThan(sessionId, id, limit); 71 return sessionMessageMapper.selectListBySessionIdAndIdLessThan(sessionId, id, limit);
60 } 72 }
61 73
62 @Override 74 @Override
  75 + public List<Long> getGeneratingAssistantMessageIdList(Long sessionId) {
  76 + return sessionMessageMapper.selectIdListBySessionIdAndRoleAndStatus(sessionId,
  77 + Ai1SessionMessageRoleEnum.ASSISTANT.getRole(), Ai1SessionMessageStatusEnum.GENERATING.getStatus());
  78 + }
  79 +
  80 + @Override
  81 + public Ai1SessionMessageDO getFirstSessionMessageBySessionIdsAndStatusAndIdGt(Collection<Long> sessionIds, Integer status, Long id) {
  82 + if (CollUtil.isEmpty(sessionIds)) {
  83 + return null;
  84 + }
  85 + return sessionMessageMapper.selectFirstBySessionIdsAndStatusAndIdGt(sessionIds, status, id);
  86 + }
  87 +
  88 + @Override
  89 + public List<Ai1SessionMessageDO> getSessionMessageListBySessionIdsAndStatusAndIdBetween(Collection<Long> sessionIds, Integer status,
  90 + Long minId, Long maxId, Integer limit) {
  91 + if (CollUtil.isEmpty(sessionIds)) {
  92 + return Collections.emptyList();
  93 + }
  94 + return sessionMessageMapper.selectListBySessionIdsAndStatusAndIdBetween(sessionIds, status, minId, maxId, limit);
  95 + }
  96 +
  97 + @Override
63 public SseEmitter sendSessionMessageStream(Long userId, Ai1SessionMessageSendReqVO sendReqVO) { 98 public SseEmitter sendSessionMessageStream(Long userId, Ai1SessionMessageSendReqVO sendReqVO) {
64 try { 99 try {
65 // 1. 校验会话归属、Agent 存在且已开启 100 // 1. 校验会话归属、Agent 存在且已开启
66 Ai1SessionDO session = sessionService.validateSessionMy(userId, sendReqVO.getSessionId()); 101 Ai1SessionDO session = sessionService.validateSessionMy(userId, sendReqVO.getSessionId());
67 agentService.validateAgentEnabled(session.getAgentId()); 102 agentService.validateAgentEnabled(session.getAgentId());
68 103
69 - // 2. 同一事务内落库用户消息与助手占位  
70 - Long messageId = getSelf().createRoundMessages(session, sendReqVO.getContent()); 104 + // 2. 同一事务内落库用户消息与助手占位;持有记忆快照锁至事务提交
  105 + Long messageId = memoryLockRedisDAO.lockSnapshot(session.getAgentId(), session.getUserId(),
  106 + () -> getSelf().createRoundMessages(session, sendReqVO.getContent()));
71 107
72 // 3. 投递生成任务,并打开 SSE 连接 108 // 3. 投递生成任务,并打开 SSE 连接
73 try { 109 try {
@@ -120,6 +156,14 @@ public class Ai1SessionMessageServiceImpl implements Ai1SessionMessageService { @@ -120,6 +156,14 @@ public class Ai1SessionMessageServiceImpl implements Ai1SessionMessageService {
120 } 156 }
121 } 157 }
122 158
  159 + @Override
  160 + public void stopSessionMessageMy(Long userId, Long sessionId) {
  161 + // 1. 校验会话属于当前用户
  162 + sessionService.validateSessionMy(userId, sessionId);
  163 + // 2. 取消会话中生成中的助手消息
  164 + sessionStreamTool.cancel(sessionId);
  165 + }
  166 +
123 /** 167 /**
124 * 开启一轮会话:首条消息自动生成标题 → 落库用户消息(完成)与助手占位(生成中)→ 刷新会话活跃时间 168 * 开启一轮会话:首条消息自动生成标题 → 落库用户消息(完成)与助手占位(生成中)→ 刷新会话活跃时间
125 * 169 *
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionService.java
@@ -54,6 +54,14 @@ public interface Ai1SessionService { @@ -54,6 +54,14 @@ public interface Ai1SessionService {
54 Ai1SessionDO validateSessionMy(Long userId, Long id); 54 Ai1SessionDO validateSessionMy(Long userId, Long id);
55 55
56 /** 56 /**
  57 + * 获得会话
  58 + *
  59 + * @param id 编号
  60 + * @return 会话
  61 + */
  62 + Ai1SessionDO getSession(Long id);
  63 +
  64 + /**
57 * 获得指定用户在指定 Agent 下的会话列表,按编号倒序 65 * 获得指定用户在指定 Agent 下的会话列表,按编号倒序
58 * 66 *
59 * @param agentId Agent 编号 67 * @param agentId Agent 编号
@@ -63,6 +71,14 @@ public interface Ai1SessionService { @@ -63,6 +71,14 @@ public interface Ai1SessionService {
63 List<Ai1SessionDO> getSessionListByAgentIdAndUserId(Long agentId, Long userId); 71 List<Ai1SessionDO> getSessionListByAgentIdAndUserId(Long agentId, Long userId);
64 72
65 /** 73 /**
  74 + * 获得 Agent 下有会话的用户编号列表
  75 + *
  76 + * @param agentId Agent 编号
  77 + * @return 用户编号列表
  78 + */
  79 + List<Long> getUserIdListByAgentId(Long agentId);
  80 +
  81 + /**
66 * 获得会话分页(管理端查看 Agent 的全部会话) 82 * 获得会话分页(管理端查看 Agent 的全部会话)
67 * 83 *
68 * @param pageReqVO 分页查询 84 * @param pageReqVO 分页查询
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionServiceImpl.java
@@ -84,11 +84,21 @@ public class Ai1SessionServiceImpl implements Ai1SessionService { @@ -84,11 +84,21 @@ public class Ai1SessionServiceImpl implements Ai1SessionService {
84 } 84 }
85 85
86 @Override 86 @Override
  87 + public Ai1SessionDO getSession(Long id) {
  88 + return sessionMapper.selectById(id);
  89 + }
  90 +
  91 + @Override
87 public List<Ai1SessionDO> getSessionListByAgentIdAndUserId(Long agentId, Long userId) { 92 public List<Ai1SessionDO> getSessionListByAgentIdAndUserId(Long agentId, Long userId) {
88 return sessionMapper.selectListByAgentIdAndUserId(agentId, userId); 93 return sessionMapper.selectListByAgentIdAndUserId(agentId, userId);
89 } 94 }
90 95
91 @Override 96 @Override
  97 + public List<Long> getUserIdListByAgentId(Long agentId) {
  98 + return sessionMapper.selectUserIdListByAgentId(agentId);
  99 + }
  100 +
  101 + @Override
92 public PageResult<Ai1SessionDO> getSessionPage(Ai1SessionPageReqVO pageReqVO) { 102 public PageResult<Ai1SessionDO> getSessionPage(Ai1SessionPageReqVO pageReqVO) {
93 return sessionMapper.selectPage(pageReqVO); 103 return sessionMapper.selectPage(pageReqVO);
94 } 104 }
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/util/Ai1Utils.java
@@ -87,4 +87,56 @@ public class Ai1Utils { @@ -87,4 +87,56 @@ public class Ai1Utils {
87 } 87 }
88 } 88 }
89 89
  90 + /**
  91 + * 规范化模型生成的长期记忆 Markdown:修正标题、列表标记,并保证维度标题与内容不在同一行
  92 + *
  93 + * 只按长期记忆的固定维度标题拆行,不拆开 C#、#123、URL 锚点,不把中文破折号视为列表标记
  94 + *
  95 + * @param content 模型输出的记忆
  96 + * @param sections 记忆维度标题
  97 + * @return 规范化后的记忆;为空时原样返回
  98 + */
  99 + public static String normalizeMemoryMarkdown(String content, Collection<String> sections) {
  100 + if (StrUtil.isBlank(content)) {
  101 + return content;
  102 + }
  103 + String text = content.replace("\r\n", "\n").replace("\r", "\n").trim();
  104 + // 1. 行内出现的维度标题另起一行:…文字## 用户画像 → …文字\n## 用户画像
  105 + text = text.replaceAll("([^#\\n])(#{1,6}[ \\t]*(?:" + String.join("|", sections) + "))", "$1\n$2");
  106 + // 2. 标题标记后补空格:#标题 → # 标题(# 后为非 #、非空白,避免把 ## 拆成 # #)
  107 + text = text.replaceAll("(?m)^(#{1,6})(?=[^#\\s])", "$1 ");
  108 + // 3. 维度标题与紧随其后的内容拆行:## 偏好习惯-xxx → ## 偏好习惯\n-xxx
  109 + for (String section : sections) {
  110 + text = text.replaceAll("(?m)(#{1,6} " + section + ")[ \\t]*(?=\\S)", "$1\n");
  111 + }
  112 + // 4. 行内列表项另起一行,仅当前一字符为非 ASCII:…口吻-开场 → …口吻\n- 开场(不把中文破折号 —、– 视为列表标记)
  113 + text = text.replaceAll("([^\\x00-\\x7F])[-*+\\u2022][ \\t]*(?=\\S)", "$1\n- ");
  114 + // 5. 行首列表标记后补空格:-xxx → - xxx(同样不处理中文破折号)
  115 + text = text.replaceAll("(?m)^([-*+\\u2022])(?=\\S)", "$1 ");
  116 + // 6. 不同维度之间空一行
  117 + text = text.replaceAll("(?m)^(#{1,6} )", "\n$1");
  118 + // 7. 合并多余空行
  119 + text = text.replaceAll("\\n{3,}", "\n\n");
  120 + return text.trim();
  121 + }
  122 +
  123 + /**
  124 + * 去除首尾空白后按字符数截断,不拆开 UTF-16 代理对
  125 + *
  126 + * @param content 内容
  127 + * @param maxLength 最大长度(字符),需大于 0
  128 + * @return 截断后的内容;为空时原样返回
  129 + */
  130 + public static String limitLength(String content, int maxLength) {
  131 + if (StrUtil.isBlank(content)) {
  132 + return content;
  133 + }
  134 + String text = content.trim();
  135 + if (text.length() <= maxLength) {
  136 + return text;
  137 + }
  138 + int end = Character.isHighSurrogate(text.charAt(maxLength - 1)) ? maxLength - 1 : maxLength;
  139 + return text.substring(0, end);
  140 + }
  141 +
90 } 142 }
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatToolTest.java
@@ -18,7 +18,10 @@ import org.springframework.ai.tool.annotation.Tool; @@ -18,7 +18,10 @@ import org.springframework.ai.tool.annotation.Tool;
18 import org.springframework.ai.tool.function.FunctionToolCallback; 18 import org.springframework.ai.tool.function.FunctionToolCallback;
19 import reactor.core.publisher.Flux; 19 import reactor.core.publisher.Flux;
20 20
  21 +import java.util.ArrayList;
  22 +import java.util.HashMap;
21 import java.util.List; 23 import java.util.List;
  24 +import java.util.Map;
22 import java.util.concurrent.atomic.AtomicInteger; 25 import java.util.concurrent.atomic.AtomicInteger;
23 26
24 import static org.junit.jupiter.api.Assertions.*; 27 import static org.junit.jupiter.api.Assertions.*;
@@ -89,6 +92,41 @@ public class Ai1LlmChatToolTest extends BaseMockitoUnitTest { @@ -89,6 +92,41 @@ public class Ai1LlmChatToolTest extends BaseMockitoUnitTest {
89 assertEquals(1, skillCalls.get()); 92 assertEquals(1, skillCalls.get());
90 } 93 }
91 94
  95 + @Test
  96 + public void testChat_whitespaceChunks() {
  97 + // mock chatModel 和 llmModelFactory 的方法:空格、换行分片也是有效增量,只跳过空串
  98 + ChatModel chatModel = mock(ChatModel.class);
  99 + when(chatModel.getDefaultOptions()).thenReturn(ToolCallingChatOptions.builder().build());
  100 + when(chatModel.stream(any(Prompt.class))).thenReturn(Flux.just(
  101 + buildChunk("想", "你好"), buildChunk(" ", " "), buildChunk("\n", "\n"),
  102 + buildChunk("", "世界"), buildChunk(null, "")));
  103 + ChatClient chatClient = ChatClient.create(chatModel);
  104 + when(llmModelFactory.buildChatClient(any(), any())).thenReturn(chatClient);
  105 + // 准备参数
  106 + List<String> thinkingDeltas = new ArrayList<>();
  107 + List<String> contentDeltas = new ArrayList<>();
  108 +
  109 + // 调用
  110 + Ai1LlmChatTool.ChatText result = llmChatTool.chat(new Ai1ModelRespBO(), "", List.of(), "你好",
  111 + List.of(), List.of(), 1L, thinkingDeltas::add, contentDeltas::add);
  112 + // 断言
  113 + assertEquals("你好 \n世界", result.getContent());
  114 + assertEquals("想 \n", result.getThinking());
  115 + assertEquals(List.of("你好", " ", "\n", "世界"), contentDeltas);
  116 + assertEquals(List.of("想", " ", "\n"), thinkingDeltas);
  117 + }
  118 +
  119 + // ========== 测试数据 ==========
  120 +
  121 + private static ChatResponse buildChunk(String reasoning, String content) {
  122 + Map<String, Object> properties = new HashMap<>();
  123 + if (reasoning != null) {
  124 + properties.put("reasoningContent", reasoning);
  125 + }
  126 + return new ChatResponse(List.of(new Generation(AssistantMessage.builder()
  127 + .content(content).properties(properties).build())));
  128 + }
  129 +
92 // ========== 测试工具 ========== 130 // ========== 测试工具 ==========
93 131
94 public static class AnnotatedTools { 132 public static class AnnotatedTools {
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/memory/Ai1MemoryArchiveToolTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.harness.memory;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  4 +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  6 +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO;
  7 +import cn.iocoder.yudao.module.ai1.dal.dataobject.memory.Ai1MemoryDO;
  8 +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO;
  9 +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO;
  10 +import cn.iocoder.yudao.module.ai1.dal.redis.memory.Ai1MemoryLockRedisDAO;
  11 +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum;
  12 +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties;
  13 +import cn.iocoder.yudao.module.ai1.harness.session.Ai1SessionStreamTool;
  14 +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService;
  15 +import cn.iocoder.yudao.module.ai1.service.memory.Ai1MemoryService;
  16 +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService;
  17 +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService;
  18 +import org.junit.jupiter.api.AfterEach;
  19 +import org.junit.jupiter.api.BeforeEach;
  20 +import org.junit.jupiter.api.Test;
  21 +import org.mockito.InOrder;
  22 +import org.mockito.InjectMocks;
  23 +import org.mockito.Mock;
  24 +import org.mockito.Spy;
  25 +import org.redisson.api.RLock;
  26 +import org.redisson.api.RedissonClient;
  27 +import org.springframework.test.util.ReflectionTestUtils;
  28 +
  29 +import java.util.List;
  30 +
  31 +import static org.junit.jupiter.api.Assertions.*;
  32 +import static org.mockito.ArgumentMatchers.*;
  33 +import static org.mockito.Mockito.*;
  34 +
  35 +/**
  36 + * {@link Ai1MemoryArchiveTool} 的单元测试
  37 + *
  38 + * @author 芋道源码
  39 + */
  40 +public class Ai1MemoryArchiveToolTest extends BaseMockitoUnitTest {
  41 +
  42 + private static final Long TENANT_ID = 1L;
  43 + private static final Long AGENT_ID = 1L;
  44 + private static final Long USER_ID = 100L;
  45 + private static final List<Long> SESSION_IDS = List.of(10L, 11L);
  46 +
  47 + @InjectMocks
  48 + private Ai1MemoryArchiveTool memoryArchiveTool;
  49 +
  50 + @Spy
  51 + private Ai1MemoryLockRedisDAO memoryLockRedisDAO;
  52 + @Mock
  53 + private RedissonClient redissonClient;
  54 + @Mock
  55 + private RLock archiveLock;
  56 + @Mock
  57 + private RLock snapshotLock;
  58 + @Spy
  59 + private YudaoAi1Properties ai1Properties = new YudaoAi1Properties();
  60 +
  61 + @Mock
  62 + private Ai1AgentService agentService;
  63 + @Mock
  64 + private Ai1SessionService sessionService;
  65 + @Mock
  66 + private Ai1SessionMessageService sessionMessageService;
  67 + @Mock
  68 + private Ai1MemoryService memoryService;
  69 + @Mock
  70 + private Ai1MemoryTool memoryTool;
  71 + @Mock
  72 + private Ai1SessionStreamTool sessionStreamTool;
  73 +
  74 + @BeforeEach
  75 + public void before() {
  76 + TenantContextHolder.setTenantId(TENANT_ID);
  77 + // mock 归档锁、快照锁
  78 + ReflectionTestUtils.setField(memoryLockRedisDAO, "redissonClient", redissonClient);
  79 + lenient().when(redissonClient.getLock("ai1:memory:archive-lock:" + TENANT_ID)).thenReturn(archiveLock);
  80 + lenient().when(archiveLock.tryLock()).thenReturn(true);
  81 + lenient().when(archiveLock.isHeldByCurrentThread()).thenReturn(true);
  82 + lenient().when(redissonClient.getLock("ai1:memory:snapshot-lock:" + AGENT_ID + ":" + USER_ID)).thenReturn(snapshotLock);
  83 + lenient().when(sessionService.getSessionListByAgentIdAndUserId(AGENT_ID, USER_ID)).thenReturn(List.of(
  84 + new Ai1SessionDO().setId(10L), new Ai1SessionDO().setId(11L)));
  85 + }
  86 +
  87 + @AfterEach
  88 + public void after() {
  89 + TenantContextHolder.clear();
  90 + }
  91 +
  92 + @Test
  93 + public void testArchive_locked() {
  94 + // mock 其他节点归档中
  95 + when(archiveLock.tryLock()).thenReturn(false);
  96 +
  97 + // 调用,并断言
  98 + assertEquals(0, memoryArchiveTool.archive());
  99 + verifyNoInteractions(agentService, memoryTool);
  100 + verify(archiveLock, never()).unlock();
  101 + }
  102 +
  103 + @Test
  104 + public void testArchive_lockNotHeld() {
  105 + // mock 方法:归档结束时锁已不属于当前线程
  106 + when(agentService.getAgentListByStatus(CommonStatusEnum.ENABLE.getStatus())).thenReturn(List.of());
  107 + when(archiveLock.isHeldByCurrentThread()).thenReturn(false);
  108 +
  109 + // 调用,并断言:不释放其他线程持有的锁
  110 + assertEquals(0, memoryArchiveTool.archive());
  111 + verify(archiveLock, never()).unlock();
  112 + }
  113 +
  114 + @Test
  115 + public void testArchive_onlyMemoryEnabled() {
  116 + // mock 方法:只有开启记忆的 Agent 归档
  117 + Ai1AgentDO agent = buildAgent();
  118 + when(agentService.getAgentListByStatus(CommonStatusEnum.ENABLE.getStatus())).thenReturn(List.of(
  119 + agent, buildAgent().setId(2L).setMemoryEnabled(false), buildAgent().setId(3L).setMemoryEnabled(null)));
  120 + when(sessionService.getUserIdListByAgentId(AGENT_ID)).thenReturn(List.of(USER_ID));
  121 + mockNewMessages(0L, null, List.of(buildMessage(12L), buildMessage(13L)));
  122 + when(memoryTool.generate(eq(agent), isNull(), anyList(), eq(2000))).thenReturn("## 用户画像\n- 程序员");
  123 + when(memoryService.createMemoryIfAgentExists(any())).thenReturn(true);
  124 +
  125 + // 调用
  126 + int count = memoryArchiveTool.archive();
  127 + // 断言:首次归档插入,水位线为本批最后一条
  128 + assertEquals(1, count);
  129 + verify(sessionService, never()).getUserIdListByAgentId(2L);
  130 + verify(sessionService, never()).getUserIdListByAgentId(3L);
  131 + verify(memoryService).createMemoryIfAgentExists(argThat(memory -> AGENT_ID.equals(memory.getAgentId())
  132 + && USER_ID.equals(memory.getUserId()) && "## 用户画像\n- 程序员".equals(memory.getContent())
  133 + && Long.valueOf(13L).equals(memory.getLastMessageId())));
  134 + verify(archiveLock).unlock();
  135 + }
  136 +
  137 + @Test
  138 + public void testArchive_userFailedNotAffectOthers() {
  139 + // mock 方法:第一个用户生成失败,第二个用户无新消息
  140 + when(agentService.getAgentListByStatus(CommonStatusEnum.ENABLE.getStatus())).thenReturn(List.of(buildAgent()));
  141 + when(sessionService.getUserIdListByAgentId(AGENT_ID)).thenReturn(List.of(USER_ID, 200L));
  142 + mockNewMessages(0L, null, List.of(buildMessage(12L)));
  143 + when(memoryTool.generate(any(), any(), anyList(), anyInt())).thenThrow(new IllegalStateException("模型超时"));
  144 + when(redissonClient.getLock("ai1:memory:snapshot-lock:" + AGENT_ID + ":200")).thenReturn(snapshotLock);
  145 +
  146 + // 调用
  147 + int count = memoryArchiveTool.archive();
  148 + // 断言:不推进水位线,下次重试
  149 + assertEquals(0, count);
  150 + verify(memoryService).getMemoryByAgentIdAndUserId(AGENT_ID, 200L);
  151 + verify(memoryService, never()).createMemoryIfAgentExists(any());
  152 + verify(memoryService, never()).updateMemoryIfUnchanged(any(), any(), any());
  153 + verify(archiveLock).unlock();
  154 + }
  155 +
  156 + @Test
  157 + public void testArchiveOne_safePrefix() {
  158 + // mock 方法:水位线之后有生成中的消息,只读取它之前的已完成消息
  159 + Ai1MemoryDO memory = Ai1MemoryDO.builder().id(1L).agentId(AGENT_ID).userId(USER_ID)
  160 + .content("旧记忆").lastMessageId(11L).build();
  161 + when(memoryService.getMemoryByAgentIdAndUserId(AGENT_ID, USER_ID)).thenReturn(memory);
  162 + mockNewMessages(11L, 20L, List.of(buildMessage(12L), buildMessage(13L)));
  163 + when(memoryTool.generate(any(), eq("旧记忆"), anyList(), eq(2000))).thenReturn("新记忆");
  164 + when(memoryService.updateMemoryIfUnchanged(memory, "新记忆", 13L)).thenReturn(true);
  165 +
  166 + // 调用,并断言
  167 + assertTrue(memoryArchiveTool.archiveOne(buildAgent(), USER_ID, 2000));
  168 + // 断言:孤儿收尾在快照锁外,持有快照锁读取,模型在锁外调用
  169 + InOrder inOrder = inOrder(sessionStreamTool, snapshotLock, sessionMessageService, memoryTool);
  170 + inOrder.verify(sessionStreamTool).failStaleMessage(argThat(message -> Long.valueOf(20L).equals(message.getId())));
  171 + inOrder.verify(snapshotLock).lock();
  172 + inOrder.verify(sessionMessageService).getSessionMessageListBySessionIdsAndStatusAndIdBetween(SESSION_IDS,
  173 + Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), 11L, 20L, Ai1MemoryArchiveTool.BATCH_SIZE);
  174 + inOrder.verify(snapshotLock).unlock();
  175 + inOrder.verify(memoryTool).generate(any(), any(), anyList(), anyInt());
  176 + }
  177 +
  178 + @Test
  179 + public void testArchiveOne_snapshotReadFailed() {
  180 + // mock 方法:持有快照锁读取新消息时失败
  181 + when(sessionMessageService.getSessionMessageListBySessionIdsAndStatusAndIdBetween(SESSION_IDS,
  182 + Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), 0L, null, Ai1MemoryArchiveTool.BATCH_SIZE))
  183 + .thenThrow(new IllegalStateException("数据库超时"));
  184 +
  185 + // 调用,并断言:异常抛出,快照锁仍释放
  186 + assertThrows(IllegalStateException.class, () -> memoryArchiveTool.archiveOne(buildAgent(), USER_ID, 2000));
  187 + verify(snapshotLock).lock();
  188 + verify(snapshotLock).unlock();
  189 + verifyNoInteractions(memoryTool);
  190 + }
  191 +
  192 + @Test
  193 + public void testArchiveOne_noNewMessages() {
  194 + // mock 方法
  195 + mockNewMessages(0L, null, List.of());
  196 +
  197 + // 调用,并断言
  198 + assertFalse(memoryArchiveTool.archiveOne(buildAgent(), USER_ID, 2000));
  199 + verifyNoInteractions(memoryTool, sessionStreamTool);
  200 + }
  201 +
  202 + @Test
  203 + public void testArchiveOne_staleGeneratingRecovered() {
  204 + // mock 数据:孤儿生成之后有已完成的消息
  205 + Ai1MemoryDO memory = Ai1MemoryDO.builder().id(1L).content("旧记忆").lastMessageId(11L).build();
  206 + when(memoryService.getMemoryByAgentIdAndUserId(AGENT_ID, USER_ID)).thenReturn(memory);
  207 + // mock 方法:孤儿生成收尾后,不再有生成中的消息
  208 + Ai1SessionMessageDO generatingMessage = buildMessage(20L);
  209 + when(sessionMessageService.getFirstSessionMessageBySessionIdsAndStatusAndIdGt(SESSION_IDS,
  210 + Ai1SessionMessageStatusEnum.GENERATING.getStatus(), 11L)).thenReturn(generatingMessage, (Ai1SessionMessageDO) null);
  211 + when(sessionStreamTool.failStaleMessage(generatingMessage)).thenReturn(true);
  212 + when(sessionMessageService.getSessionMessageListBySessionIdsAndStatusAndIdBetween(SESSION_IDS,
  213 + Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), 11L, null, Ai1MemoryArchiveTool.BATCH_SIZE))
  214 + .thenReturn(List.of(buildMessage(12L), buildMessage(21L)));
  215 + when(memoryTool.generate(any(), eq("旧记忆"), anyList(), eq(2000))).thenReturn("新记忆");
  216 + when(memoryService.updateMemoryIfUnchanged(memory, "新记忆", 21L)).thenReturn(true);
  217 +
  218 + // 调用,并断言:水位线越过已收尾的孤儿生成
  219 + assertTrue(memoryArchiveTool.archiveOne(buildAgent(), USER_ID, 2000));
  220 + InOrder inOrder = inOrder(sessionStreamTool, snapshotLock);
  221 + inOrder.verify(sessionStreamTool).failStaleMessage(generatingMessage);
  222 + inOrder.verify(snapshotLock).lock();
  223 + }
  224 +
  225 + @Test
  226 + public void testArchiveOne_activeGeneratingBlocks() {
  227 + // mock 方法:生成仍在执行或未超时,安全前缀为空
  228 + mockNewMessages(0L, 12L, List.of());
  229 +
  230 + // 调用,并断言:不收尾,也不调用模型
  231 + assertFalse(memoryArchiveTool.archiveOne(buildAgent(), USER_ID, 2000));
  232 + verify(sessionStreamTool).failStaleMessage(argThat(message -> Long.valueOf(12L).equals(message.getId())));
  233 + verifyNoInteractions(memoryTool);
  234 + }
  235 +
  236 + @Test
  237 + public void testArchiveOne_blankMemory() {
  238 + // mock 方法
  239 + mockNewMessages(0L, null, List.of(buildMessage(12L)));
  240 + when(memoryTool.generate(any(), any(), anyList(), anyInt())).thenReturn(" ");
  241 +
  242 + // 调用,并断言:模型无输出时不写入,水位线不前进
  243 + assertFalse(memoryArchiveTool.archiveOne(buildAgent(), USER_ID, 2000));
  244 + verify(memoryService, never()).createMemoryIfAgentExists(any());
  245 + }
  246 +
  247 + @Test
  248 + public void testArchiveOne_changedDuringArchive() {
  249 + // mock 方法:归档期间记忆被人工修改或删除
  250 + Ai1MemoryDO memory = Ai1MemoryDO.builder().id(1L).content("旧记忆").lastMessageId(11L).build();
  251 + when(memoryService.getMemoryByAgentIdAndUserId(AGENT_ID, USER_ID)).thenReturn(memory);
  252 + mockNewMessages(11L, null, List.of(buildMessage(12L)));
  253 + when(memoryTool.generate(any(), any(), anyList(), anyInt())).thenReturn("新记忆");
  254 + when(memoryService.updateMemoryIfUnchanged(memory, "新记忆", 12L)).thenReturn(false);
  255 +
  256 + // 调用,并断言:放弃本次结果,不重新插入
  257 + assertFalse(memoryArchiveTool.archiveOne(buildAgent(), USER_ID, 2000));
  258 + verify(memoryService, never()).createMemoryIfAgentExists(any());
  259 + }
  260 +
  261 + @Test
  262 + public void testArchiveOne_insertConflict() {
  263 + // mock 方法:其他归档已插入,或 Agent 已删除
  264 + mockNewMessages(0L, null, List.of(buildMessage(12L)));
  265 + when(memoryTool.generate(any(), any(), anyList(), anyInt())).thenReturn("新记忆");
  266 + when(memoryService.createMemoryIfAgentExists(any())).thenReturn(false);
  267 +
  268 + // 调用,并断言:放弃本次结果
  269 + assertFalse(memoryArchiveTool.archiveOne(buildAgent(), USER_ID, 2000));
  270 + verify(memoryService, never()).updateMemoryIfUnchanged(any(), any(), any());
  271 + }
  272 +
  273 + // ========== mock 方法 ==========
  274 +
  275 + private void mockNewMessages(Long lastMessageId, Long generatingMessageId, List<Ai1SessionMessageDO> messages) {
  276 + when(sessionMessageService.getFirstSessionMessageBySessionIdsAndStatusAndIdGt(SESSION_IDS,
  277 + Ai1SessionMessageStatusEnum.GENERATING.getStatus(), lastMessageId))
  278 + .thenReturn(generatingMessageId != null ? buildMessage(generatingMessageId) : null);
  279 + when(sessionMessageService.getSessionMessageListBySessionIdsAndStatusAndIdBetween(SESSION_IDS,
  280 + Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), lastMessageId, generatingMessageId, Ai1MemoryArchiveTool.BATCH_SIZE))
  281 + .thenReturn(messages);
  282 + }
  283 +
  284 + // ========== 测试数据 ==========
  285 +
  286 + private static Ai1AgentDO buildAgent() {
  287 + return new Ai1AgentDO().setId(AGENT_ID).setProviderId(1L).setModelId(2L).setMemoryEnabled(true);
  288 + }
  289 +
  290 + private static Ai1SessionMessageDO buildMessage(Long id) {
  291 + return new Ai1SessionMessageDO().setId(id).setContent("消息" + id);
  292 + }
  293 +
  294 +}
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/memory/Ai1MemoryToolTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.harness.memory;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO;
  5 +import cn.iocoder.yudao.module.ai1.dal.dataobject.memory.Ai1MemoryDO;
  6 +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO;
  7 +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum;
  8 +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum;
  9 +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool;
  10 +import cn.iocoder.yudao.module.ai1.service.memory.Ai1MemoryService;
  11 +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService;
  12 +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO;
  13 +import org.junit.jupiter.api.Test;
  14 +import org.mockito.ArgumentCaptor;
  15 +import org.mockito.InjectMocks;
  16 +import org.mockito.Mock;
  17 +
  18 +import java.util.List;
  19 +
  20 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  21 +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.MODEL_TYPE_NOT_CHAT;
  22 +import static org.junit.jupiter.api.Assertions.*;
  23 +import static org.mockito.ArgumentMatchers.*;
  24 +import static org.mockito.Mockito.*;
  25 +
  26 +/**
  27 + * {@link Ai1MemoryTool} 的单元测试
  28 + *
  29 + * @author 芋道源码
  30 + */
  31 +public class Ai1MemoryToolTest extends BaseMockitoUnitTest {
  32 +
  33 + @InjectMocks
  34 + private Ai1MemoryTool memoryTool;
  35 +
  36 + @Mock
  37 + private Ai1MemoryService memoryService;
  38 + @Mock
  39 + private Ai1ModelService modelService;
  40 + @Mock
  41 + private Ai1LlmChatTool llmChatTool;
  42 +
  43 + @Test
  44 + public void testLoadMemory() {
  45 + // mock 方法
  46 + when(memoryService.getMemoryByAgentIdAndUserId(1L, 100L)).thenReturn(Ai1MemoryDO.builder().content("## 用户画像\n- 程序员").build());
  47 +
  48 + // 调用,并断言
  49 + assertEquals("## 用户画像\n- 程序员", memoryTool.loadMemory(1L, 100L));
  50 + // 调用,并断言:编号为空时不查询
  51 + assertNull(memoryTool.loadMemory(null, 100L));
  52 + verify(memoryService, never()).getMemoryByAgentIdAndUserId(isNull(), any());
  53 + }
  54 +
  55 + @Test
  56 + public void testMergePrompt() {
  57 + // 调用,并断言:按模板合入记忆
  58 + assertEquals("你是客服\n\n以下是该用户的历史长期记忆(Markdown),请在回答时结合这些信息;"
  59 + + "若与用户当前表述冲突,以当前表述为准:\n## 用户画像\n- 程序员", memoryTool.mergePrompt("你是客服", " ## 用户画像\n- 程序员 \n"));
  60 + // 调用,并断言:记忆为空时原样返回
  61 + assertEquals("你是客服", memoryTool.mergePrompt("你是客服", " "));
  62 + assertEquals("", memoryTool.mergePrompt(null, null));
  63 + }
  64 +
  65 + @Test
  66 + public void testGenerate_modelNotChat() {
  67 + // mock modelService 的方法
  68 + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO()
  69 + .setModelType(Ai1ModelTypeEnum.EMBEDDING.getType()));
  70 +
  71 + // 调用,并断言异常
  72 + assertServiceException(() -> memoryTool.generate(buildAgent(), null, List.of(), 2000), MODEL_TYPE_NOT_CHAT);
  73 + verifyNoInteractions(llmChatTool);
  74 + }
  75 +
  76 + @Test
  77 + public void testGenerate_prompt() {
  78 + // mock 方法
  79 + mockChat("## 用户画像\n- 程序员");
  80 + // 准备参数
  81 + List<Ai1SessionMessageDO> messages = List.of(
  82 + new Ai1SessionMessageDO().setRole(Ai1SessionMessageRoleEnum.USER.getRole()).setContent("我是程序员"),
  83 + new Ai1SessionMessageDO().setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole()).setContent("好的"));
  84 +
  85 + // 调用
  86 + String memory = memoryTool.generate(buildAgent(), " 旧记忆 ", messages, 500);
  87 + // 断言:单轮会话,不带历史、工具、RAG,也不替换会话 Header
  88 + assertEquals("## 用户画像\n- 程序员", memory);
  89 + ArgumentCaptor<String> systemPromptCaptor = ArgumentCaptor.forClass(String.class);
  90 + ArgumentCaptor<String> userPromptCaptor = ArgumentCaptor.forClass(String.class);
  91 + verify(llmChatTool).chat(any(), systemPromptCaptor.capture(), eq(List.of()), userPromptCaptor.capture(),
  92 + eq(List.of()), eq(List.of()), isNull(), isNull(), isNull());
  93 + assertTrue(systemPromptCaptor.getValue().endsWith("总长度不超过 500 字。"));
  94 + assertEquals("【历史记忆】\n旧记忆\n\n【最新对话】\n用户:我是程序员\n助手:好的\n", userPromptCaptor.getValue());
  95 + }
  96 +
  97 + @Test
  98 + public void testGenerate_normalizeSections() {
  99 + // mock 方法:标题与内容压成一行
  100 + mockChat("##用户画像-后端工程师##偏好习惯-简洁回答");
  101 +
  102 + // 调用
  103 + String memory = memoryTool.generate(buildAgent(), null, List.of(), 2000);
  104 + // 断言
  105 + assertEquals("## 用户画像\n- 后端工程师\n\n## 偏好习惯\n- 简洁回答", memory);
  106 + }
  107 +
  108 + @Test
  109 + public void testGenerate_defaultMaxLength() {
  110 + // mock 方法
  111 + mockChat("用户喜欢😀表情");
  112 +
  113 + // 调用:未配置长度上限时使用默认值
  114 + String memory = memoryTool.generate(buildAgent(), null, List.of(), 0);
  115 + // 断言
  116 + assertEquals("用户喜欢😀表情", memory);
  117 + ArgumentCaptor<String> systemPromptCaptor = ArgumentCaptor.forClass(String.class);
  118 + verify(llmChatTool).chat(any(), systemPromptCaptor.capture(), anyList(), anyString(), anyList(), anyList(),
  119 + isNull(), isNull(), isNull());
  120 + assertTrue(systemPromptCaptor.getValue().endsWith("总长度不超过 2000 字。"));
  121 + // 调用,并断言:按长度上限截断
  122 + assertEquals("用户喜欢", memoryTool.generate(buildAgent(), null, List.of(), 5));
  123 + }
  124 +
  125 + @Test
  126 + public void testGenerate_empty() {
  127 + // mock 方法
  128 + mockChat(" ");
  129 +
  130 + // 调用,并断言:模型无输出时返回空,由调用方跳过写入
  131 + assertTrue(memoryTool.generate(buildAgent(), null, List.of(), 2000).isBlank());
  132 + }
  133 +
  134 + // ========== mock 方法 ==========
  135 +
  136 + private void mockChat(String content) {
  137 + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO()
  138 + .setModelType(Ai1ModelTypeEnum.CHAT.getType()));
  139 + when(llmChatTool.chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), isNull(), isNull(), isNull()))
  140 + .thenReturn(new Ai1LlmChatTool.ChatText(content, ""));
  141 + }
  142 +
  143 + // ========== 测试数据 ==========
  144 +
  145 + private static Ai1AgentDO buildAgent() {
  146 + return new Ai1AgentDO().setId(1L).setProviderId(1L).setModelId(2L);
  147 + }
  148 +
  149 +}
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamToolTest.java
@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.ai1.harness.session; @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.ai1.harness.session;
3 import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; 3 import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
4 import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; 4 import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
5 import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; 5 import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO;
  6 +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO;
6 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; 7 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO;
7 import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; 8 import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum;
8 import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; 9 import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum;
@@ -10,7 +11,9 @@ import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; @@ -10,7 +11,9 @@ import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum;
10 import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; 11 import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties;
11 import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool; 12 import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool;
12 import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory; 13 import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory;
  14 +import cn.iocoder.yudao.module.ai1.harness.memory.Ai1MemoryTool;
13 import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; 15 import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool;
  16 +import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillShellTracker;
14 import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; 17 import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory;
15 import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; 18 import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService;
16 import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; 19 import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService;
@@ -20,29 +23,52 @@ import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService; @@ -20,29 +23,52 @@ import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService;
20 import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; 23 import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService;
21 import org.junit.jupiter.api.BeforeEach; 24 import org.junit.jupiter.api.BeforeEach;
22 import org.junit.jupiter.api.Test; 25 import org.junit.jupiter.api.Test;
  26 +import org.junit.jupiter.api.Timeout;
  27 +import org.junit.jupiter.api.condition.EnabledOnOs;
  28 +import org.junit.jupiter.api.condition.OS;
  29 +import org.junit.jupiter.api.io.TempDir;
23 import org.junit.jupiter.params.ParameterizedTest; 30 import org.junit.jupiter.params.ParameterizedTest;
24 import org.junit.jupiter.params.provider.ValueSource; 31 import org.junit.jupiter.params.provider.ValueSource;
25 import org.mockito.ArgumentCaptor; 32 import org.mockito.ArgumentCaptor;
26 import org.mockito.InjectMocks; 33 import org.mockito.InjectMocks;
27 import org.mockito.Mock; 34 import org.mockito.Mock;
28 import org.mockito.Spy; 35 import org.mockito.Spy;
  36 +import org.redisson.api.RBucket;
29 import org.redisson.api.RLock; 37 import org.redisson.api.RLock;
30 import org.redisson.api.RStream; 38 import org.redisson.api.RStream;
  39 +import org.redisson.api.RTopic;
31 import org.redisson.api.RedissonClient; 40 import org.redisson.api.RedissonClient;
32 import org.redisson.api.stream.StreamAddArgs; 41 import org.redisson.api.stream.StreamAddArgs;
  42 +import org.redisson.api.stream.StreamCreateGroupArgs;
33 import org.redisson.api.stream.StreamMessageId; 43 import org.redisson.api.stream.StreamMessageId;
34 import org.redisson.api.stream.StreamRangeArgs; 44 import org.redisson.api.stream.StreamRangeArgs;
35 import org.redisson.api.stream.StreamReadArgs; 45 import org.redisson.api.stream.StreamReadArgs;
  46 +import org.redisson.api.stream.StreamReadGroupArgs;
  47 +import org.redisson.client.RedisException;
36 import org.redisson.client.codec.Codec; 48 import org.redisson.client.codec.Codec;
  49 +import org.springaicommunity.agent.common.exec.ExecBackend;
  50 +import org.springaicommunity.agent.common.exec.ExecHandle;
  51 +import org.springaicommunity.agent.common.exec.ExecSpec;
  52 +import org.springaicommunity.agent.exec.LocalExecBackend;
37 import org.springframework.test.util.ReflectionTestUtils; 53 import org.springframework.test.util.ReflectionTestUtils;
38 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; 54 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
39 55
40 import java.io.IOException; 56 import java.io.IOException;
  57 +import java.nio.file.Files;
  58 +import java.nio.file.Path;
  59 +import java.time.Duration;
  60 +import java.time.LocalDateTime;
41 import java.util.ArrayList; 61 import java.util.ArrayList;
42 import java.util.HashMap; 62 import java.util.HashMap;
43 import java.util.LinkedHashMap; 63 import java.util.LinkedHashMap;
44 import java.util.List; 64 import java.util.List;
45 import java.util.Map; 65 import java.util.Map;
  66 +import java.util.Optional;
  67 +import java.util.Set;
  68 +import java.util.concurrent.CancellationException;
  69 +import java.util.concurrent.CountDownLatch;
  70 +import java.util.concurrent.TimeUnit;
  71 +import java.util.concurrent.atomic.AtomicBoolean;
46 import java.util.concurrent.atomic.AtomicReference; 72 import java.util.concurrent.atomic.AtomicReference;
47 import java.util.function.Consumer; 73 import java.util.function.Consumer;
48 74
@@ -74,6 +100,10 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { @@ -74,6 +100,10 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest {
74 private RStream<String, String> resultStream; 100 private RStream<String, String> resultStream;
75 @Mock 101 @Mock
76 private RLock taskLock; 102 private RLock taskLock;
  103 + @Mock
  104 + private RBucket<String> stopBucket;
  105 + @Mock
  106 + private RTopic stopTopic;
77 @Spy 107 @Spy
78 private YudaoAi1Properties ai1Properties = new YudaoAi1Properties(); 108 private YudaoAi1Properties ai1Properties = new YudaoAi1Properties();
79 109
@@ -95,15 +125,24 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { @@ -95,15 +125,24 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest {
95 private Ai1SkillToolFactory skillToolFactory; 125 private Ai1SkillToolFactory skillToolFactory;
96 @Mock 126 @Mock
97 private Ai1RagTool ragTool; 127 private Ai1RagTool ragTool;
  128 + @Mock
  129 + private Ai1MemoryTool memoryTool;
  130 + @Mock
  131 + private Ai1SkillShellTracker shellTracker;
98 132
99 @BeforeEach 133 @BeforeEach
100 public void before() { 134 public void before() {
  135 + // 直接调用 handleTask 的用例按运行中处理,与 start 之后一致
  136 + ReflectionTestUtils.setField(sessionStreamTool, "running", true);
101 // mock redissonClient 的方法(部分用例不访问 Redis) 137 // mock redissonClient 的方法(部分用例不访问 Redis)
102 lenient().doReturn(taskStream).when(redissonClient).getStream(eq("ai1:session:tasks"), any(Codec.class)); 138 lenient().doReturn(taskStream).when(redissonClient).getStream(eq("ai1:session:tasks"), any(Codec.class));
103 lenient().doReturn(resultStream).when(redissonClient).getStream(eq("ai1:session:result:" + MESSAGE_ID), any(Codec.class)); 139 lenient().doReturn(resultStream).when(redissonClient).getStream(eq("ai1:session:result:" + MESSAGE_ID), any(Codec.class));
104 lenient().when(redissonClient.getLock("ai1:session:task-lock:" + MESSAGE_ID)).thenReturn(taskLock); 140 lenient().when(redissonClient.getLock("ai1:session:task-lock:" + MESSAGE_ID)).thenReturn(taskLock);
105 lenient().when(taskLock.tryLock()).thenReturn(true); 141 lenient().when(taskLock.tryLock()).thenReturn(true);
106 lenient().when(taskLock.isHeldByThread(anyLong())).thenReturn(true); 142 lenient().when(taskLock.isHeldByThread(anyLong())).thenReturn(true);
  143 + lenient().doReturn(stopBucket).when(redissonClient).getBucket(eq("ai1:session:stop:" + MESSAGE_ID), any(Codec.class));
  144 + lenient().when(redissonClient.getTopic(eq("ai1:session:stop"), any(Codec.class))).thenReturn(stopTopic);
  145 + lenient().when(skillToolFactory.newShellTracker(AGENT_ID)).thenReturn(shellTracker);
107 lenient().when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(new Ai1SessionMessageDO() 146 lenient().when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(new Ai1SessionMessageDO()
108 .setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole()) 147 .setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole())
109 .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus())); 148 .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus()));
@@ -256,11 +295,14 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { @@ -256,11 +295,14 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest {
256 295
257 // 调用 296 // 调用
258 sessionStreamTool.handleTask(RECORD_ID, buildTaskFields()); 297 sessionStreamTool.handleTask(RECORD_ID, buildTaskFields());
259 - // 断言 298 + // 断言:正文追加固定失败提示,不拼接原始异常
260 ArgumentCaptor<Ai1SessionMessageDO> messageCaptor = ArgumentCaptor.forClass(Ai1SessionMessageDO.class); 299 ArgumentCaptor<Ai1SessionMessageDO> messageCaptor = ArgumentCaptor.forClass(Ai1SessionMessageDO.class);
261 verify(sessionMessageService).updateSessionMessage(messageCaptor.capture()); 300 verify(sessionMessageService).updateSessionMessage(messageCaptor.capture());
262 assertEquals(Ai1SessionMessageStatusEnum.FAILED.getStatus(), messageCaptor.getValue().getStatus()); 301 assertEquals(Ai1SessionMessageStatusEnum.FAILED.getStatus(), messageCaptor.getValue().getStatus());
  302 + assertEquals("\n\n> 本条回复生成失败", messageCaptor.getValue().getContent());
263 assertEquals(List.of("ping", "error", "done"), captureResultTypes()); 303 assertEquals(List.of("ping", "error", "done"), captureResultTypes());
  304 + assertEquals(List.of("", "本条回复生成失败", ""), captureResultData());
  305 + verify(shellTracker).cancel();
264 } 306 }
265 307
266 @Test 308 @Test
@@ -310,7 +352,7 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { @@ -310,7 +352,7 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest {
310 sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), reclaimed); 352 sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), reclaimed);
311 // 断言 353 // 断言
312 verifyNoInteractions(llmChatTool, agentService); 354 verifyNoInteractions(llmChatTool, agentService);
313 - verify(sessionMessageService).updateSessionMessage(argThat(message -> "第一段第二段".equals(message.getContent()) 355 + verify(sessionMessageService).updateSessionMessage(argThat(message -> "第一段第二段\n\n> 生成任务已中断,请重新发送".equals(message.getContent())
314 && "思考".equals(message.getReasoning()) 356 && "思考".equals(message.getReasoning())
315 && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus()))); 357 && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus())));
316 assertEquals(List.of("error", "done"), captureResultTypes()); 358 assertEquals(List.of("error", "done"), captureResultTypes());
@@ -326,7 +368,7 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { @@ -326,7 +368,7 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest {
326 sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true); 368 sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true);
327 // 断言 369 // 断言
328 verifyNoInteractions(llmChatTool); 370 verifyNoInteractions(llmChatTool);
329 - verify(sessionMessageService).updateSessionMessage(argThat(message -> "".equals(message.getContent()) 371 + verify(sessionMessageService).updateSessionMessage(argThat(message -> "\n\n> 生成任务已中断,请重新发送".equals(message.getContent())
330 && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus()))); 372 && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus())));
331 assertEquals(List.of("error", "done"), captureResultTypes()); 373 assertEquals(List.of("error", "done"), captureResultTypes());
332 } 374 }
@@ -437,6 +479,573 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { @@ -437,6 +479,573 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest {
437 verify(taskStream).ack("ai1-session-workers", RECORD_ID); 479 verify(taskStream).ack("ai1-session-workers", RECORD_ID);
438 } 480 }
439 481
  482 + @Test
  483 + public void testHandleTask_terminatedWithoutTerminal() {
  484 + // mock 方法:人工终止已回填,done 尚未写入
  485 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(new Ai1SessionMessageDO()
  486 + .setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole())
  487 + .setStatus(Ai1SessionMessageStatusEnum.FAILED.getStatus()).setContent("已生成\n\n> 本条回复已被人工终止"));
  488 + when(resultStream.rangeReversed(StreamRangeArgs.startId(StreamMessageId.MAX).endId(StreamMessageId.MIN).count(1))).thenReturn(Map.of());
  489 +
  490 + // 调用
  491 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true);
  492 + // 断言:人工终止不补写 error
  493 + verifyNoInteractions(llmChatTool);
  494 + verify(sessionMessageService, never()).updateSessionMessage(any());
  495 + assertEquals(List.of("done"), captureResultTypes());
  496 + verify(taskStream).ack("ai1-session-workers", RECORD_ID);
  497 + }
  498 +
  499 + @Test
  500 + public void testHandleTask_generateFailedWithoutTerminal() {
  501 + // mock 方法:生成失败已回填,error 尚未写入
  502 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(new Ai1SessionMessageDO()
  503 + .setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole())
  504 + .setStatus(Ai1SessionMessageStatusEnum.FAILED.getStatus()).setContent("\n\n> 本条回复生成失败"));
  505 + when(resultStream.rangeReversed(StreamRangeArgs.startId(StreamMessageId.MAX).endId(StreamMessageId.MIN).count(1))).thenReturn(Map.of());
  506 +
  507 + // 调用
  508 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true);
  509 + // 断言:按已回填的固定提示补写,不改为中断
  510 + assertEquals(List.of("error", "done"), captureResultTypes());
  511 + assertEquals(List.of("本条回复生成失败", ""), captureResultData());
  512 + verify(taskStream).ack("ai1-session-workers", RECORD_ID);
  513 + }
  514 +
  515 + @Test
  516 + public void testHandleTask_failedBeforeStart() {
  517 + // mock 方法:投递超时后补偿已标记失败,worker 才开始处理
  518 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(new Ai1SessionMessageDO()
  519 + .setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole())
  520 + .setStatus(Ai1SessionMessageStatusEnum.FAILED.getStatus()).setContent(""));
  521 + when(resultStream.rangeReversed(StreamRangeArgs.startId(StreamMessageId.MAX).endId(StreamMessageId.MIN).count(1))).thenReturn(Map.of());
  522 +
  523 + // 调用
  524 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields());
  525 + // 断言:不生成、不改写消息,补齐终态后确认
  526 + verifyNoInteractions(agentService, llmChatTool, mcpToolFactory, skillToolFactory);
  527 + verify(sessionMessageService, never()).updateSessionMessage(any());
  528 + assertEquals(List.of("error", "done"), captureResultTypes());
  529 + verify(taskStream).ack("ai1-session-workers", RECORD_ID);
  530 + }
  531 +
  532 + @Test
  533 + public void testHandleTask_injectMemory() {
  534 + // mock 方法
  535 + mockGenerate(new Ai1AgentDO().setId(AGENT_ID).setName("客服").setProviderId(1L).setModelId(2L).setMemoryEnabled(true));
  536 + when(sessionService.getSession(SESSION_ID)).thenReturn(new Ai1SessionDO().setId(SESSION_ID).setUserId(100L));
  537 + when(memoryTool.loadMemory(AGENT_ID, 100L)).thenReturn("## 用户画像\n- 程序员");
  538 + when(memoryTool.mergePrompt("你是 客服 的智能助手。", "## 用户画像\n- 程序员")).thenReturn("合并后的系统指令");
  539 + when(llmChatTool.chat(any(), eq("合并后的系统指令"), anyList(), eq("你好"), anyList(), anyList(), eq(SESSION_ID), any(), any()))
  540 + .thenReturn(new Ai1LlmChatTool.ChatText("您好", ""));
  541 +
  542 + // 调用
  543 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields());
  544 + // 断言
  545 + verify(sessionMessageService).updateSessionMessage(argThat(message ->
  546 + Ai1SessionMessageStatusEnum.SUCCESS.getStatus().equals(message.getStatus())));
  547 + verify(skillToolFactory).buildTools(any(), same(shellTracker));
  548 + }
  549 +
  550 + @Test
  551 + public void testHandleTask_injectMemoryFailed() {
  552 + // mock 方法:记忆查询失败时降级为原系统指令
  553 + mockGenerate(new Ai1AgentDO().setId(AGENT_ID).setName("客服").setProviderId(1L).setModelId(2L).setMemoryEnabled(true));
  554 + when(sessionService.getSession(SESSION_ID)).thenThrow(new IllegalStateException("数据库不可用"));
  555 + when(llmChatTool.chat(any(), eq("你是 客服 的智能助手。"), anyList(), eq("你好"), anyList(), anyList(), eq(SESSION_ID), any(), any()))
  556 + .thenReturn(new Ai1LlmChatTool.ChatText("您好", ""));
  557 +
  558 + // 调用
  559 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields());
  560 + // 断言
  561 + verifyNoInteractions(memoryTool);
  562 + verify(sessionMessageService).updateSessionMessage(argThat(message ->
  563 + Ai1SessionMessageStatusEnum.SUCCESS.getStatus().equals(message.getStatus())));
  564 + }
  565 +
  566 + @Test
  567 + public void testHandleTask_stoppedWhileQueued() {
  568 + // mock 排队期间已写入停止意图
  569 + when(stopBucket.isExists()).thenReturn(true);
  570 +
  571 + // 调用
  572 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields());
  573 + // 断言:不调用模型,人工终止只写 done,并清除中断标志
  574 + verifyNoInteractions(agentService, llmChatTool);
  575 + verify(sessionMessageService).updateSessionMessage(argThat(message -> "\n\n> 本条回复已被人工终止".equals(message.getContent())
  576 + && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus())));
  577 + assertEquals(List.of("done"), captureResultTypes());
  578 + verify(taskStream).ack("ai1-session-workers", RECORD_ID);
  579 + assertFalse(Thread.currentThread().isInterrupted());
  580 + }
  581 +
  582 + @Test
  583 + public void testHandleTask_stoppedDuringPreparation() {
  584 + // mock 方法:装配工具期间收到停止通知
  585 + mockGenerate(new Ai1AgentDO().setId(AGENT_ID).setName("客服").setProviderId(1L).setModelId(2L));
  586 + when(mcpToolFactory.buildTools(any())).thenAnswer(invocation -> {
  587 + ReflectionTestUtils.invokeMethod(sessionStreamTool, "cancelRunContext", MESSAGE_ID);
  588 + return List.of();
  589 + });
  590 +
  591 + // 调用
  592 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields());
  593 + // 断言:不再调用模型
  594 + verifyNoInteractions(llmChatTool);
  595 + verify(sessionMessageService).updateSessionMessage(argThat(message -> "\n\n> 本条回复已被人工终止".equals(message.getContent())
  596 + && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus())));
  597 + assertEquals(List.of("ping", "done"), captureResultTypes());
  598 + verify(shellTracker, atLeastOnce()).cancel();
  599 + verify(taskStream).ack("ai1-session-workers", RECORD_ID);
  600 + assertFalse(Thread.currentThread().isInterrupted());
  601 + }
  602 +
  603 + @Test
  604 + public void testHandleTask_stoppedDuringGenerate() {
  605 + // mock 方法:输出第一段后停止,之后的增量不再写入
  606 + mockGenerate(new Ai1AgentDO().setId(AGENT_ID).setName("客服").setProviderId(1L).setModelId(2L));
  607 + when(llmChatTool.chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any()))
  608 + .thenAnswer(invocation -> {
  609 + Consumer<String> onContent = invocation.getArgument(8);
  610 + onContent.accept("第一段");
  611 + ReflectionTestUtils.invokeMethod(sessionStreamTool, "cancelRunContext", MESSAGE_ID);
  612 + onContent.accept("第二段");
  613 + return new Ai1LlmChatTool.ChatText("第一段第二段", "");
  614 + });
  615 +
  616 + // 调用
  617 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields());
  618 + // 断言:保留已生成内容,只提交一个终态
  619 + verify(sessionMessageService).updateSessionMessage(argThat(message -> "第一段\n\n> 本条回复已被人工终止".equals(message.getContent())
  620 + && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus())));
  621 + assertEquals(List.of("ping", "message", "done"), captureResultTypes());
  622 + assertFalse(Thread.currentThread().isInterrupted());
  623 + }
  624 +
  625 + @Test
  626 + public void testStop_interruptRunning() {
  627 + // mock 方法:生成期间节点停机
  628 + mockGenerate(new Ai1AgentDO().setId(AGENT_ID).setName("客服").setProviderId(1L).setModelId(2L));
  629 + when(llmChatTool.chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any()))
  630 + .thenAnswer(invocation -> {
  631 + sessionStreamTool.stop();
  632 + return new Ai1LlmChatTool.ChatText("部分", "");
  633 + });
  634 +
  635 + // 调用
  636 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields());
  637 + // 断言:保留内容并标记中断,另写 error,确认后不会被重新生成
  638 + verify(sessionMessageService).updateSessionMessage(argThat(message -> "部分\n\n> 生成任务已中断,请重新发送".equals(message.getContent())
  639 + && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus())));
  640 + assertEquals(List.of("ping", "error", "done"), captureResultTypes());
  641 + verify(taskStream).ack("ai1-session-workers", RECORD_ID);
  642 + }
  643 +
  644 + @Test
  645 + public void testHandleTask_stoppedBeforeRegister() {
  646 + // mock 方法:查询消息后、登记上下文前节点停机
  647 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenAnswer(invocation -> {
  648 + sessionStreamTool.stop();
  649 + return new Ai1SessionMessageDO().setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole())
  650 + .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus());
  651 + });
  652 +
  653 + // 调用
  654 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields());
  655 + // 断言:不调用模型、不装配工具,按中断收尾并确认
  656 + verifyNoInteractions(agentService, llmChatTool, mcpToolFactory);
  657 + verify(stopBucket, never()).isExists();
  658 + verify(sessionMessageService).updateSessionMessage(argThat(message -> "\n\n> 生成任务已中断,请重新发送".equals(message.getContent())
  659 + && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus())));
  660 + assertEquals(List.of("error", "done"), captureResultTypes());
  661 + verify(taskStream).ack("ai1-session-workers", RECORD_ID);
  662 + assertTrue(getRunContexts().isEmpty());
  663 + assertFalse(Thread.currentThread().isInterrupted());
  664 + }
  665 +
  666 + @Test
  667 + public void testConsumeLoop_stoppedAfterRead() {
  668 + // mock 方法:阻塞读取返回任务时,节点已停机
  669 + when(taskStream.readGroup(anyString(), anyString(), any(StreamReadGroupArgs.class))).thenAnswer(invocation -> {
  670 + sessionStreamTool.stop();
  671 + return Map.of(RECORD_ID, buildTaskFields());
  672 + });
  673 +
  674 + // 调用
  675 + ReflectionTestUtils.invokeMethod(sessionStreamTool, "consumeLoop", "worker-test");
  676 + // 断言:不调用模型,按中断收尾后退出循环
  677 + verifyNoInteractions(agentService, llmChatTool);
  678 + assertEquals(List.of("error", "done"), captureResultTypes());
  679 + verify(taskStream).ack("ai1-session-workers", RECORD_ID);
  680 + assertFalse(Thread.currentThread().isInterrupted());
  681 + }
  682 +
  683 + @Test
  684 + public void testCancel_queued() {
  685 + // mock 方法:会话中有一条生成中的助手消息
  686 + when(sessionMessageService.getGeneratingAssistantMessageIdList(SESSION_ID)).thenReturn(List.of(MESSAGE_ID));
  687 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(new Ai1SessionMessageDO().setId(MESSAGE_ID)
  688 + .setSessionId(SESSION_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole())
  689 + .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus()));
  690 + when(resultStream.range(StreamRangeArgs.startId(StreamMessageId.MIN).endId(StreamMessageId.MAX))).thenReturn(
  691 + Map.of(RECORD_ID, Map.of("type", "message", "data", "已生成")));
  692 + when(taskLock.isHeldByCurrentThread()).thenReturn(true);
  693 +
  694 + // 调用
  695 + sessionStreamTool.cancel(SESSION_ID);
  696 + // 断言:写入停止意图并广播
  697 + verify(stopBucket).set("1", Duration.ofSeconds(3600));
  698 + verify(stopTopic).publish(String.valueOf(MESSAGE_ID));
  699 + verify(redissonClient, times(1)).getBucket(anyString(), any(Codec.class));
  700 + // 断言:没有 worker 执行,直接收尾
  701 + verify(sessionMessageService).updateSessionMessage(argThat(message -> "已生成\n\n> 本条回复已被人工终止".equals(message.getContent())
  702 + && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus())));
  703 + verify(sessionService).touchSession(SESSION_ID);
  704 + assertEquals(List.of("done"), captureResultTypes());
  705 + verify(taskLock).unlock();
  706 + verify(sessionMessageService, never()).getSessionMessageListBySessionId(any());
  707 + }
  708 +
  709 + @Test
  710 + public void testCancel_running() {
  711 + // mock 方法:任务正在其他 worker 执行
  712 + when(sessionMessageService.getGeneratingAssistantMessageIdList(SESSION_ID)).thenReturn(List.of(MESSAGE_ID));
  713 + when(taskLock.tryLock()).thenReturn(false);
  714 +
  715 + // 调用
  716 + sessionStreamTool.cancel(SESSION_ID);
  717 + // 断言:只写意图与通知,由执行节点收尾
  718 + verify(stopBucket).set("1", Duration.ofSeconds(3600));
  719 + verify(stopTopic).publish(String.valueOf(MESSAGE_ID));
  720 + verify(sessionMessageService, never()).updateSessionMessage(any());
  721 + verify(resultStream, never()).add(any());
  722 + }
  723 +
  724 + @Test
  725 + public void testCancel_noGenerating() {
  726 + // mock 方法
  727 + when(sessionMessageService.getGeneratingAssistantMessageIdList(SESSION_ID)).thenReturn(List.of());
  728 +
  729 + // 调用
  730 + sessionStreamTool.cancel(SESSION_ID);
  731 + // 断言:没有生成中的消息时无副作用
  732 + verifyNoInteractions(stopBucket, stopTopic, taskLock);
  733 + }
  734 +
  735 + @Test
  736 + @Timeout(30)
  737 + public void testCheckStopRequests() throws Exception {
  738 + // 准备参数
  739 + Ai1SessionStreamTool.RunContext context = new Ai1SessionStreamTool.RunContext(new Thread(() -> {}), shellTracker, getReapers());
  740 + Map<Long, Ai1SessionStreamTool.RunContext> runContexts = getRunContexts();
  741 + runContexts.put(MESSAGE_ID, context);
  742 + // mock 订阅断开期间写入的停止意图;进程回收一直阻塞
  743 + when(stopBucket.isExists()).thenReturn(true);
  744 + CountDownLatch killing = new CountDownLatch(1);
  745 + CountDownLatch releaseKill = new CountDownLatch(1);
  746 + doAnswer(invocation -> {
  747 + killing.countDown();
  748 + releaseKill.await(10, TimeUnit.SECONDS);
  749 + return null;
  750 + }).when(shellTracker).cancel();
  751 +
  752 + try {
  753 + // 调用:回收阻塞时检查线程仍立即返回
  754 + ReflectionTestUtils.invokeMethod(sessionStreamTool, "checkStopRequests");
  755 + // 断言:回收在独立线程执行
  756 + assertTrue(killing.await(5, TimeUnit.SECONDS));
  757 + assertEquals(Ai1SessionStreamTool.TERMINATED_NOTICE, context.finish());
  758 + } finally {
  759 + releaseKill.countDown();
  760 + awaitReapers();
  761 + }
  762 + }
  763 +
  764 + @Test
  765 + @Timeout(30)
  766 + public void testStop_slowReapNotBlockOthers() throws Exception {
  767 + // 准备参数:两个执行中的生成,第一个的进程回收一直阻塞
  768 + Ai1SkillShellTracker slowTracker = mock(Ai1SkillShellTracker.class);
  769 + Ai1SkillShellTracker fastTracker = mock(Ai1SkillShellTracker.class);
  770 + CountDownLatch killing = new CountDownLatch(1);
  771 + CountDownLatch releaseKill = new CountDownLatch(1);
  772 + CountDownLatch fastReaped = new CountDownLatch(1);
  773 + doAnswer(invocation -> {
  774 + killing.countDown();
  775 + releaseKill.await(10, TimeUnit.SECONDS);
  776 + return null;
  777 + }).when(slowTracker).cancel();
  778 + doAnswer(invocation -> {
  779 + fastReaped.countDown();
  780 + return null;
  781 + }).when(fastTracker).cancel();
  782 + CountDownLatch releaseWorker = new CountDownLatch(1);
  783 + CountDownLatch interrupted = new CountDownLatch(2);
  784 + Thread worker1 = startWorker(releaseWorker, interrupted);
  785 + Thread worker2 = startWorker(releaseWorker, interrupted);
  786 + getRunContexts().put(1L, new Ai1SessionStreamTool.RunContext(worker1, slowTracker, getReapers()));
  787 + getRunContexts().put(2L, new Ai1SessionStreamTool.RunContext(worker2, fastTracker, getReapers()));
  788 + Thread stopping = new Thread(sessionStreamTool::stop);
  789 +
  790 + try {
  791 + // 调用
  792 + stopping.start();
  793 + // 断言:慢回收进行中,两个生成都被中断,另一个回收不受影响;stop 等待回收结束
  794 + assertTrue(killing.await(5, TimeUnit.SECONDS));
  795 + assertTrue(interrupted.await(5, TimeUnit.SECONDS));
  796 + assertTrue(fastReaped.await(5, TimeUnit.SECONDS));
  797 + assertTrue(stopping.isAlive());
  798 + } finally {
  799 + releaseKill.countDown();
  800 + releaseWorker.countDown();
  801 + stopping.join(10_000);
  802 + worker1.join(5000);
  803 + worker2.join(5000);
  804 + }
  805 + assertFalse(stopping.isAlive());
  806 + }
  807 +
  808 + @Test
  809 + @Timeout(30)
  810 + @EnabledOnOs({OS.MAC, OS.LINUX})
  811 + public void testRunContext_cancelReapsWhileWorkerIgnoresInterrupt(@TempDir Path workDir) throws Exception {
  812 + // 准备参数:worker 吞掉中断,模拟模型或工具库不响应中断;Shell 写入自身 PID 后 exec 为 sleep
  813 + CountDownLatch releaseWorker = new CountDownLatch(1);
  814 + Thread worker = new Thread(() -> {
  815 + while (releaseWorker.getCount() > 0) {
  816 + try {
  817 + releaseWorker.await();
  818 + } catch (InterruptedException ignored) {
  819 + // 忽略中断,继续等待
  820 + }
  821 + }
  822 + });
  823 + worker.start();
  824 + Ai1SkillShellTracker tracker = new Ai1SkillShellTracker(LocalExecBackend.builder().workingDirectory(workDir).build());
  825 + Ai1SessionStreamTool.RunContext context = new Ai1SessionStreamTool.RunContext(worker, tracker, getReapers());
  826 + ExecHandle handle = tracker.start(ExecSpec.of("echo $$ > pid.tmp && mv pid.tmp pid && exec sleep 60"));
  827 + ProcessHandle process = null;
  828 + try {
  829 + process = awaitDescendant(workDir.resolve("pid"));
  830 + assertTrue(process.isAlive());
  831 +
  832 + // 调用
  833 + context.cancel(Ai1SessionStreamTool.TERMINATED_NOTICE);
  834 + // 断言:worker 仍未返回,在册的后台进程已被回收
  835 + process.onExit().get(10, TimeUnit.SECONDS);
  836 + assertFalse(process.isAlive());
  837 + assertFalse(handle.isAlive());
  838 + assertTrue(worker.isAlive());
  839 + } finally {
  840 + // 只清理本用例启动的线程、进程
  841 + releaseWorker.countDown();
  842 + worker.join(5000);
  843 + awaitReapers();
  844 + if (process != null && process.isAlive()) {
  845 + process.destroyForcibly();
  846 + }
  847 + if (handle.isAlive()) {
  848 + handle.kill();
  849 + }
  850 + }
  851 + }
  852 +
  853 + @Test
  854 + public void testRunContext_cancelThenFinish() throws Exception {
  855 + // 准备参数
  856 + Ai1SessionStreamTool.RunContext context = new Ai1SessionStreamTool.RunContext(Thread.currentThread(), shellTracker, getReapers());
  857 +
  858 + // 调用:重复停止只记录第一次
  859 + context.cancel(Ai1SessionStreamTool.TERMINATED_NOTICE);
  860 + context.cancel(Ai1SessionStreamTool.INTERRUPTED_MESSAGE);
  861 + // 断言
  862 + assertTrue(Thread.currentThread().isInterrupted());
  863 + assertThrows(CancellationException.class, context::checkActive);
  864 + assertEquals(Ai1SessionStreamTool.TERMINATED_NOTICE, context.finish());
  865 + assertFalse(Thread.currentThread().isInterrupted());
  866 + // 断言:只启动一次回收
  867 + awaitReapers();
  868 + verify(shellTracker, times(1)).cancel();
  869 + }
  870 +
  871 + @Test
  872 + public void testRunContext_lateCancel() {
  873 + // 准备参数
  874 + Ai1SessionStreamTool.RunContext context = new Ai1SessionStreamTool.RunContext(Thread.currentThread(), shellTracker, getReapers());
  875 + assertNull(context.finish());
  876 +
  877 + // 调用:结束后迟到的停止
  878 + context.cancel(Ai1SessionStreamTool.TERMINATED_NOTICE);
  879 + // 断言:不中断 worker 处理下一个任务,也不启动回收
  880 + assertFalse(Thread.currentThread().isInterrupted());
  881 + assertTrue(getReapers().isEmpty());
  882 + verifyNoInteractions(shellTracker);
  883 + }
  884 +
  885 + @Test
  886 + @Timeout(30)
  887 + public void testStop_waitReaperAfterWorkerExit() throws Exception {
  888 + // mock 方法:生成中启动后台进程,人工停止后进程回收一直阻塞
  889 + ExecBackend backend = mock(ExecBackend.class);
  890 + ExecHandle handle = mock(ExecHandle.class);
  891 + ExecSpec spec = ExecSpec.of("sleep 60");
  892 + when(backend.start(spec)).thenReturn(handle);
  893 + CountDownLatch killing = new CountDownLatch(1);
  894 + CountDownLatch releaseKill = new CountDownLatch(1);
  895 + CountDownLatch killed = new CountDownLatch(1);
  896 + doAnswer(invocation -> {
  897 + killing.countDown();
  898 + releaseKill.await(10, TimeUnit.SECONDS);
  899 + killed.countDown();
  900 + return null;
  901 + }).when(handle).kill();
  902 + Ai1SkillShellTracker tracker = new Ai1SkillShellTracker(backend);
  903 + when(skillToolFactory.newShellTracker(AGENT_ID)).thenReturn(tracker);
  904 + mockGenerate(new Ai1AgentDO().setId(AGENT_ID).setName("客服").setProviderId(1L).setModelId(2L));
  905 + when(llmChatTool.chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any()))
  906 + .thenAnswer(invocation -> {
  907 + tracker.start(spec);
  908 + ReflectionTestUtils.invokeMethod(sessionStreamTool, "cancelRunContext", MESSAGE_ID);
  909 + // 回收线程认领进程后再返回;worker 线程已被中断,用自旋等待
  910 + long deadline = System.currentTimeMillis() + 5000;
  911 + while (killing.getCount() > 0 && System.currentTimeMillis() < deadline) {
  912 + Thread.onSpinWait();
  913 + }
  914 + return new Ai1LlmChatTool.ChatText("部分", "");
  915 + });
  916 + Thread stopping = new Thread(sessionStreamTool::stop);
  917 +
  918 + try {
  919 + // 调用:worker 结束并移除上下文,回收仍阻塞
  920 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields());
  921 + assertTrue(killing.await(5, TimeUnit.SECONDS));
  922 + assertTrue(getRunContexts().isEmpty());
  923 + assertEquals(1, killed.getCount());
  924 + // 调用:节点停止
  925 + stopping.start();
  926 + // 断言:stop 仍等待已结束上下文的回收
  927 + stopping.join(1000);
  928 + assertTrue(stopping.isAlive());
  929 + } finally {
  930 + releaseKill.countDown();
  931 + stopping.join(10_000);
  932 + awaitReapers();
  933 + }
  934 + // 断言:回收完成后 stop 返回,worker 未重复回收
  935 + assertFalse(stopping.isAlive());
  936 + assertEquals(0, killed.getCount());
  937 + verify(handle, times(1)).kill();
  938 + assertTrue(getReapers().isEmpty());
  939 + }
  940 +
  941 + @Test
  942 + public void testConsumeLoop_noGroup() {
  943 + // mock 方法:任务队列被删除后消费组丢失,重建后退出循环
  944 + ReflectionTestUtils.setField(sessionStreamTool, "running", true);
  945 + when(taskStream.readGroup(anyString(), anyString(), any(StreamReadGroupArgs.class)))
  946 + .thenThrow(new RedisException("NOGROUP No such key 'ai1:session:tasks' or consumer group"));
  947 + doAnswer(invocation -> {
  948 + ReflectionTestUtils.setField(sessionStreamTool, "running", false);
  949 + return null;
  950 + }).when(taskStream).createGroup(any(StreamCreateGroupArgs.class));
  951 +
  952 + // 调用
  953 + ReflectionTestUtils.invokeMethod(sessionStreamTool, "consumeLoop", "worker-test");
  954 + // 断言
  955 + verify(taskStream).createGroup(any(StreamCreateGroupArgs.class));
  956 + }
  957 +
  958 + @Test
  959 + public void testFailStaleMessage_notStale() {
  960 + // 调用,并断言:未超时时不加锁,避免 worker 误判为在途
  961 + assertFalse(sessionStreamTool.failStaleMessage(buildGeneratingMessage(LocalDateTime.now())));
  962 + verifyNoInteractions(redissonClient, sessionMessageService);
  963 + }
  964 +
  965 + @Test
  966 + public void testFailStaleMessage_running() {
  967 + // mock 方法:仍有 worker 持有执行锁
  968 + when(taskLock.tryLock()).thenReturn(false);
  969 +
  970 + // 调用,并断言
  971 + assertFalse(sessionStreamTool.failStaleMessage(buildGeneratingMessage(LocalDateTime.now().minusMinutes(10))));
  972 + verify(sessionMessageService, never()).getSessionMessage(any());
  973 + verify(sessionMessageService, never()).updateSessionMessage(any());
  974 + verify(resultStream, never()).add(any());
  975 + }
  976 +
  977 + @Test
  978 + public void testFailStaleMessage_success() {
  979 + // mock 方法:任务记录已裁剪,没有 worker 执行
  980 + Ai1SessionMessageDO message = buildGeneratingMessage(LocalDateTime.now().minusMinutes(10));
  981 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(message);
  982 + when(resultStream.range(StreamRangeArgs.startId(StreamMessageId.MIN).endId(StreamMessageId.MAX))).thenReturn(
  983 + Map.of(RECORD_ID, Map.of("type", "message", "data", "已生成")));
  984 +
  985 + // 调用,并断言:与接管任务一致,保留部分结果并标记中断
  986 + assertTrue(sessionStreamTool.failStaleMessage(message));
  987 + verify(sessionMessageService).updateSessionMessage(argThat(updateObj -> "已生成\n\n> 生成任务已中断,请重新发送".equals(updateObj.getContent())
  988 + && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(updateObj.getStatus())));
  989 + verify(sessionService).touchSession(SESSION_ID);
  990 + assertEquals(List.of("error", "done"), captureResultTypes());
  991 + verify(taskLock).unlock();
  992 + verifyNoInteractions(llmChatTool);
  993 + }
  994 +
  995 + @Test
  996 + public void testFailStaleMessage_completedConcurrently() {
  997 + // mock 方法:加锁前 worker 已完成
  998 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(buildGeneratingMessage(LocalDateTime.now().minusMinutes(10))
  999 + .setStatus(Ai1SessionMessageStatusEnum.SUCCESS.getStatus()));
  1000 +
  1001 + // 调用,并断言:不再改写,归档可继续
  1002 + assertTrue(sessionStreamTool.failStaleMessage(buildGeneratingMessage(LocalDateTime.now().minusMinutes(10))));
  1003 + verify(sessionMessageService, never()).updateSessionMessage(any());
  1004 + verify(resultStream, never()).add(any());
  1005 + verify(taskLock).unlock();
  1006 + }
  1007 +
  1008 + @Test
  1009 + public void testFailStaleMessage_lockLostDuringPartialRead() {
  1010 + // mock 方法:读取部分结果期间执行锁失效,已被新持有者接管
  1011 + Ai1SessionMessageDO message = buildGeneratingMessage(LocalDateTime.now().minusMinutes(10));
  1012 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(message);
  1013 + AtomicBoolean owned = new AtomicBoolean(true);
  1014 + when(taskLock.isHeldByThread(anyLong())).thenAnswer(invocation -> owned.get());
  1015 + when(resultStream.range(StreamRangeArgs.startId(StreamMessageId.MIN).endId(StreamMessageId.MAX))).thenAnswer(invocation -> {
  1016 + owned.set(false);
  1017 + return Map.of(RECORD_ID, Map.of("type", "message", "data", "已生成"));
  1018 + });
  1019 +
  1020 + // 调用,并断言:不改动消息、结果流,也不释放新持有者的锁
  1021 + assertThrows(IllegalStateException.class, () -> sessionStreamTool.failStaleMessage(message));
  1022 + verify(sessionMessageService, never()).updateSessionMessage(any());
  1023 + verify(sessionService, never()).touchSession(any());
  1024 + verify(resultStream, never()).add(any());
  1025 + verify(taskLock, never()).unlock();
  1026 + }
  1027 +
  1028 + // ========== mock 方法 ==========
  1029 +
  1030 + private void mockGenerate(Ai1AgentDO agent) {
  1031 + when(agentService.validateAgentExists(AGENT_ID)).thenReturn(agent);
  1032 + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO().setModelType(Ai1ModelTypeEnum.CHAT.getType()));
  1033 + when(sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan(eq(SESSION_ID), eq(MESSAGE_ID), anyInt()))
  1034 + .thenReturn(new ArrayList<>());
  1035 + }
  1036 +
  1037 + private static Thread startWorker(CountDownLatch release, CountDownLatch interrupted) {
  1038 + Thread worker = new Thread(() -> {
  1039 + try {
  1040 + release.await();
  1041 + } catch (InterruptedException e) {
  1042 + interrupted.countDown();
  1043 + }
  1044 + });
  1045 + worker.start();
  1046 + return worker;
  1047 + }
  1048 +
440 // ========== 测试数据 ========== 1049 // ========== 测试数据 ==========
441 1050
442 private static Map<String, String> buildTaskFields() { 1051 private static Map<String, String> buildTaskFields() {
@@ -449,6 +1058,33 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { @@ -449,6 +1058,33 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest {
449 return fields; 1058 return fields;
450 } 1059 }
451 1060
  1061 + private static Ai1SessionMessageDO buildGeneratingMessage(LocalDateTime createTime) {
  1062 + Ai1SessionMessageDO message = new Ai1SessionMessageDO().setId(MESSAGE_ID).setSessionId(SESSION_ID)
  1063 + .setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole())
  1064 + .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus());
  1065 + message.setCreateTime(createTime);
  1066 + return message;
  1067 + }
  1068 +
  1069 + /**
  1070 + * 等待 PID 文件写入,且该进程属于当前 JVM 的子孙进程
  1071 + */
  1072 + private static ProcessHandle awaitDescendant(Path pidFile) throws Exception {
  1073 + long deadline = System.currentTimeMillis() + 5_000;
  1074 + while (System.currentTimeMillis() < deadline) {
  1075 + if (Files.exists(pidFile)) {
  1076 + long pid = Long.parseLong(Files.readString(pidFile).trim());
  1077 + Optional<ProcessHandle> process = ProcessHandle.current().descendants()
  1078 + .filter(descendant -> descendant.pid() == pid).findFirst();
  1079 + if (process.isPresent()) {
  1080 + return process.get();
  1081 + }
  1082 + }
  1083 + Thread.sleep(50);
  1084 + }
  1085 + return fail("未找到本用例启动的 Shell 进程");
  1086 + }
  1087 +
452 // ========== 断言工具 ========== 1088 // ========== 断言工具 ==========
453 1089
454 @SuppressWarnings({"unchecked", "rawtypes"}) 1090 @SuppressWarnings({"unchecked", "rawtypes"})
@@ -463,6 +1099,36 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { @@ -463,6 +1099,36 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest {
463 return types; 1099 return types;
464 } 1100 }
465 1101
  1102 + @SuppressWarnings({"unchecked", "rawtypes"})
  1103 + private List<String> captureResultData() {
  1104 + ArgumentCaptor<StreamAddArgs> captor = ArgumentCaptor.forClass(StreamAddArgs.class);
  1105 + verify(resultStream, atLeastOnce()).add(captor.capture());
  1106 + List<String> data = new ArrayList<>();
  1107 + for (StreamAddArgs args : captor.getAllValues()) {
  1108 + data.add(String.valueOf(readEntries(args).get("data")));
  1109 + }
  1110 + return data;
  1111 + }
  1112 +
  1113 + @SuppressWarnings("unchecked")
  1114 + private Set<Thread> getReapers() {
  1115 + return (Set<Thread>) ReflectionTestUtils.getField(sessionStreamTool, "reapers");
  1116 + }
  1117 +
  1118 + /**
  1119 + * 等待本用例启动的回收线程结束
  1120 + */
  1121 + private void awaitReapers() throws InterruptedException {
  1122 + for (Thread reaper : getReapers()) {
  1123 + reaper.join(10_000);
  1124 + }
  1125 + }
  1126 +
  1127 + @SuppressWarnings("unchecked")
  1128 + private Map<Long, Ai1SessionStreamTool.RunContext> getRunContexts() {
  1129 + return (Map<Long, Ai1SessionStreamTool.RunContext>) ReflectionTestUtils.getField(sessionStreamTool, "runContexts");
  1130 + }
  1131 +
466 @SuppressWarnings("unchecked") 1132 @SuppressWarnings("unchecked")
467 private static Map<String, String> readEntries(StreamAddArgs<String, String> args) { 1133 private static Map<String, String> readEntries(StreamAddArgs<String, String> args) {
468 try { 1134 try {
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionTaskRecoveryIntegrationTest.java
@@ -9,7 +9,9 @@ import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; @@ -9,7 +9,9 @@ import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum;
9 import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; 9 import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties;
10 import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool; 10 import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool;
11 import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory; 11 import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory;
  12 +import cn.iocoder.yudao.module.ai1.harness.memory.Ai1MemoryTool;
12 import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; 13 import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool;
  14 +import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillShellTracker;
13 import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; 15 import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory;
14 import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; 16 import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService;
15 import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; 17 import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService;
@@ -26,6 +28,7 @@ import org.mockito.Mock; @@ -26,6 +28,7 @@ import org.mockito.Mock;
26 import org.redisson.Redisson; 28 import org.redisson.Redisson;
27 import org.redisson.api.RScript; 29 import org.redisson.api.RScript;
28 import org.redisson.api.RStream; 30 import org.redisson.api.RStream;
  31 +import org.redisson.api.RTopic;
29 import org.redisson.api.RedissonClient; 32 import org.redisson.api.RedissonClient;
30 import org.redisson.api.stream.StreamAddArgs; 33 import org.redisson.api.stream.StreamAddArgs;
31 import org.redisson.api.stream.StreamCreateGroupArgs; 34 import org.redisson.api.stream.StreamCreateGroupArgs;
@@ -87,6 +90,10 @@ public class Ai1SessionTaskRecoveryIntegrationTest extends BaseMockitoUnitTest { @@ -87,6 +90,10 @@ public class Ai1SessionTaskRecoveryIntegrationTest extends BaseMockitoUnitTest {
87 private Ai1SkillToolFactory skillToolFactory; 90 private Ai1SkillToolFactory skillToolFactory;
88 @Mock 91 @Mock
89 private Ai1RagTool ragTool; 92 private Ai1RagTool ragTool;
  93 + @Mock
  94 + private Ai1MemoryTool memoryTool;
  95 + @Mock
  96 + private Ai1SkillShellTracker shellTracker;
90 97
91 private RedissonClient firstClient; 98 private RedissonClient firstClient;
92 private RedissonClient secondClient; 99 private RedissonClient secondClient;
@@ -114,18 +121,21 @@ public class Ai1SessionTaskRecoveryIntegrationTest extends BaseMockitoUnitTest { @@ -114,18 +121,21 @@ public class Ai1SessionTaskRecoveryIntegrationTest extends BaseMockitoUnitTest {
114 message.set(new Ai1SessionMessageDO().setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole()) 121 message.set(new Ai1SessionMessageDO().setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole())
115 .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus())); 122 .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus()));
116 123
117 - // mock 业务方法 124 + // mock 业务方法(排队中停止的用例不调用模型)
118 when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenAnswer(invocation -> message.get()); 125 when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenAnswer(invocation -> message.get());
  126 + lenient().when(sessionMessageService.getGeneratingAssistantMessageIdList(SESSION_ID)).thenAnswer(invocation ->
  127 + Ai1SessionMessageStatusEnum.isGenerating(message.get().getStatus()) ? List.of(MESSAGE_ID) : List.of());
119 doAnswer(invocation -> { 128 doAnswer(invocation -> {
120 message.set(invocation.getArgument(0)); 129 message.set(invocation.getArgument(0));
121 return null; 130 return null;
122 }).when(sessionMessageService).updateSessionMessage(any()); 131 }).when(sessionMessageService).updateSessionMessage(any());
123 - when(agentService.validateAgentExists(1L)).thenReturn(new Ai1AgentDO().setId(1L).setName("客服") 132 + lenient().when(skillToolFactory.newShellTracker(1L)).thenReturn(shellTracker);
  133 + lenient().when(agentService.validateAgentExists(1L)).thenReturn(new Ai1AgentDO().setId(1L).setName("客服")
124 .setProviderId(1L).setModelId(2L)); 134 .setProviderId(1L).setModelId(2L));
125 - when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO().setModelType(Ai1ModelTypeEnum.CHAT.getType()));  
126 - when(sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan(eq(SESSION_ID), eq(MESSAGE_ID), anyInt())) 135 + lenient().when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO().setModelType(Ai1ModelTypeEnum.CHAT.getType()));
  136 + lenient().when(sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan(eq(SESSION_ID), eq(MESSAGE_ID), anyInt()))
127 .thenReturn(new ArrayList<>()); 137 .thenReturn(new ArrayList<>());
128 - when(llmChatTool.chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any())) 138 + lenient().when(llmChatTool.chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any()))
129 .thenAnswer(invocation -> { 139 .thenAnswer(invocation -> {
130 Consumer<String> onContent = invocation.getArgument(8); 140 Consumer<String> onContent = invocation.getArgument(8);
131 onContent.accept("第一段"); 141 onContent.accept("第一段");
@@ -143,6 +153,7 @@ public class Ai1SessionTaskRecoveryIntegrationTest extends BaseMockitoUnitTest { @@ -143,6 +153,7 @@ public class Ai1SessionTaskRecoveryIntegrationTest extends BaseMockitoUnitTest {
143 assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); 153 assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
144 taskStream.delete(); 154 taskStream.delete();
145 resultStream.delete(); 155 resultStream.delete();
  156 + secondClient.getBucket(String.format(SESSION_STOP, MESSAGE_ID)).delete();
146 secondClient.getLock(String.format(SESSION_TASK_LOCK, MESSAGE_ID)).forceUnlock(); 157 secondClient.getLock(String.format(SESSION_TASK_LOCK, MESSAGE_ID)).forceUnlock();
147 firstClient.shutdown(); 158 firstClient.shutdown();
148 secondClient.shutdown(); 159 secondClient.shutdown();
@@ -195,7 +206,7 @@ public class Ai1SessionTaskRecoveryIntegrationTest extends BaseMockitoUnitTest { @@ -195,7 +206,7 @@ public class Ai1SessionTaskRecoveryIntegrationTest extends BaseMockitoUnitTest {
195 secondWorker.handleTask(recordId, reclaimed.get(recordId), true); 206 secondWorker.handleTask(recordId, reclaimed.get(recordId), true);
196 207
197 // 断言接管只保留部分内容并标记失败,不再次调用模型 208 // 断言接管只保留部分内容并标记失败,不再次调用模型
198 - assertEquals("第一段", message.get().getContent()); 209 + assertEquals("第一段\n\n> 生成任务已中断,请重新发送", message.get().getContent());
199 assertEquals(Ai1SessionMessageStatusEnum.FAILED.getStatus(), message.get().getStatus()); 210 assertEquals(Ai1SessionMessageStatusEnum.FAILED.getStatus(), message.get().getStatus());
200 assertEquals(List.of("ping", "message", "error", "done"), resultTypes()); 211 assertEquals(List.of("ping", "message", "error", "done"), resultTypes());
201 assertEquals(0, taskStream.getPendingInfo(SESSION_TASK_GROUP).getTotal()); 212 assertEquals(0, taskStream.getPendingInfo(SESSION_TASK_GROUP).getTotal());
@@ -204,10 +215,108 @@ public class Ai1SessionTaskRecoveryIntegrationTest extends BaseMockitoUnitTest { @@ -204,10 +215,108 @@ public class Ai1SessionTaskRecoveryIntegrationTest extends BaseMockitoUnitTest {
204 // 调用旧节点恢复,断言不追加旧增量或覆盖回填结果 215 // 调用旧节点恢复,断言不追加旧增量或覆盖回填结果
205 release.countDown(); 216 release.countDown();
206 generation.get(5, TimeUnit.SECONDS); 217 generation.get(5, TimeUnit.SECONDS);
207 - assertEquals("第一段", message.get().getContent()); 218 + assertEquals("第一段\n\n> 生成任务已中断,请重新发送", message.get().getContent());
208 assertEquals(List.of("ping", "message", "error", "done"), resultTypes()); 219 assertEquals(List.of("ping", "message", "error", "done"), resultTypes());
209 } 220 }
210 221
  222 + @Test
  223 + public void testStop_crossNode() throws Exception {
  224 + // 准备任务,交给第一个节点生成;第一个节点订阅停止通知
  225 + startWithoutWorker(firstWorker);
  226 + StreamMessageId recordId = addTask();
  227 + Map<String, String> fields = taskStream.readGroup(SESSION_TASK_GROUP, "first",
  228 + StreamReadGroupArgs.neverDelivered().count(1)).get(recordId);
  229 + Future<?> generation = executor.submit(() -> firstWorker.handleTask(recordId, fields));
  230 + assertTrue(generated.await(5, TimeUnit.SECONDS));
  231 +
  232 + try {
  233 + // 调用:请求落在第二个节点
  234 + secondWorker.cancel(SESSION_ID);
  235 + generation.get(5, TimeUnit.SECONDS);
  236 +
  237 + // 断言:保留已生成内容,人工终止只写 done,并确认任务
  238 + assertEquals("第一段\n\n> 本条回复已被人工终止", message.get().getContent());
  239 + assertEquals(Ai1SessionMessageStatusEnum.FAILED.getStatus(), message.get().getStatus());
  240 + assertEquals(List.of("ping", "message", "done"), resultTypes());
  241 + assertEquals(0, taskStream.getPendingInfo(SESSION_TASK_GROUP).getTotal());
  242 + verify(shellTracker, atLeastOnce()).cancel();
  243 + // 断言:worker 线程的中断标志已清除,不影响下一轮
  244 + assertFalse(executor.submit(() -> Thread.currentThread().isInterrupted()).get(5, TimeUnit.SECONDS));
  245 + } finally {
  246 + firstWorker.stop();
  247 + }
  248 + }
  249 +
  250 + @Test
  251 + public void testStop_intentWithoutSubscription() throws Exception {
  252 + // 准备任务,交给第一个节点生成;模拟停止通知的订阅已断开
  253 + startWithoutWorker(firstWorker);
  254 + RTopic stopTopic = firstClient.getTopic(SESSION_STOP_TOPIC, StringCodec.INSTANCE);
  255 + stopTopic.removeListener((Integer) ReflectionTestUtils.getField(firstWorker, "stopListenerId"));
  256 + StreamMessageId recordId = addTask();
  257 + Map<String, String> fields = taskStream.readGroup(SESSION_TASK_GROUP, "first",
  258 + StreamReadGroupArgs.neverDelivered().count(1)).get(recordId);
  259 + Future<?> generation = executor.submit(() -> firstWorker.handleTask(recordId, fields));
  260 + assertTrue(generated.await(5, TimeUnit.SECONDS));
  261 +
  262 + try {
  263 + // 调用:停止通知丢失,由定时检查停止意图兜底
  264 + secondWorker.cancel(SESSION_ID);
  265 + generation.get(5, TimeUnit.SECONDS);
  266 +
  267 + // 断言
  268 + assertEquals("第一段\n\n> 本条回复已被人工终止", message.get().getContent());
  269 + assertEquals(List.of("ping", "message", "done"), resultTypes());
  270 + } finally {
  271 + firstWorker.stop();
  272 + }
  273 + }
  274 +
  275 + @Test
  276 + public void testStop_queued() {
  277 + // 准备任务,尚未被任何 worker 读取
  278 + StreamMessageId recordId = addTask();
  279 +
  280 + // 调用:无人执行时由请求节点直接收尾
  281 + secondWorker.cancel(SESSION_ID);
  282 + // 断言
  283 + assertEquals("\n\n> 本条回复已被人工终止", message.get().getContent());
  284 + assertEquals(Ai1SessionMessageStatusEnum.FAILED.getStatus(), message.get().getStatus());
  285 + assertEquals(List.of("done"), resultTypes());
  286 +
  287 + // 调用:worker 之后读到该任务
  288 + Map<String, String> fields = taskStream.readGroup(SESSION_TASK_GROUP, "first",
  289 + StreamReadGroupArgs.neverDelivered().count(1)).get(recordId);
  290 + firstWorker.handleTask(recordId, fields);
  291 + // 断言:不调用模型,补齐终态后确认
  292 + verifyNoInteractions(llmChatTool);
  293 + assertEquals(List.of("done"), resultTypes());
  294 + assertEquals(0, taskStream.getPendingInfo(SESSION_TASK_GROUP).getTotal());
  295 + }
  296 +
  297 + @Test
  298 + public void testConsume_missingGroupRecovered() throws Exception {
  299 + // 准备:启动一个 worker 后删除任务队列,消费组随之丢失
  300 + YudaoAi1Properties properties = (YudaoAi1Properties) ReflectionTestUtils.getField(secondWorker, "ai1Properties");
  301 + properties.getSession().getWorker().setCount(1);
  302 + secondWorker.start();
  303 + try {
  304 + taskStream.delete();
  305 + addTask();
  306 + release.countDown();
  307 +
  308 + // 断言:worker 重建消费组后继续消费
  309 + long deadline = System.currentTimeMillis() + 20_000;
  310 + while (Ai1SessionMessageStatusEnum.isGenerating(message.get().getStatus()) && System.currentTimeMillis() < deadline) {
  311 + Thread.sleep(200);
  312 + }
  313 + assertEquals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), message.get().getStatus());
  314 + assertEquals("第一段第二段", message.get().getContent());
  315 + } finally {
  316 + secondWorker.stop();
  317 + }
  318 + }
  319 +
211 // ========== 测试数据 ========== 320 // ========== 测试数据 ==========
212 321
213 private RedissonClient createClient() { 322 private RedissonClient createClient() {
@@ -241,9 +350,21 @@ public class Ai1SessionTaskRecoveryIntegrationTest extends BaseMockitoUnitTest { @@ -241,9 +350,21 @@ public class Ai1SessionTaskRecoveryIntegrationTest extends BaseMockitoUnitTest {
241 ReflectionTestUtils.setField(worker, "mcpToolFactory", mcpToolFactory); 350 ReflectionTestUtils.setField(worker, "mcpToolFactory", mcpToolFactory);
242 ReflectionTestUtils.setField(worker, "skillToolFactory", skillToolFactory); 351 ReflectionTestUtils.setField(worker, "skillToolFactory", skillToolFactory);
243 ReflectionTestUtils.setField(worker, "ragTool", ragTool); 352 ReflectionTestUtils.setField(worker, "ragTool", ragTool);
  353 + ReflectionTestUtils.setField(worker, "memoryTool", memoryTool);
  354 + // 直接调用 handleTask 的用例按运行中处理,与 start 之后一致
  355 + ReflectionTestUtils.setField(worker, "running", true);
244 return worker; 356 return worker;
245 } 357 }
246 358
  359 + /**
  360 + * 启动节点但不拉起消费线程,只订阅停止通知、检查停止意图
  361 + */
  362 + private static void startWithoutWorker(Ai1SessionStreamTool worker) {
  363 + YudaoAi1Properties properties = (YudaoAi1Properties) ReflectionTestUtils.getField(worker, "ai1Properties");
  364 + properties.getSession().getWorker().setCount(0);
  365 + worker.start();
  366 + }
  367 +
247 private StreamMessageId addTask() { 368 private StreamMessageId addTask() {
248 return taskStream.add(StreamAddArgs.entries(Map.of("messageId", String.valueOf(MESSAGE_ID), 369 return taskStream.add(StreamAddArgs.entries(Map.of("messageId", String.valueOf(MESSAGE_ID),
249 "agentId", "1", "sessionId", String.valueOf(SESSION_ID), "content", "你好"))); 370 "agentId", "1", "sessionId", String.valueOf(SESSION_ID), "content", "你好")));
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillShellTrackerTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.harness.skill;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import org.junit.jupiter.api.Test;
  5 +import org.junit.jupiter.api.Timeout;
  6 +import org.junit.jupiter.api.condition.EnabledOnOs;
  7 +import org.junit.jupiter.api.condition.OS;
  8 +import org.junit.jupiter.api.io.TempDir;
  9 +import org.mockito.Mock;
  10 +import org.mockito.stubbing.Answer;
  11 +import org.springaicommunity.agent.common.exec.ExecBackend;
  12 +import org.springaicommunity.agent.common.exec.ExecHandle;
  13 +import org.springaicommunity.agent.common.exec.ExecResult;
  14 +import org.springaicommunity.agent.common.exec.ExecSpec;
  15 +import org.springaicommunity.agent.exec.LocalExecBackend;
  16 +
  17 +import java.nio.file.Files;
  18 +import java.nio.file.Path;
  19 +import java.util.Optional;
  20 +import java.util.concurrent.TimeUnit;
  21 +import java.util.concurrent.atomic.AtomicBoolean;
  22 +
  23 +import static org.junit.jupiter.api.Assertions.*;
  24 +import static org.mockito.Mockito.*;
  25 +
  26 +/**
  27 + * {@link Ai1SkillShellTracker} 的单元测试
  28 + *
  29 + * @author 芋道源码
  30 + */
  31 +public class Ai1SkillShellTrackerTest extends BaseMockitoUnitTest {
  32 +
  33 + private static final ExecSpec SPEC = ExecSpec.of("sleep 60");
  34 +
  35 + @Mock
  36 + private ExecBackend delegate;
  37 + @Mock
  38 + private ExecHandle handle1;
  39 + @Mock
  40 + private ExecHandle handle2;
  41 +
  42 + @Test
  43 + public void testRun_delegate() {
  44 + // mock 方法
  45 + ExecResult result = ExecResult.completed(0, "ok", "");
  46 + when(delegate.run(SPEC)).thenReturn(result);
  47 + Ai1SkillShellTracker tracker = new Ai1SkillShellTracker(delegate);
  48 +
  49 + // 调用,并断言:同步命令自带超时,不登记
  50 + assertSame(result, tracker.run(SPEC));
  51 + tracker.cancel();
  52 + verifyNoInteractions(handle1);
  53 + }
  54 +
  55 + @Test
  56 + public void testCancel_killAll() {
  57 + // mock 方法:第一个进程回收失败,不影响其他进程
  58 + when(delegate.start(SPEC)).thenReturn(handle1, handle2);
  59 + doThrow(new IllegalStateException("进程已退出")).when(handle1).kill();
  60 + Ai1SkillShellTracker tracker = new Ai1SkillShellTracker(delegate);
  61 + tracker.start(SPEC);
  62 + tracker.start(SPEC);
  63 +
  64 + // 调用:重复终止幂等
  65 + tracker.cancel();
  66 + tracker.cancel();
  67 + // 断言
  68 + verify(handle1).kill();
  69 + verify(handle2).kill();
  70 + }
  71 +
  72 + @Test
  73 + @Timeout(30)
  74 + public void testCancel_concurrentKillOnce() throws Exception {
  75 + // mock 方法:第一个进程回收期间,另一个线程(如 worker 结束)并发回收
  76 + when(delegate.start(SPEC)).thenReturn(handle1, handle2);
  77 + Ai1SkillShellTracker tracker = new Ai1SkillShellTracker(delegate);
  78 + tracker.start(SPEC);
  79 + tracker.start(SPEC);
  80 + AtomicBoolean first = new AtomicBoolean(true);
  81 + Answer<Void> concurrentCancel = invocation -> {
  82 + if (first.getAndSet(false)) {
  83 + Thread other = new Thread(tracker::cancel);
  84 + other.start();
  85 + other.join(5000);
  86 + }
  87 + return null;
  88 + };
  89 + doAnswer(concurrentCancel).when(handle1).kill();
  90 + doAnswer(concurrentCancel).when(handle2).kill();
  91 +
  92 + // 调用
  93 + tracker.cancel();
  94 + // 断言:每个进程只回收一次
  95 + verify(handle1, times(1)).kill();
  96 + verify(handle2, times(1)).kill();
  97 + }
  98 +
  99 + @Test
  100 + public void testStart_afterCancel() {
  101 + // 准备参数
  102 + Ai1SkillShellTracker tracker = new Ai1SkillShellTracker(delegate);
  103 + tracker.cancel();
  104 +
  105 + // 调用,并断言异常:终止后拒绝再启动
  106 + assertThrows(IllegalStateException.class, () -> tracker.start(SPEC));
  107 + verify(delegate, never()).start(any());
  108 + }
  109 +
  110 + @Test
  111 + public void testStart_cancelledDuringStart() {
  112 + // mock 方法:进程启动期间被终止
  113 + Ai1SkillShellTracker tracker = new Ai1SkillShellTracker(delegate);
  114 + when(delegate.start(SPEC)).thenAnswer(invocation -> {
  115 + tracker.cancel();
  116 + return handle1;
  117 + });
  118 +
  119 + // 调用
  120 + assertSame(handle1, tracker.start(SPEC));
  121 + // 断言:登记后发现已终止,立即回收
  122 + verify(handle1).kill();
  123 + }
  124 +
  125 + @Test
  126 + @Timeout(30)
  127 + @EnabledOnOs({OS.MAC, OS.LINUX})
  128 + public void testCancel_realProcess(@TempDir Path workDir) throws Exception {
  129 + // 准备参数:Shell 写入自身 PID 后 exec 为 sleep,PID 不变
  130 + Ai1SkillShellTracker tracker = new Ai1SkillShellTracker(LocalExecBackend.builder().workingDirectory(workDir).build());
  131 + ExecHandle handle = tracker.start(ExecSpec.of("echo $$ > pid.tmp && mv pid.tmp pid && exec sleep 60"));
  132 + ProcessHandle process = null;
  133 + try {
  134 + process = awaitDescendant(workDir.resolve("pid"));
  135 + assertTrue(process.isAlive());
  136 +
  137 + // 调用
  138 + tracker.cancel();
  139 + // 断言
  140 + process.onExit().get(10, TimeUnit.SECONDS);
  141 + assertFalse(process.isAlive());
  142 + assertFalse(handle.isAlive());
  143 + } finally {
  144 + // 只清理本用例启动的进程
  145 + if (process != null && process.isAlive()) {
  146 + process.destroyForcibly();
  147 + }
  148 + if (handle.isAlive()) {
  149 + handle.kill();
  150 + }
  151 + }
  152 + }
  153 +
  154 + // ========== 测试数据 ==========
  155 +
  156 + /**
  157 + * 等待 PID 文件写入,且该进程属于当前 JVM 的子孙进程
  158 + */
  159 + private static ProcessHandle awaitDescendant(Path pidFile) throws Exception {
  160 + long deadline = System.currentTimeMillis() + 5_000;
  161 + while (System.currentTimeMillis() < deadline) {
  162 + if (Files.exists(pidFile)) {
  163 + long pid = Long.parseLong(Files.readString(pidFile).trim());
  164 + Optional<ProcessHandle> process = ProcessHandle.current().descendants()
  165 + .filter(descendant -> descendant.pid() == pid).findFirst();
  166 + if (process.isPresent()) {
  167 + return process.get();
  168 + }
  169 + }
  170 + Thread.sleep(50);
  171 + }
  172 + return fail("未找到本用例启动的 Shell 进程");
  173 + }
  174 +
  175 +}
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactoryTest.java
@@ -16,6 +16,8 @@ import org.junit.jupiter.api.io.TempDir; @@ -16,6 +16,8 @@ import org.junit.jupiter.api.io.TempDir;
16 import org.mockito.InjectMocks; 16 import org.mockito.InjectMocks;
17 import org.mockito.Mock; 17 import org.mockito.Mock;
18 import org.mockito.Spy; 18 import org.mockito.Spy;
  19 +import org.springaicommunity.agent.common.exec.ExecResult;
  20 +import org.springaicommunity.agent.common.exec.ExecSpec;
19 import org.springframework.ai.tool.ToolCallback; 21 import org.springframework.ai.tool.ToolCallback;
20 22
21 import java.nio.file.Path; 23 import java.nio.file.Path;
@@ -64,7 +66,7 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { @@ -64,7 +66,7 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest {
64 mockSkillFiles("echo hi"); 66 mockSkillFiles("echo hi");
65 67
66 // 调用 68 // 调用
67 - List<Object> tools = skillToolFactory.buildTools(buildAgent()); 69 + List<Object> tools = buildTools(buildAgent());
68 // 断言 70 // 断言
69 assertEquals(6, tools.size()); 71 assertEquals(6, tools.size());
70 assertInstanceOf(ToolCallback.class, tools.get(0)); 72 assertInstanceOf(ToolCallback.class, tools.get(0));
@@ -82,8 +84,8 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { @@ -82,8 +84,8 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest {
82 Ai1AgentDO agent = buildAgent(); 84 Ai1AgentDO agent = buildAgent();
83 85
84 // 调用 86 // 调用
85 - skillToolFactory.buildTools(agent);  
86 - skillToolFactory.buildTools(agent); 87 + buildTools(agent);
  88 + buildTools(agent);
87 // 断言 89 // 断言
88 verify(skillFileService, times(1)).getSkillFileListBySkillId(SKILL_ID); 90 verify(skillFileService, times(1)).getSkillFileListBySkillId(SKILL_ID);
89 91
@@ -94,7 +96,7 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { @@ -94,7 +96,7 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest {
94 mockSkillFiles("echo v2"); 96 mockSkillFiles("echo v2");
95 97
96 // 调用 98 // 调用
97 - skillToolFactory.buildTools(agent); 99 + buildTools(agent);
98 // 断言 100 // 断言
99 verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID); 101 verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID);
100 assertEquals("echo v2", FileUtil.readUtf8String(agentRoot().resolve("pdf-reader/scripts/run.sh").toFile())); 102 assertEquals("echo v2", FileUtil.readUtf8String(agentRoot().resolve("pdf-reader/scripts/run.sh").toFile()));
@@ -107,13 +109,13 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { @@ -107,13 +109,13 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest {
107 mockSkill(CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0)); 109 mockSkill(CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0));
108 mockSkillFiles("echo hi"); 110 mockSkillFiles("echo hi");
109 Ai1AgentDO agent = buildAgent(); 111 Ai1AgentDO agent = buildAgent();
110 - skillToolFactory.buildTools(agent); 112 + buildTools(agent);
111 113
112 // mock 物化目录被删除 114 // mock 物化目录被删除
113 FileUtil.del(agentRoot()); 115 FileUtil.del(agentRoot());
114 116
115 // 调用 117 // 调用
116 - skillToolFactory.buildTools(agent); 118 + buildTools(agent);
117 // 断言 119 // 断言
118 verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID); 120 verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID);
119 assertTrue(FileUtil.exist(agentRoot().resolve("pdf-reader/SKILL.md").toFile())); 121 assertTrue(FileUtil.exist(agentRoot().resolve("pdf-reader/SKILL.md").toFile()));
@@ -125,7 +127,7 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { @@ -125,7 +127,7 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest {
125 mockSkill(CommonStatusEnum.DISABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0)); 127 mockSkill(CommonStatusEnum.DISABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0));
126 128
127 // 调用 129 // 调用
128 - List<Object> tools = skillToolFactory.buildTools(buildAgent()); 130 + List<Object> tools = buildTools(buildAgent());
129 // 断言只保留执行工具,不物化 SKILL 文件 131 // 断言只保留执行工具,不物化 SKILL 文件
130 assertEquals(5, tools.size()); 132 assertEquals(5, tools.size());
131 assertTrue(tools.stream().noneMatch(ToolCallback.class::isInstance)); 133 assertTrue(tools.stream().noneMatch(ToolCallback.class::isInstance));
@@ -136,7 +138,7 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { @@ -136,7 +138,7 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest {
136 @Test 138 @Test
137 public void testBuildTools_noSkill() { 139 public void testBuildTools_noSkill() {
138 // 调用,并断言 140 // 调用,并断言
139 - assertTrue(skillToolFactory.buildTools(new Ai1AgentDO().setId(AGENT_ID).setSkillIds(List.of())).isEmpty()); 141 + assertTrue(buildTools(new Ai1AgentDO().setId(AGENT_ID).setSkillIds(List.of())).isEmpty());
140 verify(skillService, never()).getSkillList(anyCollection()); 142 verify(skillService, never()).getSkillList(anyCollection());
141 } 143 }
142 144
@@ -146,7 +148,7 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { @@ -146,7 +148,7 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest {
146 mockSkill(CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0)); 148 mockSkill(CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0));
147 mockSkillFiles("echo hi"); 149 mockSkillFiles("echo hi");
148 Ai1AgentDO agent = buildAgent(); 150 Ai1AgentDO agent = buildAgent();
149 - skillToolFactory.buildTools(agent); 151 + buildTools(agent);
150 assertTrue(FileUtil.exist(agentRoot().toFile())); 152 assertTrue(FileUtil.exist(agentRoot().toFile()));
151 153
152 // 调用 154 // 调用
@@ -155,7 +157,7 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { @@ -155,7 +157,7 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest {
155 assertFalse(FileUtil.exist(agentRoot().toFile())); 157 assertFalse(FileUtil.exist(agentRoot().toFile()));
156 158
157 // 调用 159 // 调用
158 - skillToolFactory.buildTools(agent); 160 + buildTools(agent);
159 // 断言重新物化 161 // 断言重新物化
160 verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID); 162 verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID);
161 } 163 }
@@ -176,7 +178,7 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { @@ -176,7 +178,7 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest {
176 } 178 }
177 179
178 // 调用 180 // 调用
179 - ToolCallback tool = (ToolCallback) skillToolFactory.buildTools(agent).get(0); 181 + ToolCallback tool = (ToolCallback) buildTools(agent).get(0);
180 String result = tool.call("{\"command\":\"same\"}"); 182 String result = tool.call("{\"command\":\"same\"}");
181 // 断言 183 // 断言
182 assertTrue(result.contains("first-content")); 184 assertTrue(result.contains("first-content"));
@@ -185,14 +187,32 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { @@ -185,14 +187,32 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest {
185 187
186 // 调用 188 // 调用
187 skillToolFactory.evict(AGENT_ID); 189 skillToolFactory.evict(AGENT_ID);
188 - ToolCallback rebuiltTool = (ToolCallback) skillToolFactory.buildTools(agent).get(0); 190 + ToolCallback rebuiltTool = (ToolCallback) buildTools(agent).get(0);
189 String rebuiltResult = rebuiltTool.call("{\"command\":\"same\"}"); 191 String rebuiltResult = rebuiltTool.call("{\"command\":\"same\"}");
190 // 断言 192 // 断言
191 assertEquals(result, rebuiltResult); 193 assertEquals(result, rebuiltResult);
192 } 194 }
193 195
  196 + @Test
  197 + public void testNewShellTracker_workingDirectory() throws Exception {
  198 + // mock skillService 和 skillFileService 的方法
  199 + mockSkill(CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0));
  200 + mockSkillFiles("echo hi");
  201 + buildTools(buildAgent());
  202 +
  203 + // 调用
  204 + ExecResult result = skillToolFactory.newShellTracker(AGENT_ID).run(ExecSpec.of("pwd"));
  205 + // 断言:Shell 命令限定在 Agent 物化根目录执行
  206 + assertEquals(0, result.exitCode());
  207 + assertEquals(agentRoot().toRealPath(), Path.of(result.stdout().trim()).toRealPath());
  208 + }
  209 +
194 // ========== 测试数据 ========== 210 // ========== 测试数据 ==========
195 211
  212 + private List<Object> buildTools(Ai1AgentDO agent) {
  213 + return skillToolFactory.buildTools(agent, skillToolFactory.newShellTracker(agent.getId()));
  214 + }
  215 +
196 private Path agentRoot() { 216 private Path agentRoot() {
197 return tempDir.resolve("agent_" + AGENT_ID); 217 return tempDir.resolve("agent_" + AGENT_ID);
198 } 218 }
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentServiceImplTest.java
@@ -7,6 +7,7 @@ import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; @@ -7,6 +7,7 @@ import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO;
7 import cn.iocoder.yudao.module.ai1.dal.mysql.agent.Ai1AgentMapper; 7 import cn.iocoder.yudao.module.ai1.dal.mysql.agent.Ai1AgentMapper;
8 import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; 8 import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum;
9 import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; 9 import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory;
  10 +import cn.iocoder.yudao.module.ai1.service.memory.Ai1MemoryService;
10 import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; 11 import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService;
11 import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; 12 import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService;
12 import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; 13 import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO;
@@ -45,6 +46,8 @@ public class Ai1AgentServiceImplTest extends BaseDbUnitTest { @@ -45,6 +46,8 @@ public class Ai1AgentServiceImplTest extends BaseDbUnitTest {
45 @MockitoBean 46 @MockitoBean
46 private Ai1SessionService sessionService; 47 private Ai1SessionService sessionService;
47 @MockitoBean 48 @MockitoBean
  49 + private Ai1MemoryService memoryService;
  50 + @MockitoBean
48 private Ai1SkillToolFactory skillToolFactory; 51 private Ai1SkillToolFactory skillToolFactory;
49 52
50 @Test 53 @Test
@@ -123,6 +126,23 @@ public class Ai1AgentServiceImplTest extends BaseDbUnitTest { @@ -123,6 +126,23 @@ public class Ai1AgentServiceImplTest extends BaseDbUnitTest {
123 } 126 }
124 127
125 @Test 128 @Test
  129 + public void testGetAgentListByStatus_all() {
  130 + // mock 数据:开启、关闭、已删除的 Agent 各一个
  131 + mockChatModel();
  132 + Long enabledId = agentService.createAgent(buildAgentSaveReqVO());
  133 + agentService.updateAgentStatus(enabledId, CommonStatusEnum.ENABLE.getStatus());
  134 + Long disabledId = agentService.createAgent(buildAgentSaveReqVO());
  135 + agentMapper.deleteById(agentService.createAgent(buildAgentSaveReqVO()));
  136 +
  137 + // 调用:状态为空时返回全部状态
  138 + List<Ai1AgentDO> list = agentService.getAgentListByStatus(null);
  139 + // 断言:按编号升序,不含已删除的 Agent
  140 + assertEquals(List.of(enabledId, disabledId), list.stream().map(Ai1AgentDO::getId).toList());
  141 + assertEquals(CommonStatusEnum.ENABLE.getStatus(), list.get(0).getStatus());
  142 + assertEquals(CommonStatusEnum.DISABLE.getStatus(), list.get(1).getStatus());
  143 + }
  144 +
  145 + @Test
126 public void testDeleteAgent_cascade() { 146 public void testDeleteAgent_cascade() {
127 // mock 数据 147 // mock 数据
128 mockChatModel(); 148 mockChatModel();
@@ -133,10 +153,29 @@ public class Ai1AgentServiceImplTest extends BaseDbUnitTest { @@ -133,10 +153,29 @@ public class Ai1AgentServiceImplTest extends BaseDbUnitTest {
133 // 断言 153 // 断言
134 assertNull(agentMapper.selectById(id)); 154 assertNull(agentMapper.selectById(id));
135 verify(sessionService).deleteSessionListByAgentIds(Collections.singletonList(id)); 155 verify(sessionService).deleteSessionListByAgentIds(Collections.singletonList(id));
  156 + verify(memoryService).deleteMemoryListByAgentIds(Collections.singletonList(id));
136 verify(skillToolFactory).evict(id); 157 verify(skillToolFactory).evict(id);
137 } 158 }
138 159
139 @Test 160 @Test
  161 + public void testCreateAgent_memoryEnabled() {
  162 + // mock 数据
  163 + mockChatModel();
  164 +
  165 + // 调用
  166 + Long defaultId = agentService.createAgent(buildAgentSaveReqVO());
  167 + Long enabledId = agentService.createAgent(buildAgentSaveReqVO().setMemoryEnabled(true));
  168 + // 断言:默认关闭
  169 + assertFalse(agentMapper.selectById(defaultId).getMemoryEnabled());
  170 + assertTrue(agentMapper.selectById(enabledId).getMemoryEnabled());
  171 +
  172 + // 调用:关闭
  173 + agentService.updateAgent(buildAgentSaveReqVO().setId(enabledId).setMemoryEnabled(false));
  174 + // 断言
  175 + assertFalse(agentMapper.selectById(enabledId).getMemoryEnabled());
  176 + }
  177 +
  178 + @Test
140 public void testGetAgentCountByKnowledgeBaseIdsAndSkillIds() { 179 public void testGetAgentCountByKnowledgeBaseIdsAndSkillIds() {
141 // mock 数据 180 // mock 数据
142 mockChatModel(); 181 mockChatModel();
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/memory/Ai1MemoryServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.service.memory;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  4 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
  6 +import cn.iocoder.yudao.module.ai1.controller.admin.memory.vo.Ai1MemoryPageReqVO;
  7 +import cn.iocoder.yudao.module.ai1.controller.admin.memory.vo.Ai1MemoryUpdateReqVO;
  8 +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO;
  9 +import cn.iocoder.yudao.module.ai1.dal.dataobject.memory.Ai1MemoryDO;
  10 +import cn.iocoder.yudao.module.ai1.dal.mysql.memory.Ai1MemoryMapper;
  11 +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService;
  12 +import jakarta.annotation.Resource;
  13 +import org.junit.jupiter.api.Test;
  14 +import org.springframework.context.annotation.Import;
  15 +import org.springframework.test.context.bean.override.mockito.MockitoBean;
  16 +
  17 +import java.util.Arrays;
  18 +import java.util.Collections;
  19 +import java.util.List;
  20 +
  21 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  22 +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.MEMORY_NOT_EXISTS;
  23 +import static org.junit.jupiter.api.Assertions.*;
  24 +import static org.mockito.Mockito.when;
  25 +
  26 +/**
  27 + * {@link Ai1MemoryServiceImpl} 的单元测试
  28 + *
  29 + * @author 芋道源码
  30 + */
  31 +@Import(Ai1MemoryServiceImpl.class)
  32 +public class Ai1MemoryServiceImplTest extends BaseDbUnitTest {
  33 +
  34 + @Resource
  35 + private Ai1MemoryServiceImpl memoryService;
  36 +
  37 + @Resource
  38 + private Ai1MemoryMapper memoryMapper;
  39 +
  40 + @MockitoBean
  41 + private Ai1AgentService agentService;
  42 +
  43 + @Test
  44 + public void testUpdateMemory_success() {
  45 + // mock 数据
  46 + Ai1MemoryDO memory = insertMemory(1L, 100L);
  47 +
  48 + // 调用
  49 + memoryService.updateMemory(new Ai1MemoryUpdateReqVO().setId(memory.getId()).setContent("## 用户画像\n- 新内容"));
  50 + // 断言:正文更新,水位线不变
  51 + Ai1MemoryDO result = memoryMapper.selectById(memory.getId());
  52 + assertEquals("## 用户画像\n- 新内容", result.getContent());
  53 + assertEquals(10L, result.getLastMessageId());
  54 + }
  55 +
  56 + @Test
  57 + public void testUpdateMemory_notExists() {
  58 + // 调用,并断言异常
  59 + assertServiceException(() -> memoryService.updateMemory(new Ai1MemoryUpdateReqVO().setId(1L).setContent("内容")),
  60 + MEMORY_NOT_EXISTS);
  61 + }
  62 +
  63 + @Test
  64 + public void testDeleteMemoryListByIds_success() {
  65 + // mock 数据
  66 + Ai1MemoryDO memory1 = insertMemory(1L, 100L);
  67 + Ai1MemoryDO memory2 = insertMemory(1L, 200L);
  68 + Ai1MemoryDO memory3 = insertMemory(2L, 100L);
  69 +
  70 + // 调用
  71 + memoryService.deleteMemoryListByIds(Arrays.asList(memory1.getId(), memory2.getId()));
  72 + // 断言:逻辑删除后不再可见,同一用户可以重新生成
  73 + assertNull(memoryMapper.selectById(memory1.getId()));
  74 + assertNull(memoryMapper.selectById(memory2.getId()));
  75 + assertNotNull(memoryMapper.selectById(memory3.getId()));
  76 + assertNotNull(insertMemory(1L, 100L).getId());
  77 + }
  78 +
  79 + @Test
  80 + public void testDeleteMemoryListByIds_notExists() {
  81 + // mock 数据
  82 + Ai1MemoryDO memory = insertMemory(1L, 100L);
  83 +
  84 + // 调用,并断言异常
  85 + assertServiceException(() -> memoryService.deleteMemoryListByIds(Arrays.asList(memory.getId(), memory.getId() + 1)),
  86 + MEMORY_NOT_EXISTS);
  87 + // 断言
  88 + assertNotNull(memoryMapper.selectById(memory.getId()));
  89 + }
  90 +
  91 + @Test
  92 + public void testDeleteMemoryListByAgentIds() {
  93 + // mock 数据
  94 + Ai1MemoryDO memory1 = insertMemory(1L, 100L);
  95 + Ai1MemoryDO memory2 = insertMemory(2L, 100L);
  96 + Ai1MemoryDO memory3 = insertMemory(3L, 100L);
  97 +
  98 + // 调用
  99 + memoryService.deleteMemoryListByAgentIds(Arrays.asList(1L, 2L));
  100 + memoryService.deleteMemoryListByAgentIds(Collections.emptyList());
  101 + // 断言
  102 + assertNull(memoryMapper.selectById(memory1.getId()));
  103 + assertNull(memoryMapper.selectById(memory2.getId()));
  104 + assertNotNull(memoryMapper.selectById(memory3.getId()));
  105 + }
  106 +
  107 + @Test
  108 + public void testGetMemoryPage() {
  109 + // mock 数据
  110 + Ai1MemoryDO memory1 = insertMemory(1L, 100L);
  111 + Ai1MemoryDO memory2 = insertMemory(1L, 200L);
  112 + Ai1MemoryDO memory3 = insertMemory(2L, 100L);
  113 + memoryService.deleteMemory(insertMemory(1L, 300L).getId());
  114 +
  115 + // 调用:不传条件,按编号倒序,不含已删除的记忆
  116 + PageResult<Ai1MemoryDO> pageResult = memoryService.getMemoryPage(new Ai1MemoryPageReqVO());
  117 + // 断言
  118 + assertEquals(List.of(memory3.getId(), memory2.getId(), memory1.getId()),
  119 + pageResult.getList().stream().map(Ai1MemoryDO::getId).toList());
  120 +
  121 + // 调用:按 Agent、用户筛选
  122 + pageResult = memoryService.getMemoryPage(new Ai1MemoryPageReqVO().setAgentId(1L).setUserId(100L));
  123 + // 断言
  124 + assertEquals(List.of(memory1.getId()), pageResult.getList().stream().map(Ai1MemoryDO::getId).toList());
  125 + }
  126 +
  127 + @Test
  128 + public void testGetMemoryByAgentIdAndUserId() {
  129 + // mock 数据
  130 + Ai1MemoryDO memory = insertMemory(1L, 100L);
  131 + insertMemory(1L, 200L);
  132 +
  133 + // 调用,并断言
  134 + assertEquals(memory.getId(), memoryService.getMemoryByAgentIdAndUserId(1L, 100L).getId());
  135 + assertNull(memoryService.getMemoryByAgentIdAndUserId(2L, 100L));
  136 + }
  137 +
  138 + @Test
  139 + public void testGetMemoryByAgentIdAndUserId_duplicate() {
  140 + // mock 数据:极端并发下同一用户产生了重复记录
  141 + insertMemory(1L, 100L);
  142 + insertMemory(1L, 100L);
  143 +
  144 + // 调用,并断言:不报错,返回其中一条
  145 + assertNotNull(memoryService.getMemoryByAgentIdAndUserId(1L, 100L));
  146 + }
  147 +
  148 + @Test
  149 + public void testCreateMemoryIfAgentExists() {
  150 + // mock 方法:Agent 1 存在,Agent 2 已删除
  151 + when(agentService.getAgent(1L)).thenReturn(Ai1AgentDO.builder().id(1L).name("客服")
  152 + .status(CommonStatusEnum.ENABLE.getStatus()).build());
  153 +
  154 + // 调用,并断言:Agent 已删除时不创建
  155 + assertFalse(memoryService.createMemoryIfAgentExists(Ai1MemoryDO.builder().agentId(2L)
  156 + .userId(100L).content("迟到的归档结果").lastMessageId(20L).build()));
  157 + assertNull(memoryMapper.selectByAgentIdAndUserId(2L, 100L));
  158 + // 调用,并断言:Agent 存在时创建
  159 + assertTrue(memoryService.createMemoryIfAgentExists(Ai1MemoryDO.builder().agentId(1L)
  160 + .userId(100L).content("用户偏好").lastMessageId(20L).build()));
  161 + assertEquals("用户偏好", memoryMapper.selectByAgentIdAndUserId(1L, 100L).getContent());
  162 + // 调用,并断言:已有未删除的记忆时不重复创建
  163 + assertFalse(memoryService.createMemoryIfAgentExists(Ai1MemoryDO.builder().agentId(1L)
  164 + .userId(100L).content("重复归档").lastMessageId(30L).build()));
  165 + assertEquals(1, memoryMapper.selectCount(Ai1MemoryDO::getAgentId, 1L));
  166 + }
  167 +
  168 + @Test
  169 + public void testCreateMemoryIfAgentExists_afterDelete() {
  170 + // mock 数据:已删除的历史记忆
  171 + when(agentService.getAgent(1L)).thenReturn(Ai1AgentDO.builder().id(1L).name("客服")
  172 + .status(CommonStatusEnum.ENABLE.getStatus()).build());
  173 + Ai1MemoryDO old = insertMemory(1L, 100L);
  174 + memoryService.deleteMemory(old.getId());
  175 +
  176 + // 调用:删除后按历史消息重新生成
  177 + assertTrue(memoryService.createMemoryIfAgentExists(Ai1MemoryDO.builder().agentId(1L)
  178 + .userId(100L).content("重建记忆").lastMessageId(5L).build()));
  179 + // 断言:生成新的记录与水位线,历史记录不影响按用户查询
  180 + Ai1MemoryDO memory = memoryService.getMemoryByAgentIdAndUserId(1L, 100L);
  181 + assertNotEquals(old.getId(), memory.getId());
  182 + assertEquals("重建记忆", memory.getContent());
  183 + assertEquals(5L, memory.getLastMessageId());
  184 + // 断言:旧快照的归档写入不能复活已删除的记录
  185 + assertFalse(memoryService.updateMemoryIfUnchanged(old, "迟到的归档结果", 30L));
  186 + assertNull(memoryService.getMemory(old.getId()));
  187 + }
  188 +
  189 + @Test
  190 + public void testUpdateMemoryIfUnchanged() {
  191 + // mock 数据
  192 + Ai1MemoryDO original = insertMemory(1L, 100L);
  193 +
  194 + // 调用,并断言:首次写入成功,按旧快照再次写入失败
  195 + assertTrue(memoryService.updateMemoryIfUnchanged(original, "新记忆", 20L));
  196 + assertFalse(memoryService.updateMemoryIfUnchanged(original, "过期结果", 30L));
  197 + Ai1MemoryDO memory = memoryMapper.selectById(original.getId());
  198 + assertEquals("新记忆", memory.getContent());
  199 + assertEquals(20L, memory.getLastMessageId());
  200 + }
  201 +
  202 + @Test
  203 + public void testUpdateMemoryIfUnchanged_edited() {
  204 + // mock 数据:归档读取后,人工只在末尾加了空格,水位线不变
  205 + Ai1MemoryDO original = insertMemory(1L, 100L);
  206 + memoryMapper.updateById(new Ai1MemoryDO().setId(original.getId()).setContent("## 用户画像\n- 旧内容 "));
  207 +
  208 + // 调用,并断言:放弃写入,保留人工修改
  209 + assertFalse(memoryService.updateMemoryIfUnchanged(original, "归档结果", 20L));
  210 + Ai1MemoryDO memory = memoryMapper.selectById(original.getId());
  211 + assertEquals("## 用户画像\n- 旧内容 ", memory.getContent());
  212 + assertEquals(10L, memory.getLastMessageId());
  213 + }
  214 +
  215 + // ========== 测试数据 ==========
  216 +
  217 + private Ai1MemoryDO insertMemory(Long agentId, Long userId) {
  218 + Ai1MemoryDO memory = Ai1MemoryDO.builder().agentId(agentId).userId(userId)
  219 + .content("## 用户画像\n- 旧内容").lastMessageId(10L).build();
  220 + memoryMapper.insert(memory);
  221 + return memory;
  222 + }
  223 +
  224 +}
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageServiceImplTest.java
1 package cn.iocoder.yudao.module.ai1.service.session; 1 package cn.iocoder.yudao.module.ai1.service.session;
2 2
  3 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
3 import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; 4 import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
4 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; 5 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
  6 +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessagePageReqVO;
5 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO; 7 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO;
6 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; 8 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO;
7 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; 9 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO;
8 import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMessageMapper; 10 import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMessageMapper;
  11 +import cn.iocoder.yudao.module.ai1.dal.redis.memory.Ai1MemoryLockRedisDAO;
9 import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; 12 import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum;
10 import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; 13 import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum;
11 import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; 14 import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService;
@@ -17,6 +20,9 @@ import org.junit.jupiter.api.BeforeEach; @@ -17,6 +20,9 @@ import org.junit.jupiter.api.BeforeEach;
17 import org.junit.jupiter.api.Test; 20 import org.junit.jupiter.api.Test;
18 import org.junit.jupiter.params.ParameterizedTest; 21 import org.junit.jupiter.params.ParameterizedTest;
19 import org.junit.jupiter.params.provider.ValueSource; 22 import org.junit.jupiter.params.provider.ValueSource;
  23 +import org.mockito.InOrder;
  24 +import org.redisson.api.RLock;
  25 +import org.redisson.api.RedissonClient;
20 import org.redisson.client.RedisConnectionException; 26 import org.redisson.client.RedisConnectionException;
21 import org.redisson.client.RedisTimeoutException; 27 import org.redisson.client.RedisTimeoutException;
22 import org.springframework.context.annotation.Import; 28 import org.springframework.context.annotation.Import;
@@ -24,9 +30,11 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -24,9 +30,11 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
24 import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; 30 import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
25 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; 31 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
26 32
  33 +import java.util.ArrayList;
27 import java.util.List; 34 import java.util.List;
28 35
29 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 36 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  37 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
30 import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.SESSION_NOT_EXISTS; 38 import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.SESSION_NOT_EXISTS;
31 import static org.junit.jupiter.api.Assertions.*; 39 import static org.junit.jupiter.api.Assertions.*;
32 import static org.mockito.ArgumentMatchers.*; 40 import static org.mockito.ArgumentMatchers.*;
@@ -37,7 +45,7 @@ import static org.mockito.Mockito.*; @@ -37,7 +45,7 @@ import static org.mockito.Mockito.*;
37 * 45 *
38 * @author 芋道源码 46 * @author 芋道源码
39 */ 47 */
40 -@Import(Ai1SessionMessageServiceImpl.class) 48 +@Import({Ai1SessionMessageServiceImpl.class, Ai1MemoryLockRedisDAO.class})
41 public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest { 49 public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest {
42 50
43 private static final Long TENANT_ID = 1L; 51 private static final Long TENANT_ID = 1L;
@@ -55,11 +63,17 @@ public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest { @@ -55,11 +63,17 @@ public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest {
55 private Ai1AgentService agentService; 63 private Ai1AgentService agentService;
56 @MockitoBean 64 @MockitoBean
57 private Ai1SessionStreamTool sessionStreamTool; 65 private Ai1SessionStreamTool sessionStreamTool;
  66 + @MockitoBean
  67 + private RedissonClient redissonClient;
  68 +
  69 + private final RLock snapshotLock = mock(RLock.class);
58 70
59 @BeforeEach 71 @BeforeEach
60 public void before() { 72 public void before() {
61 // 设置租户上下文 73 // 设置租户上下文
62 TenantContextHolder.setTenantId(TENANT_ID); 74 TenantContextHolder.setTenantId(TENANT_ID);
  75 + // mock 记忆快照锁
  76 + when(redissonClient.getLock(anyString())).thenReturn(snapshotLock);
63 } 77 }
64 78
65 @AfterEach 79 @AfterEach
@@ -100,6 +114,71 @@ public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest { @@ -100,6 +114,71 @@ public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest {
100 } 114 }
101 115
102 @Test 116 @Test
  117 + public void testSendSessionMessageStream_snapshotLockUntilCommitted() {
  118 + // mock sessionService 的方法
  119 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO()
  120 + .setId(10L).setAgentId(1L).setUserId(USER_ID).setTitle(Ai1SessionDO.TITLE_DEFAULT));
  121 + // mock 释放快照锁时,本轮消息已提交
  122 + List<Integer> committedCounts = new ArrayList<>();
  123 + doAnswer(invocation -> {
  124 + committedCounts.add(sessionMessageMapper.selectListBySessionId(10L).size());
  125 + return null;
  126 + }).when(snapshotLock).unlock();
  127 +
  128 + // 调用
  129 + sessionMessageService.sendSessionMessageStream(USER_ID,
  130 + new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好"));
  131 + // 断言:按 Agent、用户加锁,落库后才释放,再投递任务
  132 + verify(redissonClient).getLock("ai1:memory:snapshot-lock:1:" + USER_ID);
  133 + assertEquals(List.of(2), committedCounts);
  134 + InOrder inOrder = inOrder(snapshotLock, sessionStreamTool);
  135 + inOrder.verify(snapshotLock).lock();
  136 + inOrder.verify(snapshotLock).unlock();
  137 + inOrder.verify(sessionStreamTool).submit(anyLong(), eq(1L), eq(10L), eq("你好"));
  138 + }
  139 +
  140 + @Test
  141 + public void testStopSessionMessageMy_success() {
  142 + // 调用
  143 + sessionMessageService.stopSessionMessageMy(USER_ID, 10L);
  144 + // 断言
  145 + InOrder inOrder = inOrder(sessionService, sessionStreamTool);
  146 + inOrder.verify(sessionService).validateSessionMy(USER_ID, 10L);
  147 + inOrder.verify(sessionStreamTool).cancel(10L);
  148 + }
  149 +
  150 + @Test
  151 + public void testStopSessionMessageMy_notOwner() {
  152 + // mock sessionService 的方法
  153 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenThrow(exception(SESSION_NOT_EXISTS));
  154 +
  155 + // 调用,并断言异常
  156 + assertServiceException(() -> sessionMessageService.stopSessionMessageMy(USER_ID, 10L), SESSION_NOT_EXISTS);
  157 + // 断言
  158 + verify(sessionStreamTool, never()).cancel(anyLong());
  159 + }
  160 +
  161 + @Test
  162 + public void testGetSessionMessagePage() {
  163 + // mock 数据
  164 + Ai1SessionMessageDO message1 = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.USER.getRole());
  165 + Ai1SessionMessageDO message2 = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole());
  166 + Ai1SessionMessageDO message3 = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.USER.getRole());
  167 + insertSessionMessage(11L, Ai1SessionMessageRoleEnum.USER.getRole());
  168 + // 准备参数
  169 + Ai1SessionMessagePageReqVO reqVO = new Ai1SessionMessagePageReqVO().setSessionId(10L);
  170 + reqVO.setPageSize(2);
  171 +
  172 + // 调用
  173 + PageResult<Ai1SessionMessageDO> pageResult = sessionMessageService.getSessionMessagePage(reqVO);
  174 + // 断言:按编号倒序
  175 + assertEquals(3, pageResult.getTotal());
  176 + assertEquals(List.of(message3.getId(), message2.getId()),
  177 + pageResult.getList().stream().map(Ai1SessionMessageDO::getId).toList());
  178 + assertNotEquals(message1.getId(), pageResult.getList().get(1).getId());
  179 + }
  180 +
  181 + @Test
103 public void testSendSessionMessageStream_submitFailed() { 182 public void testSendSessionMessageStream_submitFailed() {
104 // mock sessionService 和 sessionStreamTool 的方法 183 // mock sessionService 和 sessionStreamTool 的方法
105 when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO() 184 when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO()
@@ -122,10 +201,11 @@ public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest { @@ -122,10 +201,11 @@ public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest {
122 201
123 @ParameterizedTest 202 @ParameterizedTest
124 @ValueSource(ints = {1, 2}) 203 @ValueSource(ints = {1, 2})
125 - public void testSendSessionMessageStream_submitTimeoutKeepsWorkerResult(int status) { 204 + public void testSendSessionMessageStream_submitTimeoutAfterWorkerCompleted(int status) {
126 // mock sessionService 和 sessionStreamTool 的方法 205 // mock sessionService 和 sessionStreamTool 的方法
127 when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO() 206 when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO()
128 .setId(10L).setAgentId(1L).setTitle(Ai1SessionDO.TITLE_DEFAULT)); 207 .setId(10L).setAgentId(1L).setTitle(Ai1SessionDO.TITLE_DEFAULT));
  208 + // mock 投递实际成功,worker 在投递超时前已回填终态
129 doAnswer(invocation -> { 209 doAnswer(invocation -> {
130 Long messageId = invocation.getArgument(0); 210 Long messageId = invocation.getArgument(0);
131 sessionMessageMapper.updateById(new Ai1SessionMessageDO().setId(messageId) 211 sessionMessageMapper.updateById(new Ai1SessionMessageDO().setId(messageId)
@@ -136,7 +216,7 @@ public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest { @@ -136,7 +216,7 @@ public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest {
136 // 调用 216 // 调用
137 sessionMessageService.sendSessionMessageStream(USER_ID, 217 sessionMessageService.sendSessionMessageStream(USER_ID,
138 new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好")); 218 new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好"));
139 - // 断言已有终态和内容不被补偿覆盖 219 + // 断言:补偿只更新生成中的消息,已有终态和内容不被覆盖
140 Ai1SessionMessageDO assistant = sessionMessageMapper.selectListBySessionId(10L).get(1); 220 Ai1SessionMessageDO assistant = sessionMessageMapper.selectListBySessionId(10L).get(1);
141 assertEquals(status, assistant.getStatus()); 221 assertEquals(status, assistant.getStatus());
142 assertEquals("worker 已回填", assistant.getContent()); 222 assertEquals("worker 已回填", assistant.getContent());
@@ -145,28 +225,6 @@ public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest { @@ -145,28 +225,6 @@ public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest {
145 } 225 }
146 226
147 @Test 227 @Test
148 - public void testSendSessionMessageStream_workerCanCompleteAfterSubmitTimeout() {  
149 - // mock sessionService 和 sessionStreamTool 的方法  
150 - when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO()  
151 - .setId(10L).setAgentId(1L).setTitle(Ai1SessionDO.TITLE_DEFAULT));  
152 - doThrow(new RedisTimeoutException("投递响应丢失")).when(sessionStreamTool)  
153 - .submit(anyLong(), eq(1L), eq(10L), eq("你好"));  
154 -  
155 - // 调用  
156 - sessionMessageService.sendSessionMessageStream(USER_ID,  
157 - new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好"));  
158 - Ai1SessionMessageDO assistant = sessionMessageMapper.selectListBySessionId(10L).get(1);  
159 - assertEquals(Ai1SessionMessageStatusEnum.FAILED.getStatus(), assistant.getStatus());  
160 - // 调用 worker 回填,模拟 Redis 实际写入后响应丢失  
161 - sessionMessageService.updateSessionMessage(new Ai1SessionMessageDO().setId(assistant.getId())  
162 - .setStatus(Ai1SessionMessageStatusEnum.SUCCESS.getStatus()).setContent("稍后生成成功"));  
163 - // 断言后续完成结果仍可落库  
164 - Ai1SessionMessageDO result = sessionMessageMapper.selectById(assistant.getId());  
165 - assertEquals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), result.getStatus());  
166 - assertEquals("稍后生成成功", result.getContent());  
167 - }  
168 -  
169 - @Test  
170 public void testSendSessionMessageStream_openFailedKeepsGenerating() { 228 public void testSendSessionMessageStream_openFailedKeepsGenerating() {
171 // mock sessionService 和 sessionStreamTool 的方法 229 // mock sessionService 和 sessionStreamTool 的方法
172 when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO() 230 when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO()
@@ -322,11 +380,71 @@ public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest { @@ -322,11 +380,71 @@ public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest {
322 assertNotEquals(message1.getId(), list.get(1).getId()); 380 assertNotEquals(message1.getId(), list.get(1).getId());
323 } 381 }
324 382
  383 + @Test
  384 + public void testGetSessionMessageListBySessionIdsAndStatusAndIdBetween_safePrefix() {
  385 + // mock 数据:同一用户两个会话,第一个会话的助手消息仍在生成
  386 + String user = Ai1SessionMessageRoleEnum.USER.getRole();
  387 + String assistant = Ai1SessionMessageRoleEnum.ASSISTANT.getRole();
  388 + Ai1SessionMessageDO message1 = insertSessionMessage(10L, user);
  389 + Ai1SessionMessageDO message2 = insertSessionMessage(10L, assistant, Ai1SessionMessageStatusEnum.GENERATING.getStatus());
  390 + Ai1SessionMessageDO message3 = insertSessionMessage(11L, user);
  391 + insertSessionMessage(12L, user);
  392 + insertSessionMessage(11L, assistant, Ai1SessionMessageStatusEnum.FAILED.getStatus());
  393 + List<Long> sessionIds = List.of(10L, 11L);
  394 +
  395 + // 调用:只读取第一条生成中消息之前的已完成消息
  396 + Ai1SessionMessageDO generating = sessionMessageService.getFirstSessionMessageBySessionIdsAndStatusAndIdGt(sessionIds,
  397 + Ai1SessionMessageStatusEnum.GENERATING.getStatus(), 0L);
  398 + List<Ai1SessionMessageDO> first = sessionMessageService.getSessionMessageListBySessionIdsAndStatusAndIdBetween(sessionIds,
  399 + Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), 0L, generating.getId(), 100);
  400 + // 断言
  401 + assertEquals(message2.getId(), generating.getId());
  402 + assertEquals(List.of(message1.getId()), first.stream().map(Ai1SessionMessageDO::getId).toList());
  403 +
  404 + // mock 生成结束
  405 + sessionMessageMapper.updateById(new Ai1SessionMessageDO().setId(message2.getId())
  406 + .setStatus(Ai1SessionMessageStatusEnum.SUCCESS.getStatus()));
  407 + // 调用:从水位线之后读取,并按条数截断
  408 + assertNull(sessionMessageService.getFirstSessionMessageBySessionIdsAndStatusAndIdGt(sessionIds,
  409 + Ai1SessionMessageStatusEnum.GENERATING.getStatus(), message1.getId()));
  410 + List<Ai1SessionMessageDO> limited = sessionMessageService.getSessionMessageListBySessionIdsAndStatusAndIdBetween(sessionIds,
  411 + Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), message1.getId(), null, 1);
  412 + List<Ai1SessionMessageDO> all = sessionMessageService.getSessionMessageListBySessionIdsAndStatusAndIdBetween(sessionIds,
  413 + Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), message1.getId(), null, 100);
  414 + // 断言
  415 + assertEquals(List.of(message2.getId()), limited.stream().map(Ai1SessionMessageDO::getId).toList());
  416 + assertEquals(List.of(message2.getId(), message3.getId()), all.stream().map(Ai1SessionMessageDO::getId).toList());
  417 + }
  418 +
  419 + @Test
  420 + public void testGetGeneratingAssistantMessageIdList() {
  421 + // mock 数据:只有本会话生成中的助手消息命中
  422 + insertSessionMessage(10L, Ai1SessionMessageRoleEnum.USER.getRole());
  423 + Ai1SessionMessageDO message1 = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole(),
  424 + Ai1SessionMessageStatusEnum.GENERATING.getStatus());
  425 + insertSessionMessage(10L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole());
  426 + insertSessionMessage(10L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole(), Ai1SessionMessageStatusEnum.FAILED.getStatus());
  427 + insertSessionMessage(10L, Ai1SessionMessageRoleEnum.USER.getRole(), Ai1SessionMessageStatusEnum.GENERATING.getStatus());
  428 + Ai1SessionMessageDO message2 = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole(),
  429 + Ai1SessionMessageStatusEnum.GENERATING.getStatus());
  430 + insertSessionMessage(11L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole(), Ai1SessionMessageStatusEnum.GENERATING.getStatus());
  431 +
  432 + // 调用
  433 + List<Long> ids = sessionMessageService.getGeneratingAssistantMessageIdList(10L);
  434 + // 断言:只返回本会话生成中的助手消息编号,按编号升序
  435 + assertEquals(List.of(message1.getId(), message2.getId()), ids);
  436 + assertTrue(sessionMessageService.getGeneratingAssistantMessageIdList(12L).isEmpty());
  437 + }
  438 +
325 // ========== 测试数据 ========== 439 // ========== 测试数据 ==========
326 440
327 private Ai1SessionMessageDO insertSessionMessage(Long sessionId, String role) { 441 private Ai1SessionMessageDO insertSessionMessage(Long sessionId, String role) {
  442 + return insertSessionMessage(sessionId, role, Ai1SessionMessageStatusEnum.SUCCESS.getStatus());
  443 + }
  444 +
  445 + private Ai1SessionMessageDO insertSessionMessage(Long sessionId, String role, Integer status) {
328 Ai1SessionMessageDO message = new Ai1SessionMessageDO().setSessionId(sessionId).setRole(role).setContent("内容") 446 Ai1SessionMessageDO message = new Ai1SessionMessageDO().setSessionId(sessionId).setRole(role).setContent("内容")
329 - .setStatus(Ai1SessionMessageStatusEnum.SUCCESS.getStatus()); 447 + .setStatus(status);
330 sessionMessageMapper.insert(message); 448 sessionMessageMapper.insert(message);
331 return message; 449 return message;
332 } 450 }
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionServiceImplTest.java
1 package cn.iocoder.yudao.module.ai1.service.session; 1 package cn.iocoder.yudao.module.ai1.service.session;
2 2
  3 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
3 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; 4 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
4 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionCreateMyReqVO; 5 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionCreateMyReqVO;
  6 +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionPageReqVO;
5 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionUpdateMyReqVO; 7 import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionUpdateMyReqVO;
6 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; 8 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO;
7 import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMapper; 9 import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMapper;
@@ -104,4 +106,44 @@ public class Ai1SessionServiceImplTest extends BaseDbUnitTest { @@ -104,4 +106,44 @@ public class Ai1SessionServiceImplTest extends BaseDbUnitTest {
104 assertEquals(Arrays.asList(id2, id1), list.stream().map(Ai1SessionDO::getId).toList()); 106 assertEquals(Arrays.asList(id2, id1), list.stream().map(Ai1SessionDO::getId).toList());
105 } 107 }
106 108
  109 + @Test
  110 + public void testGetUserIdListByAgentId() {
  111 + // mock 数据
  112 + sessionService.createSessionMy(200L, new Ai1SessionCreateMyReqVO().setAgentId(1L));
  113 + sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(1L));
  114 + sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(1L));
  115 + sessionService.createSessionMy(300L, new Ai1SessionCreateMyReqVO().setAgentId(2L));
  116 +
  117 + // 调用,并断言:去重并按用户编号升序
  118 + assertEquals(Arrays.asList(100L, 200L), sessionService.getUserIdListByAgentId(1L));
  119 + }
  120 +
  121 + @Test
  122 + public void testGetSession() {
  123 + // mock 数据
  124 + Long id = sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(1L));
  125 +
  126 + // 调用,并断言
  127 + assertEquals(100L, sessionService.getSession(id).getUserId());
  128 + assertNull(sessionService.getSession(id + 1));
  129 + }
  130 +
  131 + @Test
  132 + public void testGetSessionPage_withoutAgentId() {
  133 + // mock 数据
  134 + Long id1 = sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(1L));
  135 + Long id2 = sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(2L));
  136 + Long id3 = sessionService.createSessionMy(200L, new Ai1SessionCreateMyReqVO().setAgentId(2L));
  137 +
  138 + // 调用:不传 Agent 时跨 Agent 查询
  139 + PageResult<Ai1SessionDO> pageResult = sessionService.getSessionPage(new Ai1SessionPageReqVO());
  140 + // 断言
  141 + assertEquals(Arrays.asList(id3, id2, id1), pageResult.getList().stream().map(Ai1SessionDO::getId).toList());
  142 +
  143 + // 调用:按 Agent、用户筛选
  144 + pageResult = sessionService.getSessionPage(new Ai1SessionPageReqVO().setAgentId(2L).setUserId(100L));
  145 + // 断言
  146 + assertEquals(Collections.singletonList(id2), pageResult.getList().stream().map(Ai1SessionDO::getId).toList());
  147 + }
  148 +
107 } 149 }
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/util/Ai1UtilsTest.java
@@ -17,6 +17,8 @@ import static org.mockito.Mockito.mockStatic; @@ -17,6 +17,8 @@ import static org.mockito.Mockito.mockStatic;
17 17
18 public class Ai1UtilsTest { 18 public class Ai1UtilsTest {
19 19
  20 + private static final List<String> MEMORY_SECTIONS = List.of("用户画像", "偏好习惯", "关键事实", "目标与意图", "近期进展");
  21 +
20 @Test 22 @Test
21 public void testNormalizeHeaders_orderAndValues() { 23 public void testNormalizeHeaders_orderAndValues() {
22 // 准备参数 24 // 准备参数
@@ -116,4 +118,55 @@ public class Ai1UtilsTest { @@ -116,4 +118,55 @@ public class Ai1UtilsTest {
116 assertFalse(ex.getMessage().contains("literal-secret")); 118 assertFalse(ex.getMessage().contains("literal-secret"));
117 } 119 }
118 } 120 }
  121 +
  122 + @Test
  123 + public void testNormalizeMemoryMarkdown_sections() {
  124 + // 调用,并断言:标题与内容压成一行时拆开,不同维度之间空一行
  125 + assertEquals("## 用户画像\n- 后端工程师\n\n## 偏好习惯\n- 简洁回答",
  126 + Ai1Utils.normalizeMemoryMarkdown("##用户画像-后端工程师##偏好习惯-简洁回答", MEMORY_SECTIONS));
  127 + // 调用,并断言:统一换行,合并多余空行
  128 + assertEquals("## 用户画像\n- 程序员\n\n## 关键事实\n- 住在北京",
  129 + Ai1Utils.normalizeMemoryMarkdown(" ## 用户画像\r\n-程序员\r\n\r\n\r\n## 关键事实\r- 住在北京 ", MEMORY_SECTIONS));
  130 + }
  131 +
  132 + @Test
  133 + public void testNormalizeMemoryMarkdown_keepsFacts() {
  134 + // 准备参数:C#、#123、URL 锚点、中文破折号都是用户事实,不能被拆开
  135 + String content = "## 关键事实\n- 主力语言 C#,工单#123,文档 https://a.cn/doc#part\n- 常驻北京—上海,周期 3–5 天";
  136 +
  137 + // 调用,并断言
  138 + assertEquals(content, Ai1Utils.normalizeMemoryMarkdown(content, MEMORY_SECTIONS));
  139 + // 调用,并断言:非固定维度的行内标题不拆行
  140 + assertEquals("## 关键事实\n- 标签##其他", Ai1Utils.normalizeMemoryMarkdown("## 关键事实\n- 标签##其他", MEMORY_SECTIONS));
  141 + }
  142 +
  143 + @Test
  144 + public void testNormalizeMemoryMarkdown_inlineList() {
  145 + // 调用,并断言:非 ASCII 字符后紧跟 ASCII「-」,拆成列表项
  146 + assertEquals("## 偏好习惯\n- 口吻正式\n- 开场先问候",
  147 + Ai1Utils.normalizeMemoryMarkdown("## 偏好习惯\n- 口吻正式-开场先问候", MEMORY_SECTIONS));
  148 + // 调用,并断言:ASCII 字符之间的「-」不拆
  149 + assertEquals("## 关键事实\n- 使用 spring-boot", Ai1Utils.normalizeMemoryMarkdown("## 关键事实\n- 使用 spring-boot", MEMORY_SECTIONS));
  150 + }
  151 +
  152 + @Test
  153 + public void testNormalizeMemoryMarkdown_blank() {
  154 + // 调用,并断言:为空时原样返回
  155 + assertNull(Ai1Utils.normalizeMemoryMarkdown(null, MEMORY_SECTIONS));
  156 + assertEquals(" ", Ai1Utils.normalizeMemoryMarkdown(" ", MEMORY_SECTIONS));
  157 + }
  158 +
  159 + @Test
  160 + public void testLimitLength() {
  161 + // 调用,并断言:先去除首尾空白,未超长时不截断
  162 + assertEquals("用户喜欢", Ai1Utils.limitLength(" 用户喜欢 \n", 4));
  163 + // 调用,并断言:下标 4、5 的 char 是同一个 emoji,截断不拆开代理对
  164 + assertEquals("用户喜欢", Ai1Utils.limitLength("用户喜欢😀表情", 5));
  165 + assertEquals("用户喜欢😀", Ai1Utils.limitLength("用户喜欢😀表情", 6));
  166 + assertEquals("用户喜欢😀表", Ai1Utils.limitLength("用户喜欢😀表情", 7));
  167 + // 调用,并断言:为空时原样返回
  168 + assertNull(Ai1Utils.limitLength(null, 5));
  169 + assertEquals(" ", Ai1Utils.limitLength(" ", 5));
  170 + }
  171 +
119 } 172 }
yudao-module-ai1-springai/src/test/resources/sql/clean.sql
@@ -8,3 +8,4 @@ DELETE FROM &quot;ai1_knowledge_document&quot;; @@ -8,3 +8,4 @@ DELETE FROM &quot;ai1_knowledge_document&quot;;
8 DELETE FROM "ai1_agent"; 8 DELETE FROM "ai1_agent";
9 DELETE FROM "ai1_session"; 9 DELETE FROM "ai1_session";
10 DELETE FROM "ai1_session_message"; 10 DELETE FROM "ai1_session_message";
  11 +DELETE FROM "ai1_memory";
yudao-module-ai1-springai/src/test/resources/sql/create_tables.sql
@@ -152,6 +152,7 @@ CREATE TABLE IF NOT EXISTS &quot;ai1_agent&quot; ( @@ -152,6 +152,7 @@ CREATE TABLE IF NOT EXISTS &quot;ai1_agent&quot; (
152 "knowledge_base_ids" varchar(500) DEFAULT NULL, 152 "knowledge_base_ids" varchar(500) DEFAULT NULL,
153 "mcp_ids" varchar(500) DEFAULT NULL, 153 "mcp_ids" varchar(500) DEFAULT NULL,
154 "skill_ids" varchar(500) DEFAULT NULL, 154 "skill_ids" varchar(500) DEFAULT NULL,
  155 + "memory_enabled" bit NOT NULL DEFAULT FALSE,
155 "status" tinyint NOT NULL DEFAULT 1, 156 "status" tinyint NOT NULL DEFAULT 1,
156 "creator" varchar(64) DEFAULT '', 157 "creator" varchar(64) DEFAULT '',
157 "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 158 "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -197,3 +198,21 @@ CREATE TABLE IF NOT EXISTS &quot;ai1_session_message&quot; ( @@ -197,3 +198,21 @@ CREATE TABLE IF NOT EXISTS &quot;ai1_session_message&quot; (
197 "tenant_id" bigint NOT NULL DEFAULT 0, 198 "tenant_id" bigint NOT NULL DEFAULT 0,
198 PRIMARY KEY ("id") 199 PRIMARY KEY ("id")
199 ); 200 );
  201 +
  202 +-- ----------------------------
  203 +-- AI1 长期记忆
  204 +-- ----------------------------
  205 +CREATE TABLE IF NOT EXISTS "ai1_memory" (
  206 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
  207 + "agent_id" bigint NOT NULL,
  208 + "user_id" bigint NOT NULL,
  209 + "content" mediumtext NOT NULL,
  210 + "last_message_id" bigint NOT NULL DEFAULT 0,
  211 + "creator" varchar(64) DEFAULT '',
  212 + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  213 + "updater" varchar(64) DEFAULT '',
  214 + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  215 + "deleted" bit NOT NULL DEFAULT FALSE,
  216 + "tenant_id" bigint NOT NULL DEFAULT 0,
  217 + PRIMARY KEY ("id")
  218 +);