Commit 9bd3e8ea582e4cf82c01cadda72b222862256395

Authored by zichun
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.
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 // ==============================
... ...