diff --git a/sql/ai_op_queue.sql b/sql/ai_op_queue.sql new file mode 100644 index 0000000..5fc81e8 --- /dev/null +++ b/sql/ai_op_queue.sql @@ -0,0 +1,25 @@ +-- ai_op_queue:AI 写操作暂存表(人在环 HITL 写入闭环)。 +-- ProposeWrite 只写 draft;用户在对话内点【确认】后,确定性 confirm 端点才执行。 +-- v1 覆盖 update(改现有记录的字段);create/delete/examine 后续扩展。 +CREATE TABLE IF NOT EXISTS ai_op_queue ( + sId varchar(64) NOT NULL PRIMARY KEY, -- 操作/深链 token + sUserId varchar(64), + sConversationId varchar(96), + sBrandsId varchar(32), + sSubsidiaryId varchar(32), + sOpType varchar(16), -- update | create | delete | examine + sTargetFormId varchar(64), + sTargetModuleId varchar(64), + sTargetTable varchar(64), + sTargetBillId varchar(64), -- 目标记录 sId(update/delete 用) + sField varchar(64), -- 目标列(技术名) + sFieldLabel varchar(128), -- 目标列中文名 + sOldValue varchar(500), + sNewValue varchar(500), + sDescription varchar(500), -- 人类可读的改动描述 + sStatus varchar(16), -- draft | confirmed | executed | failed | cancelled + sResultMsg varchar(500), + tCreateDate datetime, + tConfirmDate datetime, + KEY idx_conv (sConversationId, sStatus) +); diff --git a/src/main/java/com/xly/config/AgentConfig.java b/src/main/java/com/xly/config/AgentConfig.java index 9fe08a8..e1f1113 100644 --- a/src/main/java/com/xly/config/AgentConfig.java +++ b/src/main/java/com/xly/config/AgentConfig.java @@ -4,6 +4,7 @@ import com.xly.agent.ReActAgent; import com.xly.service.SystemPromptService; import com.xly.tool.ErpReadTool; import com.xly.tool.KgQueryTool; +import com.xly.tool.ProposeWriteTool; import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.model.ollama.OllamaStreamingChatModel; import dev.langchain4j.service.AiServices; @@ -48,11 +49,12 @@ public class AgentConfig { public ReActAgent reActAgent(SystemPromptService systemPromptService, KgQueryTool kgQueryTool, ErpReadTool erpReadTool, + ProposeWriteTool proposeWriteTool, RedisChatMemoryStore memoryStore) { String systemPrompt = systemPromptService.buildSystemPrompt(); return AiServices.builder(ReActAgent.class) .streamingChatModel(agentStreamingModel()) - .tools(kgQueryTool, erpReadTool) + .tools(kgQueryTool, erpReadTool, proposeWriteTool) .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() .id(memoryId) .maxMessages(30) diff --git a/src/main/java/com/xly/service/ErpClient.java b/src/main/java/com/xly/service/ErpClient.java index 4ee4cac..a9aed80 100644 --- a/src/main/java/com/xly/service/ErpClient.java +++ b/src/main/java/com/xly/service/ErpClient.java @@ -126,4 +126,42 @@ public class ErpClient { throw new RuntimeException("ERP 读取异常: " + e.getMessage(), e); } } + + /** + * 执行一次字段更新(addUpdateDelBusinessData, handleType=update)。会话过期自动重登重试。 + * 请求体格式与 ERP 前端一致:{@code {data:[{sTable, name:"master", column:[{handleType:"update", sId, field:value}]}]}}。 + */ + public JsonNode updateForm(String moduleId, String table, String billId, String field, String value) { + JsonNode root = doUpdate(moduleId, table, billId, field, value, token()); + if (root.path("code").asInt() == -2) { + login(); + root = doUpdate(moduleId, table, billId, field, value, token()); + } + return root; + } + + private JsonNode doUpdate(String moduleId, String table, String billId, String field, String value, String tok) { + try { + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; + Map col = new LinkedHashMap<>(); + col.put("handleType", "update"); + col.put("sId", billId); + col.put(field, value); + Map dataItem = new LinkedHashMap<>(); + dataItem.put("sTable", table); + dataItem.put("name", "master"); + dataItem.put("column", List.of(col)); + String body = mapper.writeValueAsString(Map.of("data", List.of(dataItem))); + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/json;charset=UTF-8") + .header("Authorization", tok) + .timeout(Duration.ofSeconds(30)) + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + return mapper.readTree(resp.body()); + } catch (Exception e) { + throw new RuntimeException("ERP 更新异常: " + e.getMessage(), e); + } + } } diff --git a/src/main/java/com/xly/service/OpService.java b/src/main/java/com/xly/service/OpService.java new file mode 100644 index 0000000..827fe30 --- /dev/null +++ b/src/main/java/com/xly/service/OpService.java @@ -0,0 +1,57 @@ +package com.xly.service; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** + * ai_op_queue 暂存管理 —— 写操作的人在环闭环:ProposeWrite 写 draft,用户确认后 confirm 端点执行。 + */ +@Service +public class OpService { + + private final JdbcTemplate jdbc; + + public OpService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** 暂存一条 draft 写操作,返回 opId(不执行)。 */ + public String createDraft(String userId, String opType, + String formId, String moduleId, String table, String billId, + String field, String fieldLabel, String oldValue, String newValue, + String description) { + String sId = "op-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF)); + jdbc.update( + "INSERT INTO ai_op_queue(sId,sUserId,sOpType,sTargetFormId,sTargetModuleId,sTargetTable," + + "sTargetBillId,sField,sFieldLabel,sOldValue,sNewValue,sDescription,sStatus,tCreateDate) " + + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?, 'draft', NOW())", + sId, userId, opType, formId, moduleId, table, billId, field, fieldLabel, oldValue, newValue, description); + return sId; + } + + /** 把 draft 关联到会话(控制器在工具执行后调用;控制器持有 conversationId)。 */ + public void attachConversation(String opId, String convId) { + jdbc.update("UPDATE ai_op_queue SET sConversationId=? WHERE sId=?", convId, opId); + } + + public Map get(String sId) { + List> r = jdbc.queryForList("SELECT * FROM ai_op_queue WHERE sId=?", sId); + return r.isEmpty() ? null : r.get(0); + } + + /** 会话里最近一条待确认(draft)操作。 */ + public Map pending(String convId) { + List> r = jdbc.queryForList( + "SELECT * FROM ai_op_queue WHERE sConversationId=? AND sStatus='draft' ORDER BY tCreateDate DESC LIMIT 1", + convId); + return r.isEmpty() ? null : r.get(0); + } + + public void setStatus(String sId, String status, String resultMsg) { + jdbc.update("UPDATE ai_op_queue SET sStatus=?, sResultMsg=?, tConfirmDate=NOW() WHERE sId=?", + status, resultMsg, sId); + } +} diff --git a/src/main/java/com/xly/service/SystemPromptService.java b/src/main/java/com/xly/service/SystemPromptService.java index 57093b9..04f4390 100644 --- a/src/main/java/com/xly/service/SystemPromptService.java +++ b/src/main/java/com/xly/service/SystemPromptService.java @@ -37,6 +37,8 @@ public class SystemPromptService { - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数);\ keyword 可选,**仅用于精确查找某个命名记录**(如某个客户名);问数量 / 全部 / 概况时必须留空。 + - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\ + (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\ (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。 @@ -45,8 +47,9 @@ public class SystemPromptService { 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 - 4. 你目前只有**只读**能力(检索表单、读取数据)。凡涉及新增 / 修改 / 删除 / 审核等写操作,\ - 如实告诉用户「该能力正在开发中」,绝不假装已经执行或已经生成单据。 + 4. **改现有记录的某个字段**:直接用 proposeUpdate(给出实体类型如「客户」、记录名、字段中文名、新值)生成\ + 一条【待确认】的修改提议——它**不会立即执行**;真正修改要用户在对话内点【确认】才发生,你**绝不能声称已修改完成**。\ + 新增单据 / 删除 / 审核等其它写操作仍在开发中,如实告知。 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\ 只有确实缺少关键参数(如不知道要查哪张单据)时才提问。 """.formatted(renderDomainMap()); diff --git a/src/main/java/com/xly/tool/ProposeWriteTool.java b/src/main/java/com/xly/tool/ProposeWriteTool.java new file mode 100644 index 0000000..420bee9 --- /dev/null +++ b/src/main/java/com/xly/tool/ProposeWriteTool.java @@ -0,0 +1,173 @@ +package com.xly.tool; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.service.ErpClient; +import com.xly.service.OpService; +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * ProposeWrite 工具(写操作,人在环)。 + * + *

**只提议并暂存,绝不立即执行**:把「改某条记录的某字段」解析成具体的 表/记录id/列/新值, + * 写一条 draft 到 ai_op_queue,返回一个提议。真正执行发生在用户点【确认】后的确定性端点里 + * (见 OpController),不经过 LLM。 + */ +@Component +public class ProposeWriteTool { + + private final ErpClient erp; + private final JdbcTemplate jdbc; + private final OpService ops; + private final ObjectMapper mapper; + + public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper) { + this.erp = erp; + this.jdbc = jdbc; + this.ops = ops; + this.mapper = mapper; + } + + @Tool("提议修改某条现有记录的某个字段(写操作)。本工具**只提议并暂存、绝不立即执行**——" + + "必须等用户在对话内点【确认】后才真正修改。用于「把某个客户/物料的某字段改成X」这类需求。" + + "直接给出实体类型(如 客户)即可,本工具会自行定位主表,无需先 findForms。") + public String proposeUpdate( + @P("实体/单据类型关键词,如 客户 / 物料 / 供应商") String entityKeyword, + @P("要修改的那条记录的名称关键词(如某个客户名)") String recordKeyword, + @P("要修改的字段中文名(如 简称 / 备注 / 联系电话)") String fieldChinese, + @P("修改后的新值") String newValue) { + + if (isBlank(entityKeyword) || isBlank(recordKeyword) || isBlank(fieldChinese)) { + return err("缺少信息:需要实体类型、记录名称关键词、字段中文名、新值。"); + } + + // 1) 定位可修改的主表(该实体名下、table 类型、最常用的一张) + Map form = resolveForm(entityKeyword.trim()); + if (form == null) { + return err("找不到「" + entityKeyword + "」对应的可修改主表。"); + } + String formId = str(form.get("sFormId")); + String moduleId = str(form.get("sModuleId")); + String table = str(form.get("sDataSource")); + + // 2) 字段中文名 -> 技术列名 + String field = queryOne( + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese=? ORDER BY iFormUses DESC LIMIT 1", + table, fieldChinese.trim()); + if (field == null) { + field = queryOne( + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese LIKE ? ORDER BY iFormUses DESC LIMIT 1", + table, "%" + fieldChinese.trim() + "%"); + } + if (field == null) { + return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。"); + } + + // 3) 定位唯一记录 + 旧值(用名称字段过滤) + String nameField = queryOne( + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " + + "ORDER BY iFormUses DESC LIMIT 1", table); + JsonNode root; + try { + root = erp.readForm(formId.trim(), moduleId.trim(), 1, 5, nameField, recordKeyword.trim()); + } catch (Exception e) { + return err("定位记录时读取失败:" + e.getMessage()); + } + if (root.path("code").asInt(0) < 0) { + return err("定位记录失败:" + root.path("msg").asText("未知错误")); + } + JsonNode rows = root.path("dataset").path("rows"); + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; + int n = (data != null && data.isArray()) ? data.size() : 0; + if (n == 0) { + return err("没有找到名称含「" + recordKeyword + "」的记录,无法修改。"); + } + if (n > 1) { + StringBuilder names = new StringBuilder(); + for (int i = 0; i < data.size() && i < 5; i++) { + if (i > 0) names.append("、"); + names.append(data.get(i).path(nameField == null ? "" : nameField).asText("")); + } + return err("匹配到多条记录(" + names + "),请提供更精确的名称,只改其中一条。"); + } + + JsonNode rec = data.get(0); + String billId = rec.path("sId").asText(null); + if (isBlank(billId)) { + return err("定位到的记录缺少主键 sId,无法安全修改。"); + } + String oldValue = rec.path(field).asText(""); + String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); + + // 4) 暂存 draft(不执行) + String description = "将【" + recordName + "】的【" + fieldChinese + "】" + + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + newValue + "」"; + String opId = ops.createDraft("agent", "update", formId.trim(), moduleId.trim(), table, billId, + field, fieldChinese, oldValue, newValue, description); + + Map out = new LinkedHashMap<>(); + out.put("opId", opId); + out.put("summary", description); + out.put("message", "已为你生成一条待确认的修改,请在下方点【确认】执行、或【取消】。"); + return toJson(out); + } + + /** 定位实体的可修改主表:该实体名下 table 类型、最常用(AI工具/连接度)的一张。 */ + private Map resolveForm(String entityKeyword) { + try { + List> r = jdbc.queryForList( + "SELECT af.sFormId, af.sModuleId, af.sDataSource FROM viw_ai_useful_forms af " + + "LEFT JOIN viw_kg_form f ON f.sFormId = af.sFormId " + + "WHERE af.sFormTitle LIKE ? AND af.sExecType='table' " + + // 排除报表视图(viw_*),只取可直接改的基础主表 + "AND af.sDataSource NOT LIKE 'viw%' " + + "ORDER BY COALESCE(f.bAiTool,0) DESC, " + + "(COALESCE(f.iUpstream,0)+COALESCE(f.iDownstream,0)) DESC, CHAR_LENGTH(af.sFormTitle) ASC LIMIT 1", + "%" + entityKeyword + "%"); + return r.isEmpty() ? null : r.get(0); + } catch (Exception e) { + return null; + } + } + + private static String str(Object o) { + return o == null ? null : o.toString(); + } + + private String queryOne(String sql, Object... args) { + try { + List> r = jdbc.queryForList(sql, args); + if (!r.isEmpty()) { + Object v = r.get(0).values().iterator().next(); + return v == null ? null : v.toString(); + } + } catch (Exception ignore) { + } + return null; + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } + + private String err(String msg) { + Map m = new LinkedHashMap<>(); + m.put("error", msg); + return toJson(m); + } + + private String toJson(Map m) { + try { + return mapper.writeValueAsString(m); + } catch (Exception e) { + return "{\"error\":\"内部错误\"}"; + } + } +} diff --git a/src/main/java/com/xly/web/AgentChatController.java b/src/main/java/com/xly/web/AgentChatController.java index c98e9a2..c96d25f 100644 --- a/src/main/java/com/xly/web/AgentChatController.java +++ b/src/main/java/com/xly/web/AgentChatController.java @@ -1,9 +1,12 @@ package com.xly.web; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.ReActAgent; import com.xly.service.ConversationService; +import com.xly.service.OpService; import dev.langchain4j.service.TokenStream; +import dev.langchain4j.service.tool.ToolExecution; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.MediaType; @@ -20,14 +23,11 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** - * 单 agent 对话入口(M1)。 + * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 * - *

{@code POST /xlyAi/api/agent/chat} 以 SSE(text/event-stream)流式返回助手的 token。 - * 每帧是一条 JSON:{@code {"type":"token|done|error","content":"..."}},前端逐帧渲染。 - * - *

会话记忆按 {@code conversationId} 隔离(多条命名会话);缺省用 {@code userid:default}。 - * 说明:token 流本身是异步的({@link TokenStream#start()} 立即返回,回调在模型线程触发), - * 这里用一个线程池提交,避免占用请求线程。 + *

帧格式:{@code {"type":"token|reset|done|error"}} 或写提议 {@code {"type":"write_proposal","opId","summary"}}。 + * {@code reset} 在工具执行时清掉模型的调用旁白;{@code write_proposal} 让前端渲染确认卡片 + * (确认走确定性端点 {@code /api/agent/op/{id}/confirm},不经过 LLM)。 */ @RestController @RequestMapping("/api/agent") @@ -38,15 +38,17 @@ public class AgentChatController { private final ReActAgent agent; private final ObjectMapper mapper; private final ConversationService conversations; + private final OpService ops; private final ExecutorService exec = Executors.newCachedThreadPool(); - public AgentChatController(ReActAgent agent, ObjectMapper mapper, ConversationService conversations) { + public AgentChatController(ReActAgent agent, ObjectMapper mapper, + ConversationService conversations, OpService ops) { this.agent = agent; this.mapper = mapper; this.conversations = conversations; + this.ops = ops; } - /** 前端请求体:身份字段透传(M1 只用 userid + conversationId + text)。 */ public static class ChatReq { public String text; public String userid; @@ -61,16 +63,13 @@ public class AgentChatController { ? req.conversationId : ((req.userid == null ? "anon" : req.userid) + ":default"); - // 登记/更新会话(用于多会话侧栏;标题取首条消息) conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); exec.submit(() -> { try { TokenStream ts = agent.chat(convId, userInput); ts.onPartialResponse(token -> send(emitter, "token", token)) - // 工具执行 = 一轮结束:此前流出的是模型的“调用旁白/思考”,让前端清空, - // 只保留工具执行之后的最终答复(既去掉噪声、又保留流式)。 - .onToolExecuted(te -> send(emitter, "reset", "")) + .onToolExecuted(te -> handleToolExecuted(emitter, convId, te)) .onCompleteResponse(resp -> { send(emitter, "done", ""); emitter.complete(); @@ -90,11 +89,36 @@ public class AgentChatController { return emitter; } + /** 工具执行回调:清掉工具前的旁白(reset);若是写提议,关联会话并推确认卡片。 */ + private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) { + send(emitter, "reset", ""); + try { + if (te.request() != null && "proposeUpdate".equals(te.request().name()) && te.result() != null) { + JsonNode r = mapper.readTree(te.result()); + String opId = r.path("opId").asText(null); + if (opId != null && !opId.isBlank()) { + ops.attachConversation(opId, convId); + Map card = new LinkedHashMap<>(); + card.put("type", "write_proposal"); + card.put("opId", opId); + card.put("summary", r.path("summary").asText("")); + sendEvent(emitter, card); + } + } + } catch (Exception e) { + log.warn("handle proposeUpdate result failed", e); + } + } + private void send(SseEmitter emitter, String type, String content) { + Map m = new LinkedHashMap<>(); + m.put("type", type); + m.put("content", content); + sendEvent(emitter, m); + } + + private void sendEvent(SseEmitter emitter, Map m) { try { - Map m = new LinkedHashMap<>(); - m.put("type", type); - m.put("content", content); emitter.send(SseEmitter.event().data(mapper.writeValueAsString(m), MediaType.APPLICATION_JSON)); } catch (IOException | IllegalStateException e) { // 客户端已断开或 emitter 已结束——忽略。 diff --git a/src/main/java/com/xly/web/OpController.java b/src/main/java/com/xly/web/OpController.java new file mode 100644 index 0000000..dd5842b --- /dev/null +++ b/src/main/java/com/xly/web/OpController.java @@ -0,0 +1,98 @@ +package com.xly.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.xly.service.ErpClient; +import com.xly.service.OpService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 写操作确认端点(**确定性、不经过 LLM**)—— 人在环写入闭环的执行侧。 + * + *

用户在对话框点【确认】-> {@code /confirm} 才真正调 ERP 执行;点【取消】-> {@code /cancel}。 + * v1 为同步执行(update 很快):直接调 ERP 表单更新接口,回写 ai_op_queue 状态并返回结果。 + */ +@RestController +@RequestMapping("/api/agent/op") +public class OpController { + + private static final Logger log = LoggerFactory.getLogger(OpController.class); + + private final OpService ops; + private final ErpClient erp; + + public OpController(OpService ops, ErpClient erp) { + this.ops = ops; + this.erp = erp; + } + + /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */ + @GetMapping("/pending") + public Map pending(@RequestParam("conversationId") String conversationId) { + Map op = ops.pending(conversationId); + return op == null ? Map.of() : op; + } + + /** 确认执行:调 ERP 更新,回写状态。 */ + @PostMapping("/{id}/confirm") + public Map confirm(@PathVariable("id") String id) { + Map op = ops.get(id); + if (op == null) { + return result("failed", "找不到该操作", null); + } + if (!"draft".equals(String.valueOf(op.get("sStatus")))) { + return result(String.valueOf(op.get("sStatus")), "该操作已处理过(" + op.get("sStatus") + ")", op); + } + try { + JsonNode r = erp.updateForm( + str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), + str(op.get("sField")), str(op.get("sNewValue"))); + int code = r.path("code").asInt(0); + if (code == 1) { + ops.setStatus(id, "executed", "操作成功"); + return result("executed", "已修改:" + op.get("sDescription"), op); + } + String msg = r.path("msg").asText("执行失败"); + ops.setStatus(id, "failed", msg); + return result("failed", msg, op); + } catch (Exception e) { + log.warn("confirm op {} failed", id, e); + ops.setStatus(id, "failed", e.getMessage()); + return result("failed", "执行异常:" + e.getMessage(), op); + } + } + + /** 取消。 */ + @PostMapping("/{id}/cancel") + public Map cancel(@PathVariable("id") String id) { + Map op = ops.get(id); + if (op != null && "draft".equals(String.valueOf(op.get("sStatus")))) { + ops.setStatus(id, "cancelled", "用户取消"); + } + return result("cancelled", "已取消", op); + } + + private Map result(String status, String msg, Map op) { + Map m = new LinkedHashMap<>(); + m.put("status", status); + m.put("msg", msg); + if (op != null) { + m.put("opId", op.get("sId")); + m.put("description", op.get("sDescription")); + } + return m; + } + + private static String str(Object o) { + return o == null ? null : o.toString(); + } +} diff --git a/src/main/resources/templates/chat.html b/src/main/resources/templates/chat.html index 214daf4..4571d8e 100644 --- a/src/main/resources/templates/chat.html +++ b/src/main/resources/templates/chat.html @@ -708,7 +708,9 @@ // 工具执行前的旁白作废,清空气泡,等最终答复流入 aiText = ''; if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); } - $(`#${aiMsgId} .message-content`).html('🔎 正在查询…'); + $(`#${aiMsgId} .message-content`).html('🔎 正在处理…'); + } else if (evt.type === "write_proposal") { + renderProposalCard(evt.opId, evt.summary); } else if (evt.type === "error") { if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); } aiText += (aiText ? "\n\n" : "") + "⚠️ " + evt.content; @@ -739,6 +741,58 @@ scrollToBottom(); } + function escapeHtml(s){ return (s==null?'':String(s)).replace(/[&<>"']/g, m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); } + + // ====================== 写操作确认卡片(人在环) ====================== + function renderProposalCard(opId, summary) { + hideTypingIndicator(); + const cardId = 'op-' + opId; + if (document.getElementById(cardId)) return; + const html = ` +

+
+
+
⚠️ 待确认的修改
+
${escapeHtml(summary)}
+
+ + +
+
+
+
+
`; + $('#chatMessages').append(html); + scrollToBottom(); + } + + async function confirmOp(opId) { + const card = $('#op-' + opId); + card.find('.op-actions button').prop('disabled', true); + card.find('.op-result').text('处理中…'); + try { + const res = await fetch(CONFIG.backendUrl + '/api/agent/op/' + opId + '/confirm', { method:'POST' }); + const data = await res.json(); + if (data.status === 'executed') { + card.find('.op-actions').remove(); + card.find('.op-result').html('✅ ' + escapeHtml(data.msg || '已修改')); + } else { + card.find('.op-result').html('❌ ' + escapeHtml(data.msg || '执行失败') + '(可重试)'); + card.find('.op-actions button').prop('disabled', false); + } + } catch (e) { + card.find('.op-result').text('❌ 请求失败:' + e.message); + card.find('.op-actions button').prop('disabled', false); + } + } + + async function cancelOp(opId) { + const card = $('#op-' + opId); + try { await fetch(CONFIG.backendUrl + '/api/agent/op/' + opId + '/cancel', { method:'POST' }); } catch (e) {} + card.find('.op-actions').remove(); + card.find('.op-result').text('已取消'); + } + // ============================== // 👇 语音排队播放函数(保证顺序) // ==============================