Commit 9bd3e8ea582e4cf82c01cadda72b222862256395
1 parent
2cef96e4
feat(D): HITL write path — propose -> confirm -> execute (update)
- ai_op_queue: staging table for AI write ops (draft|confirmed|executed|failed|cancelled)
- ProposeWriteTool.proposeUpdate(entity, record, field, newValue): self-resolves the entity master table (excludes viw_* report views), resolves field via 字段字典, locates the unique record + old value, stages a DRAFT to ai_op_queue — DOES NOT execute; returns a proposal
- ErpClient.updateForm: executes via ERP addUpdateDelBusinessData with the frontend-compatible payload {data:[{sTable,name:master,column:[{handleType:update,sId,field:value}]}]}
- OpController: deterministic (non-LLM) POST /op/{id}/confirm (executes + writes back status) / cancel / GET pending
- AgentChatController.onToolExecuted: on proposeUpdate, attach conversation + push write_proposal SSE
- chat.html: confirm/cancel card + result echo
- system prompt: proposeUpdate flow; never claim a write is done before confirm
Verified end-to-end: '把必胜客简称改成必胜客中国' -> proposal card -> confirm -> ERP update -> DB changed; cancel -> DB unchanged. Nothing executes without explicit user confirm.
Showing
9 changed files
with
494 additions
and
20 deletions
sql/ai_op_queue.sql
0 → 100644
| 1 | +-- ai_op_queue:AI 写操作暂存表(人在环 HITL 写入闭环)。 | |
| 2 | +-- ProposeWrite 只写 draft;用户在对话内点【确认】后,确定性 confirm 端点才执行。 | |
| 3 | +-- v1 覆盖 update(改现有记录的字段);create/delete/examine 后续扩展。 | |
| 4 | +CREATE TABLE IF NOT EXISTS ai_op_queue ( | |
| 5 | + sId varchar(64) NOT NULL PRIMARY KEY, -- 操作/深链 token | |
| 6 | + sUserId varchar(64), | |
| 7 | + sConversationId varchar(96), | |
| 8 | + sBrandsId varchar(32), | |
| 9 | + sSubsidiaryId varchar(32), | |
| 10 | + sOpType varchar(16), -- update | create | delete | examine | |
| 11 | + sTargetFormId varchar(64), | |
| 12 | + sTargetModuleId varchar(64), | |
| 13 | + sTargetTable varchar(64), | |
| 14 | + sTargetBillId varchar(64), -- 目标记录 sId(update/delete 用) | |
| 15 | + sField varchar(64), -- 目标列(技术名) | |
| 16 | + sFieldLabel varchar(128), -- 目标列中文名 | |
| 17 | + sOldValue varchar(500), | |
| 18 | + sNewValue varchar(500), | |
| 19 | + sDescription varchar(500), -- 人类可读的改动描述 | |
| 20 | + sStatus varchar(16), -- draft | confirmed | executed | failed | cancelled | |
| 21 | + sResultMsg varchar(500), | |
| 22 | + tCreateDate datetime, | |
| 23 | + tConfirmDate datetime, | |
| 24 | + KEY idx_conv (sConversationId, sStatus) | |
| 25 | +); | ... | ... |
src/main/java/com/xly/config/AgentConfig.java
| ... | ... | @@ -4,6 +4,7 @@ import com.xly.agent.ReActAgent; |
| 4 | 4 | import com.xly.service.SystemPromptService; |
| 5 | 5 | import com.xly.tool.ErpReadTool; |
| 6 | 6 | import com.xly.tool.KgQueryTool; |
| 7 | +import com.xly.tool.ProposeWriteTool; | |
| 7 | 8 | import dev.langchain4j.memory.chat.MessageWindowChatMemory; |
| 8 | 9 | import dev.langchain4j.model.ollama.OllamaStreamingChatModel; |
| 9 | 10 | import dev.langchain4j.service.AiServices; |
| ... | ... | @@ -48,11 +49,12 @@ public class AgentConfig { |
| 48 | 49 | public ReActAgent reActAgent(SystemPromptService systemPromptService, |
| 49 | 50 | KgQueryTool kgQueryTool, |
| 50 | 51 | ErpReadTool erpReadTool, |
| 52 | + ProposeWriteTool proposeWriteTool, | |
| 51 | 53 | RedisChatMemoryStore memoryStore) { |
| 52 | 54 | String systemPrompt = systemPromptService.buildSystemPrompt(); |
| 53 | 55 | return AiServices.builder(ReActAgent.class) |
| 54 | 56 | .streamingChatModel(agentStreamingModel()) |
| 55 | - .tools(kgQueryTool, erpReadTool) | |
| 57 | + .tools(kgQueryTool, erpReadTool, proposeWriteTool) | |
| 56 | 58 | .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() |
| 57 | 59 | .id(memoryId) |
| 58 | 60 | .maxMessages(30) | ... | ... |
src/main/java/com/xly/service/ErpClient.java
| ... | ... | @@ -126,4 +126,42 @@ public class ErpClient { |
| 126 | 126 | throw new RuntimeException("ERP 读取异常: " + e.getMessage(), e); |
| 127 | 127 | } |
| 128 | 128 | } |
| 129 | + | |
| 130 | + /** | |
| 131 | + * 执行一次字段更新(addUpdateDelBusinessData, handleType=update)。会话过期自动重登重试。 | |
| 132 | + * 请求体格式与 ERP 前端一致:{@code {data:[{sTable, name:"master", column:[{handleType:"update", sId, field:value}]}]}}。 | |
| 133 | + */ | |
| 134 | + public JsonNode updateForm(String moduleId, String table, String billId, String field, String value) { | |
| 135 | + JsonNode root = doUpdate(moduleId, table, billId, field, value, token()); | |
| 136 | + if (root.path("code").asInt() == -2) { | |
| 137 | + login(); | |
| 138 | + root = doUpdate(moduleId, table, billId, field, value, token()); | |
| 139 | + } | |
| 140 | + return root; | |
| 141 | + } | |
| 142 | + | |
| 143 | + private JsonNode doUpdate(String moduleId, String table, String billId, String field, String value, String tok) { | |
| 144 | + try { | |
| 145 | + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; | |
| 146 | + Map<String, Object> col = new LinkedHashMap<>(); | |
| 147 | + col.put("handleType", "update"); | |
| 148 | + col.put("sId", billId); | |
| 149 | + col.put(field, value); | |
| 150 | + Map<String, Object> dataItem = new LinkedHashMap<>(); | |
| 151 | + dataItem.put("sTable", table); | |
| 152 | + dataItem.put("name", "master"); | |
| 153 | + dataItem.put("column", List.of(col)); | |
| 154 | + String body = mapper.writeValueAsString(Map.of("data", List.of(dataItem))); | |
| 155 | + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) | |
| 156 | + .header("Content-Type", "application/json;charset=UTF-8") | |
| 157 | + .header("Authorization", tok) | |
| 158 | + .timeout(Duration.ofSeconds(30)) | |
| 159 | + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) | |
| 160 | + .build(); | |
| 161 | + HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); | |
| 162 | + return mapper.readTree(resp.body()); | |
| 163 | + } catch (Exception e) { | |
| 164 | + throw new RuntimeException("ERP 更新异常: " + e.getMessage(), e); | |
| 165 | + } | |
| 166 | + } | |
| 129 | 167 | } | ... | ... |
src/main/java/com/xly/service/OpService.java
0 → 100644
| 1 | +package com.xly.service; | |
| 2 | + | |
| 3 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 4 | +import org.springframework.stereotype.Service; | |
| 5 | + | |
| 6 | +import java.util.List; | |
| 7 | +import java.util.Map; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * ai_op_queue 暂存管理 —— 写操作的人在环闭环:ProposeWrite 写 draft,用户确认后 confirm 端点执行。 | |
| 11 | + */ | |
| 12 | +@Service | |
| 13 | +public class OpService { | |
| 14 | + | |
| 15 | + private final JdbcTemplate jdbc; | |
| 16 | + | |
| 17 | + public OpService(JdbcTemplate jdbc) { | |
| 18 | + this.jdbc = jdbc; | |
| 19 | + } | |
| 20 | + | |
| 21 | + /** 暂存一条 draft 写操作,返回 opId(不执行)。 */ | |
| 22 | + public String createDraft(String userId, String opType, | |
| 23 | + String formId, String moduleId, String table, String billId, | |
| 24 | + String field, String fieldLabel, String oldValue, String newValue, | |
| 25 | + String description) { | |
| 26 | + String sId = "op-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF)); | |
| 27 | + jdbc.update( | |
| 28 | + "INSERT INTO ai_op_queue(sId,sUserId,sOpType,sTargetFormId,sTargetModuleId,sTargetTable," + | |
| 29 | + "sTargetBillId,sField,sFieldLabel,sOldValue,sNewValue,sDescription,sStatus,tCreateDate) " + | |
| 30 | + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?, 'draft', NOW())", | |
| 31 | + sId, userId, opType, formId, moduleId, table, billId, field, fieldLabel, oldValue, newValue, description); | |
| 32 | + return sId; | |
| 33 | + } | |
| 34 | + | |
| 35 | + /** 把 draft 关联到会话(控制器在工具执行后调用;控制器持有 conversationId)。 */ | |
| 36 | + public void attachConversation(String opId, String convId) { | |
| 37 | + jdbc.update("UPDATE ai_op_queue SET sConversationId=? WHERE sId=?", convId, opId); | |
| 38 | + } | |
| 39 | + | |
| 40 | + public Map<String, Object> get(String sId) { | |
| 41 | + List<Map<String, Object>> r = jdbc.queryForList("SELECT * FROM ai_op_queue WHERE sId=?", sId); | |
| 42 | + return r.isEmpty() ? null : r.get(0); | |
| 43 | + } | |
| 44 | + | |
| 45 | + /** 会话里最近一条待确认(draft)操作。 */ | |
| 46 | + public Map<String, Object> pending(String convId) { | |
| 47 | + List<Map<String, Object>> r = jdbc.queryForList( | |
| 48 | + "SELECT * FROM ai_op_queue WHERE sConversationId=? AND sStatus='draft' ORDER BY tCreateDate DESC LIMIT 1", | |
| 49 | + convId); | |
| 50 | + return r.isEmpty() ? null : r.get(0); | |
| 51 | + } | |
| 52 | + | |
| 53 | + public void setStatus(String sId, String status, String resultMsg) { | |
| 54 | + jdbc.update("UPDATE ai_op_queue SET sStatus=?, sResultMsg=?, tConfirmDate=NOW() WHERE sId=?", | |
| 55 | + status, resultMsg, sId); | |
| 56 | + } | |
| 57 | +} | ... | ... |
src/main/java/com/xly/service/SystemPromptService.java
| ... | ... | @@ -37,6 +37,8 @@ public class SystemPromptService { |
| 37 | 37 | - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 |
| 38 | 38 | - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数);\ |
| 39 | 39 | keyword 可选,**仅用于精确查找某个命名记录**(如某个客户名);问数量 / 全部 / 概况时必须留空。 |
| 40 | + - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\ | |
| 41 | + (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。 | |
| 40 | 42 | 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ |
| 41 | 43 | (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\ |
| 42 | 44 | (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。 |
| ... | ... | @@ -45,8 +47,9 @@ public class SystemPromptService { |
| 45 | 47 | 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 |
| 46 | 48 | 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 |
| 47 | 49 | 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 |
| 48 | - 4. 你目前只有**只读**能力(检索表单、读取数据)。凡涉及新增 / 修改 / 删除 / 审核等写操作,\ | |
| 49 | - 如实告诉用户「该能力正在开发中」,绝不假装已经执行或已经生成单据。 | |
| 50 | + 4. **改现有记录的某个字段**:直接用 proposeUpdate(给出实体类型如「客户」、记录名、字段中文名、新值)生成\ | |
| 51 | + 一条【待确认】的修改提议——它**不会立即执行**;真正修改要用户在对话内点【确认】才发生,你**绝不能声称已修改完成**。\ | |
| 52 | + 新增单据 / 删除 / 审核等其它写操作仍在开发中,如实告知。 | |
| 50 | 53 | 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\ |
| 51 | 54 | 只有确实缺少关键参数(如不知道要查哪张单据)时才提问。 |
| 52 | 55 | """.formatted(renderDomainMap()); | ... | ... |
src/main/java/com/xly/tool/ProposeWriteTool.java
0 → 100644
| 1 | +package com.xly.tool; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 4 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 5 | +import com.xly.service.ErpClient; | |
| 6 | +import com.xly.service.OpService; | |
| 7 | +import dev.langchain4j.agent.tool.P; | |
| 8 | +import dev.langchain4j.agent.tool.Tool; | |
| 9 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 10 | +import org.springframework.stereotype.Component; | |
| 11 | + | |
| 12 | +import java.util.LinkedHashMap; | |
| 13 | +import java.util.List; | |
| 14 | +import java.util.Map; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * ProposeWrite 工具(写操作,人在环)。 | |
| 18 | + * | |
| 19 | + * <p>**只提议并暂存,绝不立即执行**:把「改某条记录的某字段」解析成具体的 表/记录id/列/新值, | |
| 20 | + * 写一条 draft 到 ai_op_queue,返回一个提议。真正执行发生在用户点【确认】后的确定性端点里 | |
| 21 | + * (见 OpController),不经过 LLM。 | |
| 22 | + */ | |
| 23 | +@Component | |
| 24 | +public class ProposeWriteTool { | |
| 25 | + | |
| 26 | + private final ErpClient erp; | |
| 27 | + private final JdbcTemplate jdbc; | |
| 28 | + private final OpService ops; | |
| 29 | + private final ObjectMapper mapper; | |
| 30 | + | |
| 31 | + public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper) { | |
| 32 | + this.erp = erp; | |
| 33 | + this.jdbc = jdbc; | |
| 34 | + this.ops = ops; | |
| 35 | + this.mapper = mapper; | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Tool("提议修改某条现有记录的某个字段(写操作)。本工具**只提议并暂存、绝不立即执行**——" | |
| 39 | + + "必须等用户在对话内点【确认】后才真正修改。用于「把某个客户/物料的某字段改成X」这类需求。" | |
| 40 | + + "直接给出实体类型(如 客户)即可,本工具会自行定位主表,无需先 findForms。") | |
| 41 | + public String proposeUpdate( | |
| 42 | + @P("实体/单据类型关键词,如 客户 / 物料 / 供应商") String entityKeyword, | |
| 43 | + @P("要修改的那条记录的名称关键词(如某个客户名)") String recordKeyword, | |
| 44 | + @P("要修改的字段中文名(如 简称 / 备注 / 联系电话)") String fieldChinese, | |
| 45 | + @P("修改后的新值") String newValue) { | |
| 46 | + | |
| 47 | + if (isBlank(entityKeyword) || isBlank(recordKeyword) || isBlank(fieldChinese)) { | |
| 48 | + return err("缺少信息:需要实体类型、记录名称关键词、字段中文名、新值。"); | |
| 49 | + } | |
| 50 | + | |
| 51 | + // 1) 定位可修改的主表(该实体名下、table 类型、最常用的一张) | |
| 52 | + Map<String, Object> form = resolveForm(entityKeyword.trim()); | |
| 53 | + if (form == null) { | |
| 54 | + return err("找不到「" + entityKeyword + "」对应的可修改主表。"); | |
| 55 | + } | |
| 56 | + String formId = str(form.get("sFormId")); | |
| 57 | + String moduleId = str(form.get("sModuleId")); | |
| 58 | + String table = str(form.get("sDataSource")); | |
| 59 | + | |
| 60 | + // 2) 字段中文名 -> 技术列名 | |
| 61 | + String field = queryOne( | |
| 62 | + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese=? ORDER BY iFormUses DESC LIMIT 1", | |
| 63 | + table, fieldChinese.trim()); | |
| 64 | + if (field == null) { | |
| 65 | + field = queryOne( | |
| 66 | + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese LIKE ? ORDER BY iFormUses DESC LIMIT 1", | |
| 67 | + table, "%" + fieldChinese.trim() + "%"); | |
| 68 | + } | |
| 69 | + if (field == null) { | |
| 70 | + return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。"); | |
| 71 | + } | |
| 72 | + | |
| 73 | + // 3) 定位唯一记录 + 旧值(用名称字段过滤) | |
| 74 | + String nameField = queryOne( | |
| 75 | + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + | |
| 76 | + "ORDER BY iFormUses DESC LIMIT 1", table); | |
| 77 | + JsonNode root; | |
| 78 | + try { | |
| 79 | + root = erp.readForm(formId.trim(), moduleId.trim(), 1, 5, nameField, recordKeyword.trim()); | |
| 80 | + } catch (Exception e) { | |
| 81 | + return err("定位记录时读取失败:" + e.getMessage()); | |
| 82 | + } | |
| 83 | + if (root.path("code").asInt(0) < 0) { | |
| 84 | + return err("定位记录失败:" + root.path("msg").asText("未知错误")); | |
| 85 | + } | |
| 86 | + JsonNode rows = root.path("dataset").path("rows"); | |
| 87 | + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; | |
| 88 | + int n = (data != null && data.isArray()) ? data.size() : 0; | |
| 89 | + if (n == 0) { | |
| 90 | + return err("没有找到名称含「" + recordKeyword + "」的记录,无法修改。"); | |
| 91 | + } | |
| 92 | + if (n > 1) { | |
| 93 | + StringBuilder names = new StringBuilder(); | |
| 94 | + for (int i = 0; i < data.size() && i < 5; i++) { | |
| 95 | + if (i > 0) names.append("、"); | |
| 96 | + names.append(data.get(i).path(nameField == null ? "" : nameField).asText("")); | |
| 97 | + } | |
| 98 | + return err("匹配到多条记录(" + names + "),请提供更精确的名称,只改其中一条。"); | |
| 99 | + } | |
| 100 | + | |
| 101 | + JsonNode rec = data.get(0); | |
| 102 | + String billId = rec.path("sId").asText(null); | |
| 103 | + if (isBlank(billId)) { | |
| 104 | + return err("定位到的记录缺少主键 sId,无法安全修改。"); | |
| 105 | + } | |
| 106 | + String oldValue = rec.path(field).asText(""); | |
| 107 | + String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); | |
| 108 | + | |
| 109 | + // 4) 暂存 draft(不执行) | |
| 110 | + String description = "将【" + recordName + "】的【" + fieldChinese + "】" | |
| 111 | + + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + newValue + "」"; | |
| 112 | + String opId = ops.createDraft("agent", "update", formId.trim(), moduleId.trim(), table, billId, | |
| 113 | + field, fieldChinese, oldValue, newValue, description); | |
| 114 | + | |
| 115 | + Map<String, Object> out = new LinkedHashMap<>(); | |
| 116 | + out.put("opId", opId); | |
| 117 | + out.put("summary", description); | |
| 118 | + out.put("message", "已为你生成一条待确认的修改,请在下方点【确认】执行、或【取消】。"); | |
| 119 | + return toJson(out); | |
| 120 | + } | |
| 121 | + | |
| 122 | + /** 定位实体的可修改主表:该实体名下 table 类型、最常用(AI工具/连接度)的一张。 */ | |
| 123 | + private Map<String, Object> resolveForm(String entityKeyword) { | |
| 124 | + try { | |
| 125 | + List<Map<String, Object>> r = jdbc.queryForList( | |
| 126 | + "SELECT af.sFormId, af.sModuleId, af.sDataSource FROM viw_ai_useful_forms af " + | |
| 127 | + "LEFT JOIN viw_kg_form f ON f.sFormId = af.sFormId " + | |
| 128 | + "WHERE af.sFormTitle LIKE ? AND af.sExecType='table' " + | |
| 129 | + // 排除报表视图(viw_*),只取可直接改的基础主表 | |
| 130 | + "AND af.sDataSource NOT LIKE 'viw%' " + | |
| 131 | + "ORDER BY COALESCE(f.bAiTool,0) DESC, " + | |
| 132 | + "(COALESCE(f.iUpstream,0)+COALESCE(f.iDownstream,0)) DESC, CHAR_LENGTH(af.sFormTitle) ASC LIMIT 1", | |
| 133 | + "%" + entityKeyword + "%"); | |
| 134 | + return r.isEmpty() ? null : r.get(0); | |
| 135 | + } catch (Exception e) { | |
| 136 | + return null; | |
| 137 | + } | |
| 138 | + } | |
| 139 | + | |
| 140 | + private static String str(Object o) { | |
| 141 | + return o == null ? null : o.toString(); | |
| 142 | + } | |
| 143 | + | |
| 144 | + private String queryOne(String sql, Object... args) { | |
| 145 | + try { | |
| 146 | + List<Map<String, Object>> r = jdbc.queryForList(sql, args); | |
| 147 | + if (!r.isEmpty()) { | |
| 148 | + Object v = r.get(0).values().iterator().next(); | |
| 149 | + return v == null ? null : v.toString(); | |
| 150 | + } | |
| 151 | + } catch (Exception ignore) { | |
| 152 | + } | |
| 153 | + return null; | |
| 154 | + } | |
| 155 | + | |
| 156 | + private static boolean isBlank(String s) { | |
| 157 | + return s == null || s.isBlank(); | |
| 158 | + } | |
| 159 | + | |
| 160 | + private String err(String msg) { | |
| 161 | + Map<String, Object> m = new LinkedHashMap<>(); | |
| 162 | + m.put("error", msg); | |
| 163 | + return toJson(m); | |
| 164 | + } | |
| 165 | + | |
| 166 | + private String toJson(Map<String, Object> m) { | |
| 167 | + try { | |
| 168 | + return mapper.writeValueAsString(m); | |
| 169 | + } catch (Exception e) { | |
| 170 | + return "{\"error\":\"内部错误\"}"; | |
| 171 | + } | |
| 172 | + } | |
| 173 | +} | ... | ... |
src/main/java/com/xly/web/AgentChatController.java
| 1 | 1 | package com.xly.web; |
| 2 | 2 | |
| 3 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 3 | 4 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 4 | 5 | import com.xly.agent.ReActAgent; |
| 5 | 6 | import com.xly.service.ConversationService; |
| 7 | +import com.xly.service.OpService; | |
| 6 | 8 | import dev.langchain4j.service.TokenStream; |
| 9 | +import dev.langchain4j.service.tool.ToolExecution; | |
| 7 | 10 | import org.slf4j.Logger; |
| 8 | 11 | import org.slf4j.LoggerFactory; |
| 9 | 12 | import org.springframework.http.MediaType; |
| ... | ... | @@ -20,14 +23,11 @@ import java.util.concurrent.ExecutorService; |
| 20 | 23 | import java.util.concurrent.Executors; |
| 21 | 24 | |
| 22 | 25 | /** |
| 23 | - * 单 agent 对话入口(M1)。 | |
| 26 | + * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 | |
| 24 | 27 | * |
| 25 | - * <p>{@code POST /xlyAi/api/agent/chat} 以 SSE(text/event-stream)流式返回助手的 token。 | |
| 26 | - * 每帧是一条 JSON:{@code {"type":"token|done|error","content":"..."}},前端逐帧渲染。 | |
| 27 | - * | |
| 28 | - * <p>会话记忆按 {@code conversationId} 隔离(多条命名会话);缺省用 {@code userid:default}。 | |
| 29 | - * 说明:token 流本身是异步的({@link TokenStream#start()} 立即返回,回调在模型线程触发), | |
| 30 | - * 这里用一个线程池提交,避免占用请求线程。 | |
| 28 | + * <p>帧格式:{@code {"type":"token|reset|done|error"}} 或写提议 {@code {"type":"write_proposal","opId","summary"}}。 | |
| 29 | + * {@code reset} 在工具执行时清掉模型的调用旁白;{@code write_proposal} 让前端渲染确认卡片 | |
| 30 | + * (确认走确定性端点 {@code /api/agent/op/{id}/confirm},不经过 LLM)。 | |
| 31 | 31 | */ |
| 32 | 32 | @RestController |
| 33 | 33 | @RequestMapping("/api/agent") |
| ... | ... | @@ -38,15 +38,17 @@ public class AgentChatController { |
| 38 | 38 | private final ReActAgent agent; |
| 39 | 39 | private final ObjectMapper mapper; |
| 40 | 40 | private final ConversationService conversations; |
| 41 | + private final OpService ops; | |
| 41 | 42 | private final ExecutorService exec = Executors.newCachedThreadPool(); |
| 42 | 43 | |
| 43 | - public AgentChatController(ReActAgent agent, ObjectMapper mapper, ConversationService conversations) { | |
| 44 | + public AgentChatController(ReActAgent agent, ObjectMapper mapper, | |
| 45 | + ConversationService conversations, OpService ops) { | |
| 44 | 46 | this.agent = agent; |
| 45 | 47 | this.mapper = mapper; |
| 46 | 48 | this.conversations = conversations; |
| 49 | + this.ops = ops; | |
| 47 | 50 | } |
| 48 | 51 | |
| 49 | - /** 前端请求体:身份字段透传(M1 只用 userid + conversationId + text)。 */ | |
| 50 | 52 | public static class ChatReq { |
| 51 | 53 | public String text; |
| 52 | 54 | public String userid; |
| ... | ... | @@ -61,16 +63,13 @@ public class AgentChatController { |
| 61 | 63 | ? req.conversationId |
| 62 | 64 | : ((req.userid == null ? "anon" : req.userid) + ":default"); |
| 63 | 65 | |
| 64 | - // 登记/更新会话(用于多会话侧栏;标题取首条消息) | |
| 65 | 66 | conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); |
| 66 | 67 | |
| 67 | 68 | exec.submit(() -> { |
| 68 | 69 | try { |
| 69 | 70 | TokenStream ts = agent.chat(convId, userInput); |
| 70 | 71 | ts.onPartialResponse(token -> send(emitter, "token", token)) |
| 71 | - // 工具执行 = 一轮结束:此前流出的是模型的“调用旁白/思考”,让前端清空, | |
| 72 | - // 只保留工具执行之后的最终答复(既去掉噪声、又保留流式)。 | |
| 73 | - .onToolExecuted(te -> send(emitter, "reset", "")) | |
| 72 | + .onToolExecuted(te -> handleToolExecuted(emitter, convId, te)) | |
| 74 | 73 | .onCompleteResponse(resp -> { |
| 75 | 74 | send(emitter, "done", ""); |
| 76 | 75 | emitter.complete(); |
| ... | ... | @@ -90,11 +89,36 @@ public class AgentChatController { |
| 90 | 89 | return emitter; |
| 91 | 90 | } |
| 92 | 91 | |
| 92 | + /** 工具执行回调:清掉工具前的旁白(reset);若是写提议,关联会话并推确认卡片。 */ | |
| 93 | + private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) { | |
| 94 | + send(emitter, "reset", ""); | |
| 95 | + try { | |
| 96 | + if (te.request() != null && "proposeUpdate".equals(te.request().name()) && te.result() != null) { | |
| 97 | + JsonNode r = mapper.readTree(te.result()); | |
| 98 | + String opId = r.path("opId").asText(null); | |
| 99 | + if (opId != null && !opId.isBlank()) { | |
| 100 | + ops.attachConversation(opId, convId); | |
| 101 | + Map<String, Object> card = new LinkedHashMap<>(); | |
| 102 | + card.put("type", "write_proposal"); | |
| 103 | + card.put("opId", opId); | |
| 104 | + card.put("summary", r.path("summary").asText("")); | |
| 105 | + sendEvent(emitter, card); | |
| 106 | + } | |
| 107 | + } | |
| 108 | + } catch (Exception e) { | |
| 109 | + log.warn("handle proposeUpdate result failed", e); | |
| 110 | + } | |
| 111 | + } | |
| 112 | + | |
| 93 | 113 | private void send(SseEmitter emitter, String type, String content) { |
| 114 | + Map<String, Object> m = new LinkedHashMap<>(); | |
| 115 | + m.put("type", type); | |
| 116 | + m.put("content", content); | |
| 117 | + sendEvent(emitter, m); | |
| 118 | + } | |
| 119 | + | |
| 120 | + private void sendEvent(SseEmitter emitter, Map<String, Object> m) { | |
| 94 | 121 | try { |
| 95 | - Map<String, Object> m = new LinkedHashMap<>(); | |
| 96 | - m.put("type", type); | |
| 97 | - m.put("content", content); | |
| 98 | 122 | emitter.send(SseEmitter.event().data(mapper.writeValueAsString(m), MediaType.APPLICATION_JSON)); |
| 99 | 123 | } catch (IOException | IllegalStateException e) { |
| 100 | 124 | // 客户端已断开或 emitter 已结束——忽略。 | ... | ... |
src/main/java/com/xly/web/OpController.java
0 → 100644
| 1 | +package com.xly.web; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 4 | +import com.xly.service.ErpClient; | |
| 5 | +import com.xly.service.OpService; | |
| 6 | +import org.slf4j.Logger; | |
| 7 | +import org.slf4j.LoggerFactory; | |
| 8 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 9 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 10 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 11 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 12 | +import org.springframework.web.bind.annotation.RequestParam; | |
| 13 | +import org.springframework.web.bind.annotation.RestController; | |
| 14 | + | |
| 15 | +import java.util.LinkedHashMap; | |
| 16 | +import java.util.Map; | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * 写操作确认端点(**确定性、不经过 LLM**)—— 人在环写入闭环的执行侧。 | |
| 20 | + * | |
| 21 | + * <p>用户在对话框点【确认】-> {@code /confirm} 才真正调 ERP 执行;点【取消】-> {@code /cancel}。 | |
| 22 | + * v1 为同步执行(update 很快):直接调 ERP 表单更新接口,回写 ai_op_queue 状态并返回结果。 | |
| 23 | + */ | |
| 24 | +@RestController | |
| 25 | +@RequestMapping("/api/agent/op") | |
| 26 | +public class OpController { | |
| 27 | + | |
| 28 | + private static final Logger log = LoggerFactory.getLogger(OpController.class); | |
| 29 | + | |
| 30 | + private final OpService ops; | |
| 31 | + private final ErpClient erp; | |
| 32 | + | |
| 33 | + public OpController(OpService ops, ErpClient erp) { | |
| 34 | + this.ops = ops; | |
| 35 | + this.erp = erp; | |
| 36 | + } | |
| 37 | + | |
| 38 | + /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */ | |
| 39 | + @GetMapping("/pending") | |
| 40 | + public Map<String, Object> pending(@RequestParam("conversationId") String conversationId) { | |
| 41 | + Map<String, Object> op = ops.pending(conversationId); | |
| 42 | + return op == null ? Map.of() : op; | |
| 43 | + } | |
| 44 | + | |
| 45 | + /** 确认执行:调 ERP 更新,回写状态。 */ | |
| 46 | + @PostMapping("/{id}/confirm") | |
| 47 | + public Map<String, Object> confirm(@PathVariable("id") String id) { | |
| 48 | + Map<String, Object> op = ops.get(id); | |
| 49 | + if (op == null) { | |
| 50 | + return result("failed", "找不到该操作", null); | |
| 51 | + } | |
| 52 | + if (!"draft".equals(String.valueOf(op.get("sStatus")))) { | |
| 53 | + return result(String.valueOf(op.get("sStatus")), "该操作已处理过(" + op.get("sStatus") + ")", op); | |
| 54 | + } | |
| 55 | + try { | |
| 56 | + JsonNode r = erp.updateForm( | |
| 57 | + str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), | |
| 58 | + str(op.get("sField")), str(op.get("sNewValue"))); | |
| 59 | + int code = r.path("code").asInt(0); | |
| 60 | + if (code == 1) { | |
| 61 | + ops.setStatus(id, "executed", "操作成功"); | |
| 62 | + return result("executed", "已修改:" + op.get("sDescription"), op); | |
| 63 | + } | |
| 64 | + String msg = r.path("msg").asText("执行失败"); | |
| 65 | + ops.setStatus(id, "failed", msg); | |
| 66 | + return result("failed", msg, op); | |
| 67 | + } catch (Exception e) { | |
| 68 | + log.warn("confirm op {} failed", id, e); | |
| 69 | + ops.setStatus(id, "failed", e.getMessage()); | |
| 70 | + return result("failed", "执行异常:" + e.getMessage(), op); | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + /** 取消。 */ | |
| 75 | + @PostMapping("/{id}/cancel") | |
| 76 | + public Map<String, Object> cancel(@PathVariable("id") String id) { | |
| 77 | + Map<String, Object> op = ops.get(id); | |
| 78 | + if (op != null && "draft".equals(String.valueOf(op.get("sStatus")))) { | |
| 79 | + ops.setStatus(id, "cancelled", "用户取消"); | |
| 80 | + } | |
| 81 | + return result("cancelled", "已取消", op); | |
| 82 | + } | |
| 83 | + | |
| 84 | + private Map<String, Object> result(String status, String msg, Map<String, Object> op) { | |
| 85 | + Map<String, Object> m = new LinkedHashMap<>(); | |
| 86 | + m.put("status", status); | |
| 87 | + m.put("msg", msg); | |
| 88 | + if (op != null) { | |
| 89 | + m.put("opId", op.get("sId")); | |
| 90 | + m.put("description", op.get("sDescription")); | |
| 91 | + } | |
| 92 | + return m; | |
| 93 | + } | |
| 94 | + | |
| 95 | + private static String str(Object o) { | |
| 96 | + return o == null ? null : o.toString(); | |
| 97 | + } | |
| 98 | +} | ... | ... |
src/main/resources/templates/chat.html
| ... | ... | @@ -708,7 +708,9 @@ |
| 708 | 708 | // 工具执行前的旁白作废,清空气泡,等最终答复流入 |
| 709 | 709 | aiText = ''; |
| 710 | 710 | if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); } |
| 711 | - $(`#${aiMsgId} .message-content`).html('🔎 正在查询…'); | |
| 711 | + $(`#${aiMsgId} .message-content`).html('🔎 正在处理…'); | |
| 712 | + } else if (evt.type === "write_proposal") { | |
| 713 | + renderProposalCard(evt.opId, evt.summary); | |
| 712 | 714 | } else if (evt.type === "error") { |
| 713 | 715 | if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); } |
| 714 | 716 | aiText += (aiText ? "\n\n" : "") + "⚠️ " + evt.content; |
| ... | ... | @@ -739,6 +741,58 @@ |
| 739 | 741 | scrollToBottom(); |
| 740 | 742 | } |
| 741 | 743 | |
| 744 | + function escapeHtml(s){ return (s==null?'':String(s)).replace(/[&<>"']/g, m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); } | |
| 745 | + | |
| 746 | + // ====================== 写操作确认卡片(人在环) ====================== | |
| 747 | + function renderProposalCard(opId, summary) { | |
| 748 | + hideTypingIndicator(); | |
| 749 | + const cardId = 'op-' + opId; | |
| 750 | + if (document.getElementById(cardId)) return; | |
| 751 | + const html = ` | |
| 752 | + <div class="message ai-message" id="${cardId}"> | |
| 753 | + <div class="message-bubble" style="border:1px solid #ffd27f;background:#fff8ec;"> | |
| 754 | + <div class="message-content"> | |
| 755 | + <div style="font-weight:600;margin-bottom:8px;">⚠️ 待确认的修改</div> | |
| 756 | + <div style="margin-bottom:12px;">${escapeHtml(summary)}</div> | |
| 757 | + <div class="op-actions"> | |
| 758 | + <button onclick="confirmOp('${opId}')" style="padding:8px 18px;border:none;border-radius:16px;background:linear-gradient(90deg,#28a745,#20913c);color:#fff;cursor:pointer;font-weight:600;margin-right:8px;">确认执行</button> | |
| 759 | + <button onclick="cancelOp('${opId}')" style="padding:8px 18px;border:1px solid #ccc;border-radius:16px;background:#fff;cursor:pointer;">取消</button> | |
| 760 | + </div> | |
| 761 | + <div class="op-result" style="margin-top:10px;color:#555;"></div> | |
| 762 | + </div> | |
| 763 | + </div> | |
| 764 | + </div>`; | |
| 765 | + $('#chatMessages').append(html); | |
| 766 | + scrollToBottom(); | |
| 767 | + } | |
| 768 | + | |
| 769 | + async function confirmOp(opId) { | |
| 770 | + const card = $('#op-' + opId); | |
| 771 | + card.find('.op-actions button').prop('disabled', true); | |
| 772 | + card.find('.op-result').text('处理中…'); | |
| 773 | + try { | |
| 774 | + const res = await fetch(CONFIG.backendUrl + '/api/agent/op/' + opId + '/confirm', { method:'POST' }); | |
| 775 | + const data = await res.json(); | |
| 776 | + if (data.status === 'executed') { | |
| 777 | + card.find('.op-actions').remove(); | |
| 778 | + card.find('.op-result').html('✅ ' + escapeHtml(data.msg || '已修改')); | |
| 779 | + } else { | |
| 780 | + card.find('.op-result').html('❌ ' + escapeHtml(data.msg || '执行失败') + '(可重试)'); | |
| 781 | + card.find('.op-actions button').prop('disabled', false); | |
| 782 | + } | |
| 783 | + } catch (e) { | |
| 784 | + card.find('.op-result').text('❌ 请求失败:' + e.message); | |
| 785 | + card.find('.op-actions button').prop('disabled', false); | |
| 786 | + } | |
| 787 | + } | |
| 788 | + | |
| 789 | + async function cancelOp(opId) { | |
| 790 | + const card = $('#op-' + opId); | |
| 791 | + try { await fetch(CONFIG.backendUrl + '/api/agent/op/' + opId + '/cancel', { method:'POST' }); } catch (e) {} | |
| 792 | + card.find('.op-actions').remove(); | |
| 793 | + card.find('.op-result').text('已取消'); | |
| 794 | + } | |
| 795 | + | |
| 742 | 796 | // ============================== |
| 743 | 797 | // 👇 语音排队播放函数(保证顺序) |
| 744 | 798 | // ============================== | ... | ... |