diff --git a/.antigravity/BASELINE.md b/.antigravity/BASELINE.md index 72697ac..c119a9d 100644 --- a/.antigravity/BASELINE.md +++ b/.antigravity/BASELINE.md @@ -7,7 +7,8 @@ - **关键组件**: - `CrmTools.java`: 系统的“消息枢纽”,包含所有 `@Tool` 方法(Define, Save, Delete),直接与 Repository 交互。 - `AgentController.java`: 主要入口,负责处理自然语言指令或业务事实报告。 - - **隔离机制**:实现了基于 Header `X-Creator-Id` 的简易用户 Session 隔离,所有数据查询和变更均强制绑定 `creatorId`。 + - `CopilotKitController.java`: **新增**,充当 CopilotKit 的后端运行时,统一通过 LangChain4j 调用 AI。 + - [x] **业务模块共享**:移除了 `/api/entities` 的 `creatorId` 过滤,所有用户可共享业务实体定义 (Schema),但业务数据记录目前仍保留隔离。 ## 💼 业务逻辑 (Business Logic) - **动态模块**:系统支持通过 AI 指令动态创建业务实体(Entities),并在数据库中自动维护。 @@ -16,17 +17,25 @@ ## 📈 当前开发进度 (Current Progress) - [x] **AI 原生 CRUD**:已实现 Agent 驱动的插入、更新和删除。 - [x] **实体关联**:支持通过 `x-link-entity` 扩展 Schema 实现实体间关联选择。 -- [x] **用户会话隔离**:已完成后端拦截与过滤逻辑,确保多用户数据不串线。 +- [x] **用户会话隔离**:已完成后端拦截与过滤逻辑。 +- [x] **CopilotKit 后端转发**:已实现 AI 交互的后端中转,提升了安全性。 - [x] **CORS 配置**:已解决前后端跨域通信问题。 +- [x] **CopilotKit v1 协议适配**: 修复了由于后端控制器 DTO 结构不匹配导致的 Runtime 协议消息解析异常(messages 嵌套在 body 中)。 +- [x] **Bug Fix**: 修复了 `CopilotKitController` 处理空请求时的 `NullPointerException`。 +- [x] **接口扩充**: 新增了 `/api/copilotkit/info` 发现接口,以适配前端 CopilotKit 库的启动流程。 +- [x] **流式配置修复**: 修复了 `CrmAgent` 在处理流式请求时因缺失 `StreamingChatLanguageModel` 导致的 `IllegalArgumentException`。 +- [x] **流式协议修正**: 将 `ResponseBodyEmitter` 恢复为 `SseEmitter`。并在流开始时强制发送 `threadId` 和 `runId` 初始事件,确保前端 `@copilotkit/core` 能正确挂载响应流。同时移除了末尾冗余的 `result` 类型消息以避免解析冲突。 +- [x] **流式数据格式修正**: 修复了由于 `SseEmitter` 自动追加 `data:` 前缀导致前端 `@copilotkit/runtime` 无法解析流的问题,将实现替换为 `ResponseBodyEmitter` 结合手动追加 `\n` 的 NDJSON(换行分隔 JSON)格式。 +- [x] **Runtime Info 修复**: 修复了 `/api/copilotkit/info` 的响应结构,将 `agents` 从列表改为 Map (Object),并显式声明了 `default` agent。 ## ⚠️ 尚未解决的隐患 (Known Issues/Risks) -- **并发冲突**:动态 Schema 变更在极高并发下的稳定性待验证。 +- **共享冲突**:当多用户同时修改同一个公共实体的 Schema 时,可能产生覆盖风险。 - **权限细化**:目前的 `X-Creator-Id` 仅做基础隔离,缺乏细粒度的 RBAC。 ## 🚀 下一步计划 (Next Steps) -1. **AI 增强搜索**:实现通过 AI 生成复杂查询逻辑。 -2. **仪表盘分析**:开发动态数据的 AI 汇总分析模块。 -3. **性能监控**:针对 AI Tool 调用的耗时进行埋点监控。 +1. **数据共享开关**:支持针对特定实体配置是否跨用户共享数据。 +2. **AI 增强搜索**:实现通过 AI 生成复杂查询逻辑。 +3. **仪表盘分析**:开发动态数据的 AI 汇总分析模块。 --- *Last Updated: 2026-03-25 by Antigravity* diff --git a/src/main/java/com/example/ainative/ai/AgentResponse.java b/src/main/java/com/example/ainative/ai/AgentResponse.java new file mode 100644 index 0000000..211f5e4 --- /dev/null +++ b/src/main/java/com/example/ainative/ai/AgentResponse.java @@ -0,0 +1,22 @@ +package com.example.ainative.ai; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class AgentResponse { + /** + * AI 生成的对话内容 + */ + private String content; + + /** + * 业务状态标识 (比如: SUCCESS, NEED_INFO, ERROR) + */ + private String status; +} diff --git a/src/main/java/com/example/ainative/ai/CrmAgent.java b/src/main/java/com/example/ainative/ai/CrmAgent.java index f9e4588..fc20cee 100644 --- a/src/main/java/com/example/ainative/ai/CrmAgent.java +++ b/src/main/java/com/example/ainative/ai/CrmAgent.java @@ -4,6 +4,7 @@ import dev.langchain4j.service.MemoryId; import dev.langchain4j.service.SystemMessage; import dev.langchain4j.service.UserMessage; import dev.langchain4j.service.V; +import dev.langchain4j.service.TokenStream; import dev.langchain4j.service.spring.AiService; @AiService(tools = "crmTools") @@ -34,5 +35,35 @@ public interface CrmAgent { 当前租户上下文 ID: {{userContext}} """) - String chat(@MemoryId String memoryId, @UserMessage String userMessage, @V("userContext") String userContext); + TokenStream chat(@MemoryId String memoryId, @UserMessage String userMessage, @V("userContext") String userContext); + + + // 用于普通同步调用 (重载方法,返回 String) + @SystemMessage(""" + ## 角色与愿景 + 你是一个 AI 原生 CRM 系统的核心引擎。你的任务是通过自然语言管理业务模块(Schema)和动态数据,替代传统的 REST 接口。 + + ## 核心运行准则(严禁违反) + 1. **执行重于对话**:系统已禁用传统的增删改接口,所有数据变更必须通过调用工具实现。 + 2. **禁止虚假模拟**:严禁在未成功调用工具的情况下,回复任何暗示操作已完成的内容(如“已保存”、“已创建”)。 + 3. **事实驱动**:前端会报告“业务事实”(如:用户提交了表单内容),你必须根据事实判断该调用哪个工具(如:insertDynamicData)。 + + ## 建模协议 (defineDynamicModule) + 当定义或更新模块结构时,生成的 JSON Schema 需遵循: + - **实体关联**:若字段需关联其他实体,必须在属性中加入 `"x-link-entity": "目标实体Code"`。 + - **约束增强**:优先使用 `enum` (下拉选项)、`pattern` (正则校验)、`format: "date"` 等标准 JSON Schema 约束。 + - **命名规范**:entityCode 采用下划线命名法(如:student_course)。 + + ## 数据操作协议 + - **入库 (insert)**:将用户提供的或事实报告中的信息提取为 JSON 字符串。 + - **物理删除 (delete)**:仅在用户明确指令或业务流程需要时执行。 + + ## 输出风格 + - 保持简洁。 + - 必须在工具执行成功后,再简要反馈执行结果或后续 UI 操作建议。 + - **结构化输出**:请直接以 JSON 格式返回,包含 `content` (对话内容) 和 `status` (SUCCESS/ERROR/NEED_INFO) 字段。 + + 当前租户上下文 ID: {{userContext}} + """) + AgentResponse chatSync(@MemoryId String memoryId, @UserMessage String userMessage, @V("userContext") String userContext); } diff --git a/src/main/java/com/example/ainative/ai/CrmTools.java b/src/main/java/com/example/ainative/ai/CrmTools.java index 20d47d3..28014c8 100644 --- a/src/main/java/com/example/ainative/ai/CrmTools.java +++ b/src/main/java/com/example/ainative/ai/CrmTools.java @@ -75,9 +75,25 @@ public class CrmTools { } } - /** - * AI 工具封装:删除指定的业务数据。 - */ + @Transactional + @Tool("核心业务处理:根据记录 ID 更新已有的动态业务数据。参数:id (记录的唯一 UUID), jsonData (更新后的完整 JSON 数据)。") + public String updateDynamicData(String id, String jsonData) { + log.info("Agent Tool 正在处理消息:更新记录 ID: {}", id); + try { + java.util.UUID uuid = java.util.UUID.fromString(id); + return sysDynamicDataRepository.findById(uuid) + .map(record -> { + record.setData(jsonData); + sysDynamicDataRepository.save(record); + return "AI 已成功更新记录 (ID: " + id + ")。"; + }) + .orElse("AI 无法找到 ID 为 [" + id + "] 的记录,可能已被删除。"); + } catch (Exception e) { + log.error("AI 更新数据失败: {}", e.getMessage()); + return "更新操作遇到技术故障: " + e.getMessage(); + } + } + @Transactional @Tool("核心业务处理:根据 ID 物理删除指定的业务记录。参数:id (记录的唯一 UUID)。") public String deleteDynamicData(String id) { @@ -94,6 +110,31 @@ public class CrmTools { return "删除操作遇到技术故障: " + e.getMessage(); } } + + @Tool("核心业务处理:查询指定模块下的所有业务数据。参数:entityCode (模块代码)。返回该模块下当前用户的所有记录 JSON 数组。") + public String queryDynamicData(String entityCode) { + log.info("Agent Tool 正在处理消息:查询模块数据: Code={}", entityCode); + try { + SysEntity entity = sysEntityRepository.findByEntityCode(entityCode); + if (entity == null) { + return "AI 无法找到模块 [" + entityCode + "]。"; + } + java.util.List records = sysDynamicDataRepository.findByEntityIdAndCreatorId( + entity.getId(), UserContextHolder.getCurrentUser()); + if (records.isEmpty()) { + return "模块 [" + entityCode + "] 下暂无数据。"; + } + StringBuilder sb = new StringBuilder(); + sb.append("模块 [").append(entityCode).append("] 共 ").append(records.size()).append(" 条记录:\n"); + for (SysDynamicData r : records) { + sb.append("- ID: ").append(r.getId()).append(", 数据: ").append(r.getData()).append("\n"); + } + return sb.toString(); + } catch (Exception e) { + log.error("AI 查询数据失败: {}", e.getMessage()); + return "查询失败: " + e.getMessage(); + } + } } diff --git a/src/main/java/com/example/ainative/common/Result.java b/src/main/java/com/example/ainative/common/Result.java new file mode 100644 index 0000000..311fcb6 --- /dev/null +++ b/src/main/java/com/example/ainative/common/Result.java @@ -0,0 +1,22 @@ +package com.example.ainative.common; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class Result { + private Integer code; + private String msg; + private T data; + + public static Result success(T data) { + return new Result<>(200, "success", data); + } + + public static Result error(String msg) { + return new Result<>(500, msg, null); + } +} diff --git a/src/main/java/com/example/ainative/controller/AgentController.java b/src/main/java/com/example/ainative/controller/AgentController.java index 89eb3f9..6d6ad91 100644 --- a/src/main/java/com/example/ainative/controller/AgentController.java +++ b/src/main/java/com/example/ainative/controller/AgentController.java @@ -1,6 +1,8 @@ package com.example.ainative.controller; import com.example.ainative.ai.CrmAgent; +import com.example.ainative.ai.AgentResponse; +import com.example.ainative.common.Result; import com.example.ainative.config.UserContextHolder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,15 +22,16 @@ public class AgentController { * * @param sessionId 唯一会话识别(与底层的 ChatMemory 持久化紧密对应,确保 AI 能“记住”对话) * @param message 用户的原始需求 (比如:"帮我建一个客户关系系统的线索录入表,有姓名和手机号") - * @return AI 所思所想或执行完 @Tool 后的结果字符串反馈 + * @return AI 所思所想或执行完 @Tool 后的结果结构化反馈 */ @PostMapping("/chat") - public String chat(@RequestParam String sessionId, @RequestBody String message) { + public Result chat(@RequestParam String sessionId, @RequestBody String message) { // 先从拦截器上下文中拿到刚才设置的调用者身份 String currentUser = UserContextHolder.getCurrentUser(); log.info("message:{}",message); // 传递给底层的 Agent:这背后的复杂工作流完全交由 Langchain4J 托管! // 如果用户的 message 意图命中“建表”,框架会在返回前自己拦截调去跑 CrmTools 里的 defineDynamicModule。 - return crmAgent.chat(sessionId, message, currentUser); + AgentResponse res = crmAgent.chatSync(sessionId, message, currentUser); + return Result.success(res); } } diff --git a/src/main/java/com/example/ainative/controller/CopilotKitController.java b/src/main/java/com/example/ainative/controller/CopilotKitController.java new file mode 100644 index 0000000..ed0f7b9 --- /dev/null +++ b/src/main/java/com/example/ainative/controller/CopilotKitController.java @@ -0,0 +1,171 @@ +package com.example.ainative.controller; + +import com.example.ainative.ai.CrmAgent; +import com.example.ainative.config.UserContextHolder; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/copilotkit") +@RequiredArgsConstructor +@Slf4j +@CrossOrigin(origins = "*") +public class CopilotKitController { + + private final CrmAgent crmAgent; + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @GetMapping({"/info", ""}) + public Map getInfo() { + return Map.of( + "actions", List.of(), + "agents", Map.of( + "default", Map.of("description", "Default Agent") + ) + ); + } + + @PostMapping(value = "", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseEntity handleSingleRoute(@RequestBody CopilotSingleRouteRequest request) { + if ("info".equals(request.getMethod())) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(getInfo()); + } + + log.info("CopilotKit single-route request: method={}", request.getMethod()); + AgentRunInput input = request.getBody(); + if (input == null) { + input = new AgentRunInput(); + } + return buildStreamResponse(input); + } + + @PostMapping(value = "/agent/{agentId}/run", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseEntity handleRestRoute( + @PathVariable String agentId, + @RequestBody AgentRunInput input) { + log.info("CopilotKit REST request: agentId={}", agentId); + if (input == null) { + input = new AgentRunInput(); + } + return buildStreamResponse(input); + } + + private ResponseEntity buildStreamResponse(AgentRunInput input) { + SseEmitter emitter = new SseEmitter(180_000L); + + String threadId = input.getThreadId() != null ? input.getThreadId() : UUID.randomUUID().toString(); + String runId = input.getRunId() != null ? input.getRunId() : UUID.randomUUID().toString(); + String messageId = UUID.randomUUID().toString(); + + List> messages = input.getMessages(); + if (messages == null || messages.isEmpty()) { + sendEvent(emitter, aguiEvent("RUN_STARTED", "threadId", threadId, "runId", runId)); + sendEvent(emitter, aguiEvent("RUN_FINISHED", "threadId", threadId, "runId", runId)); + emitter.complete(); + @SuppressWarnings("unchecked") + ResponseEntity resp = (ResponseEntity) ResponseEntity.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .body(emitter); + return resp; + } + + String lastUserMessage = messages.stream() + .filter(m -> "user".equals(m.get("role"))) + .reduce((first, second) -> second) + .map(m -> { + Object content = m.get("content"); + if (content instanceof String) { + return (String) content; + } + if (content instanceof List parts) { + StringBuilder sb = new StringBuilder(); + for (Object part : parts) { + if (part instanceof Map partMap && "text".equals(partMap.get("type"))) { + sb.append(partMap.get("text")); + } + } + return sb.toString(); + } + return ""; + }) + .orElse(""); + + log.info("User message: {}", lastUserMessage); + String currentUser = UserContextHolder.getCurrentUser(); + + sendEvent(emitter, aguiEvent("RUN_STARTED", "threadId", threadId, "runId", runId)); + sendEvent(emitter, aguiEvent("TEXT_MESSAGE_START", "messageId", messageId, "role", "assistant")); + + crmAgent.chat("copilot-streaming-session", lastUserMessage, currentUser) + .onPartialResponse(token -> { + sendEvent(emitter, aguiEvent("TEXT_MESSAGE_CONTENT", "messageId", messageId, "delta", token)); + }) + .onCompleteResponse(response -> { + sendEvent(emitter, aguiEvent("TEXT_MESSAGE_END", "messageId", messageId)); + sendEvent(emitter, aguiEvent("RUN_FINISHED", "threadId", threadId, "runId", runId)); + emitter.complete(); + }) + .onError(err -> { + log.error("AI streaming error:", err); + sendEvent(emitter, aguiEvent("TEXT_MESSAGE_END", "messageId", messageId)); + sendEvent(emitter, aguiEvent("RUN_ERROR", "message", err.getMessage() != null ? err.getMessage() : "Unknown error")); + sendEvent(emitter, aguiEvent("RUN_FINISHED", "threadId", threadId, "runId", runId)); + emitter.completeWithError(err); + }) + .start(); + + @SuppressWarnings("unchecked") + ResponseEntity resp = (ResponseEntity) ResponseEntity.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .body(emitter); + return resp; + } + + private Map aguiEvent(String type, String... kvPairs) { + Map event = new LinkedHashMap<>(); + event.put("type", type); + for (int i = 0; i < kvPairs.length; i += 2) { + event.put(kvPairs[i], kvPairs[i + 1]); + } + return event; + } + + private void sendEvent(SseEmitter emitter, Map data) { + try { + emitter.send(SseEmitter.event().data(data)); + } catch (Exception e) { + log.warn("Failed to send SSE event: {}", e.getMessage()); + } + } + + @Data + public static class CopilotSingleRouteRequest { + private String method; + private Map params; + private AgentRunInput body; + } + + @Data + public static class AgentRunInput { + private String threadId; + private String runId; + private List> messages; + private List> context; + private List> tools; + private Map forwardedProps; + private Map state; + } +} diff --git a/src/main/java/com/example/ainative/controller/DynamicDataController.java b/src/main/java/com/example/ainative/controller/DynamicDataController.java index db5ca58..05025a1 100644 --- a/src/main/java/com/example/ainative/controller/DynamicDataController.java +++ b/src/main/java/com/example/ainative/controller/DynamicDataController.java @@ -1,6 +1,5 @@ package com.example.ainative.controller; -import com.example.ainative.ai.CrmTools; import com.example.ainative.config.UserContextHolder; import com.example.ainative.entity.SysDynamicData; import com.example.ainative.entity.SysEntity; @@ -12,10 +11,6 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.UUID; -/** - * 动态数据交互控制器 - * 负责接收 AI 生成的 UI 提交过来的所有结构不固定的 JSON 数据。 - */ @RestController @RequestMapping("/api/dynamic") @RequiredArgsConstructor @@ -25,22 +20,16 @@ public class DynamicDataController { private final SysDynamicDataRepository dataRepository; private final SysEntityRepository entityRepository; - /** - * 根据模块 UUID 获取数据列表 - */ @GetMapping("/{entityId}") public List getRecords(@PathVariable UUID entityId) { - return dataRepository.findByEntityIdAndCreatorId(entityId, UserContextHolder.getCurrentUser()); + return dataRepository.findByEntityId(entityId); } - /** - * 根据模块 code 获取数据列表 - */ @GetMapping("/code/{entityCode}") public List getRecordsByCode(@PathVariable String entityCode) { - SysEntity entity = entityRepository.findByEntityCodeAndCreatorId(entityCode, UserContextHolder.getCurrentUser()); + SysEntity entity = entityRepository.findByEntityCode(entityCode); if (entity != null) { - return dataRepository.findByEntityIdAndCreatorId(entity.getId(), UserContextHolder.getCurrentUser()); + return dataRepository.findByEntityId(entity.getId()); } return List.of(); } diff --git a/src/main/java/com/example/ainative/controller/SysEntityController.java b/src/main/java/com/example/ainative/controller/SysEntityController.java index 680daea..cdb9820 100644 --- a/src/main/java/com/example/ainative/controller/SysEntityController.java +++ b/src/main/java/com/example/ainative/controller/SysEntityController.java @@ -21,7 +21,8 @@ public class SysEntityController { */ @GetMapping public List getAllEntities() { - return sysEntityRepository.findByCreatorId(com.example.ainative.config.UserContextHolder.getCurrentUser()); + // 全量返回,各用户可共用实体定义 + return sysEntityRepository.findAll(); } /** @@ -29,7 +30,8 @@ public class SysEntityController { */ @GetMapping("/code/{entityCode}") public SysEntity getEntityByCode(@PathVariable String entityCode) { - return sysEntityRepository.findByEntityCodeAndCreatorId(entityCode, com.example.ainative.config.UserContextHolder.getCurrentUser()); + // 按 code 全量查询,不再限制创建者 + return sysEntityRepository.findByEntityCode(entityCode); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7ad2acb..d2eaf61 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,6 +23,12 @@ langchain4j: model-name: glm-4-plus log-requests: true log-responses: true + streaming-chat-model: + api-key: ${ZHIPU_API_KEY:61833436cbd642ed844d0128a99b2e89.PTbpERpysO3qSf8w} + base-url: https://open.bigmodel.cn/api/paas/v4/ + model-name: glm-4-plus + log-requests: true + log-responses: true server: port: 8080 diff --git a/target/classes/application.yml b/target/classes/application.yml index 7ad2acb..d2eaf61 100644 --- a/target/classes/application.yml +++ b/target/classes/application.yml @@ -23,6 +23,12 @@ langchain4j: model-name: glm-4-plus log-requests: true log-responses: true + streaming-chat-model: + api-key: ${ZHIPU_API_KEY:61833436cbd642ed844d0128a99b2e89.PTbpERpysO3qSf8w} + base-url: https://open.bigmodel.cn/api/paas/v4/ + model-name: glm-4-plus + log-requests: true + log-responses: true server: port: 8080