diff --git a/src/main/java/com/xly/service/FormResolverService.java b/src/main/java/com/xly/service/FormResolverService.java index 248b089..9c92b1b 100644 --- a/src/main/java/com/xly/service/FormResolverService.java +++ b/src/main/java/com/xly/service/FormResolverService.java @@ -180,11 +180,19 @@ public class FormResolverService { return m; } - /** FK 名称候选(下拉从对应表取选项):按名称字段模糊匹配,返回名称列表。仅允许字段字典里出现过的外键表。 */ - public List fkOptions(String fkTable, String brand, String q) { - List out = new ArrayList<>(); + /** + * FK 选项页(二级选择器数据源):名称 + 若干展示列(字段字典按使用度选前 3 个非名称文本列), + * 支持按名称模糊搜索 + 分页。仅允许「在字段字典里作为外键目标出现过」的表,避免任意读表。 + * 返回 {columns:[{col,label}…], rows:[{col:value…}], total, page, pageSize}。 + */ + public Map fkOptionPage(String fkTable, String brand, String q, int page, int pageSize) { + Map out = new LinkedHashMap<>(); + out.put("columns", List.of()); + out.put("rows", List.of()); + out.put("total", 0); + out.put("page", Math.max(1, page)); + out.put("pageSize", pageSize); try { - // 安全:只允许作为外键目标出现过的表(白名单),避免任意读表 Integer ok = jdbc.queryForObject( "SELECT COUNT(*) FROM viw_kg_field_dict WHERE sFkTable=?", Integer.class, fkTable); if (ok == null || ok == 0) { @@ -194,11 +202,26 @@ public class FormResolverService { if (nameField == null) { return out; } - String sql = "SELECT DISTINCT `" + nameField + "` n FROM `" + fkTable + "` " + - "WHERE IFNULL(`" + nameField + "`,'')<>'' " + + // 展示列:名称列 + 使用度最高的最多 3 个其它文本列(排除系统/Id 列) + List> cols = new ArrayList<>(); + cols.add(Map.of("col", nameField, "label", labelOf(fkTable, nameField))); + for (Map r : jdbc.queryForList( + "SELECT sField, SUBSTRING_INDEX(GROUP_CONCAT(sChinese ORDER BY CHAR_LENGTH(sChinese) ASC SEPARATOR 0x1f),0x1f,1) zh " + + "FROM viw_kg_field_dict WHERE sTable=? AND sField<>? AND sField NOT LIKE '%Id' " + + "AND CHAR_LENGTH(sChinese)>=2 GROUP BY sField ORDER BY SUM(iFormUses) DESC LIMIT 8", fkTable, nameField)) { + String c = String.valueOf(r.get("sField")); + if (isSystemColumn(c) || cols.size() >= 4) { + continue; + } + cols.add(Map.of("col", c, "label", String.valueOf(r.get("zh")))); + } + StringBuilder sel = new StringBuilder("sId"); + for (Map c : cols) { + sel.append(", `").append(c.get("col")).append("`"); + } + String where = "WHERE IFNULL(`" + nameField + "`,'')<>'' " + (brand != null && !brand.isBlank() ? "AND sBrandsId=? " : "") + - (q != null && !q.isBlank() ? "AND `" + nameField + "` LIKE ? " : "") + - "ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC LIMIT 50"; + (q != null && !q.isBlank() ? "AND `" + nameField + "` LIKE ? " : ""); List args = new ArrayList<>(); if (brand != null && !brand.isBlank()) { args.add(brand); @@ -206,17 +229,34 @@ public class FormResolverService { if (q != null && !q.isBlank()) { args.add("%" + q.trim() + "%"); } - for (Map r : jdbc.queryForList(sql, args.toArray())) { - Object n = r.get("n"); - if (n != null) { - out.add(n.toString()); - } - } + Integer total = jdbc.queryForObject( + "SELECT COUNT(*) FROM `" + fkTable + "` " + where, Integer.class, args.toArray()); + int p = Math.max(1, page); + int ps = Math.min(Math.max(1, pageSize), 100); + List pageArgs = new ArrayList<>(args); + pageArgs.add(ps); + pageArgs.add((p - 1) * ps); + List> rows = jdbc.queryForList( + "SELECT " + sel + " FROM `" + fkTable + "` " + where + + "ORDER BY CHAR_LENGTH(`" + nameField + "`) ASC, `" + nameField + "` ASC LIMIT ? OFFSET ?", + pageArgs.toArray()); + out.put("columns", cols); + out.put("rows", rows); + out.put("total", total == null ? 0 : total); + out.put("page", p); + out.put("pageSize", ps); } catch (Exception ignore) { } return out; } + private String labelOf(String table, String field) { + String zh = queryOne( + "SELECT sChinese FROM viw_kg_field_dict WHERE sTable=? AND sField=? ORDER BY CHAR_LENGTH(sChinese) ASC LIMIT 1", + table, field); + return zh == null ? field : zh; + } + /** 表列 -> MySQL 数据类型(information_schema)。 */ public Map columnTypes(String table) { Map m = new HashMap<>(); diff --git a/src/main/java/com/xly/service/SystemPromptService.java b/src/main/java/com/xly/service/SystemPromptService.java index 18e0522..edc3b06 100644 --- a/src/main/java/com/xly/service/SystemPromptService.java +++ b/src/main/java/com/xly/service/SystemPromptService.java @@ -50,7 +50,7 @@ public class SystemPromptService { 【可用工具(只有这些)】 - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。 - - readFormData(formId, moduleId, keyword?):读某表单的真实数据(前若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword。 + - readFormData(formId, moduleId, keyword?, page?):读某表单的真实数据(每页若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword;用户要看下一页时 page 递增。 - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问「某记录的某字段」优先用它。 - collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。**新增一律先用它**,用户提交后你再用 proposeWrite(action=create)。 - proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**(人在环:只生成待确认提议,用户点【确认】才执行)。action:create=新增(fieldsJson);update=改字段(recordKeyword+fieldChinese+newValue);invalid=作废;cancelInvalid=复原;examine=审核;cancelExamine=销审;delete=物理删除(业务单据别用)。它自行定位主表与记录,无需先 findForms。 diff --git a/src/main/java/com/xly/tool/ErpReadTool.java b/src/main/java/com/xly/tool/ErpReadTool.java index 97a8fe2..f954466 100644 --- a/src/main/java/com/xly/tool/ErpReadTool.java +++ b/src/main/java/com/xly/tool/ErpReadTool.java @@ -111,12 +111,14 @@ public class ErpReadTool { } @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。可选 keyword 用于按名称模糊过滤" - + "(如查某个客户 / 物料)。必须先用 findForms 得到目标表单的 formId 与 moduleId。") + + "(如查某个客户 / 物料);可选 page 翻页(从 1 开始,用户要看「下一页/更多」时递增)。" + + "必须先用 findForms 得到目标表单的 formId 与 moduleId。") public String readFormData( @P("表单id(findForms 返回的 formId)") String formId, @P("菜单id(findForms 返回的 moduleId)") String moduleId, @P(value = "可选:仅当要查找某个具体名称的记录时才填(如某个客户名/物料名);" - + "问数量 / 全部 / 概况时必须留空", required = false) String keyword) { + + "问数量 / 全部 / 概况时必须留空", required = false) String keyword, + @P(value = "可选:页码,从 1 开始;默认 1", required = false) Integer page) { if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) { return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。"; @@ -126,10 +128,11 @@ public class ErpReadTool { } String kw = (keyword == null) ? "" : keyword.trim(); String nameField = kw.isEmpty() ? null : resolver.resolveNameFieldByFormId(formId.trim()); + int pageNo = page == null || page < 1 ? 1 : page; JsonNode root; try { - root = erp.readForm(identity.token(), formId.trim(), moduleId.trim(), 1, MAX_ROWS, nameField, kw.isEmpty() ? null : kw); + root = erp.readForm(identity.token(), formId.trim(), moduleId.trim(), pageNo, MAX_ROWS, nameField, kw.isEmpty() ? null : kw); } catch (Exception e) { return "读取失败:" + e.getMessage(); } @@ -160,7 +163,8 @@ public class ErpReadTool { } StringBuilder sb = new StringBuilder(); - sb.append(scope).append("共 ").append(total).append(" 条,前 ").append(data.size()).append(" 条:\n\n"); + sb.append(scope).append("共 ").append(total).append(" 条,第 ").append(pageNo) + .append(" 页 ").append(data.size()).append(" 条:\n\n"); sb.append("| ") .append(cols.stream().map(c -> labels.getOrDefault(c, c)).collect(Collectors.joining(" | "))) .append(" |\n"); diff --git a/src/main/java/com/xly/tool/InteractionTool.java b/src/main/java/com/xly/tool/InteractionTool.java index 30c008b..5400e66 100644 --- a/src/main/java/com/xly/tool/InteractionTool.java +++ b/src/main/java/com/xly/tool/InteractionTool.java @@ -45,10 +45,12 @@ public class InteractionTool { out.put("type", "question"); out.put("question", question == null ? "" : question.trim()); out.put("options", opts); + // Claude Code 风格:给了选项也永远保留「自由输入」作为最后一个选项 + out.put("allowFree", true); try { return mapper.writeValueAsString(out); } catch (Exception e) { - return "{\"type\":\"question\",\"question\":\"" + (question == null ? "" : question) + "\",\"options\":[]}"; + return "{\"type\":\"question\",\"question\":\"" + (question == null ? "" : question) + "\",\"options\":[],\"allowFree\":true}"; } } } diff --git a/src/main/java/com/xly/web/AgentChatController.java b/src/main/java/com/xly/web/AgentChatController.java index 07b1617..a6901c1 100644 --- a/src/main/java/com/xly/web/AgentChatController.java +++ b/src/main/java/com/xly/web/AgentChatController.java @@ -131,6 +131,85 @@ public class AgentChatController { return emitter; } + public static class FormSubmitReq { + public String entity; + public Map fields; // 字段中文名 -> 值 + public String userid; + public String conversationId; + public String authorization; + public String brandsid; + public String subsidiaryid; + public String usertype; + } + + /** + * 确定性表单提交:collectForm 表单的结构化字段**直达** proposeWrite(action=create), + * 不再拼自然语言消息让 LLM 重新编码(旧路径会丢字段/错角色)。同步返回提议或错误。 + */ + @PostMapping("/form/submit") + public Map formSubmit(@RequestBody FormSubmitReq req) { + final String convId = (req.conversationId != null && !req.conversationId.isBlank()) + ? req.conversationId + : ((req.userid == null ? "anon" : req.userid) + ":default"); + Map out = new LinkedHashMap<>(); + String entity = req.entity == null ? "" : req.entity.trim(); + Map fields = req.fields == null ? Map.of() : req.fields; + if (entity.isEmpty() || fields.isEmpty()) { + out.put("error", "缺少单据类型或表单字段。"); + return out; + } + ChatReq idReq = new ChatReq(); + idReq.userid = req.userid; + idReq.authorization = req.authorization; + idReq.brandsid = req.brandsid; + idReq.subsidiaryid = req.subsidiaryid; + idReq.usertype = req.usertype; + AgentIdentity identity = resolveIdentity(idReq); + + StringBuilder parts = new StringBuilder(); + fields.forEach((k, v) -> { + if (v != null && !v.isBlank()) { + if (parts.length() > 0) parts.append(","); + parts.append(k).append("=").append(v); + } + }); + String userText = "提交「" + entity + "」新增表单:" + parts; + conversations.touch(req.userid == null ? "anon" : req.userid, convId, userText); + ledger.append(convId, "user", Map.of("text", userText)); + + String fieldsJson; + try { + fieldsJson = mapper.writeValueAsString(fields); + } catch (Exception e) { + out.put("error", "字段序列化失败:" + e.getMessage()); + return out; + } + String result = agentFactory.proposeWriteTool(identity) + .proposeWrite("create", entity, null, null, null, fieldsJson); + try { + JsonNode r = mapper.readTree(result); + String opId = r.path("opId").asText(null); + if (opId != null && !opId.isBlank()) { + ops.attachConversation(opId, convId); + String summary = r.path("summary").asText(""); + ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary)); + state.setActiveDoc(convId, entity, "", opId, "proposed"); + appendMemoryTurn(convId, userText, "已生成待确认提议:" + summary + "(等待用户点确认/取消)"); + out.put("opId", opId); + out.put("summary", summary); + out.put("message", r.path("message").asText("已生成待确认操作,请点【确认】。")); + } else { + String err = r.path("error").asText("无法完成该新增。"); + ledger.append(convId, "assistant", Map.of("text", err)); + appendMemoryTurn(convId, userText, err); + out.put("error", err); + } + } catch (Exception e) { + out.put("error", "服务异常:" + e.getMessage()); + } + return out; + } + /** 意图门 + 确定性路由。 */ private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { // 0) 表单提交 → 直接执行 proposeWrite(action=create),不再重新分类。 diff --git a/src/main/java/com/xly/web/FormController.java b/src/main/java/com/xly/web/FormController.java index 4751ac6..f97fff2 100644 --- a/src/main/java/com/xly/web/FormController.java +++ b/src/main/java/com/xly/web/FormController.java @@ -6,13 +6,16 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.List; +import java.util.Map; /** - * FormCollect 的**下拉选项来源**端点:外键字段(客户/产品/物料…)的选项从对应表实时取。 + * FormCollect 的**FK 二级选择器**数据端点:外键字段(客户/产品/物料…)的候选从对应表实时取, + * 多列展示 + 名称搜索 + 分页。 * - *

前端渲染 {@code type=fkselect} 字段时调 {@code GET /api/agent/form/options?table=elecustomer&q=..} - * 拿候选名称填进下拉。安全:{@link FormResolverService#fkOptions} 只允许「在字段字典里作为外键目标出现过」的表, + *

前端渲染 {@code type=fkselect} 字段时调 + * {@code GET /api/agent/form/options?table=elecustomer&q=..&page=1&pageSize=20}, + * 返回 {@code {columns:[{col,label}…], rows:[{sId, 列:值…}], total, page, pageSize}}。 + * 安全:{@link FormResolverService#fkOptionPage} 只允许「在字段字典里作为外键目标出现过」的表, * 并按名称字段模糊匹配 + 租户过滤 + LIMIT,避免任意读表。 */ @RestController @@ -26,9 +29,11 @@ public class FormController { } @GetMapping("/options") - public List options(@RequestParam("table") String table, - @RequestParam(value = "q", required = false) String q, - @RequestParam(value = "brandsid", required = false) String brandsid) { - return resolver.fkOptions(table, brandsid, q); + public Map options(@RequestParam("table") String table, + @RequestParam(value = "q", required = false) String q, + @RequestParam(value = "brandsid", required = false) String brandsid, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return resolver.fkOptionPage(table, brandsid, q, page, pageSize); } }