package com.yunhai.agent.tool; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import lombok.Data; import org.apache.commons.lang3.StringUtils; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 工具参数定义。一份声明,三处复用: * *
    *
  1. 生成给大模型的 JSON Schema({@link #toSchema()})
  2. *
  3. 入参必填校验
  4. *
  5. 生成让用户直接填写的表单({@link #toFormField()})—— 信息不全时不再靠模型口头追问, * 而是把字段的中文名、格式示例、可选值直接摆到界面上
  6. *
*/ @Data public class ToolParam { /** 参数名(与后端接口参数名严格一致,大小写敏感) */ private String name; /** JSON Schema 类型:string / integer / number / boolean / array */ private String type = "string"; /** 给模型看的说明(越具体越好,写明格式与取值范围) */ private String description; private boolean required; /** 枚举取值(非空时会在描述与 schema 里同时体现,帮助模型少犯错) */ private List enumValues; /** 枚举的中文名,与 enumValues 一一对应(表单下拉展示用;缺省时直接显示值) */ private List enumLabels; /** * 内置兜底枚举:只在「学校可自定义的字典」上使用,是 {@link #optionsFrom} 取不到实时值时的顶替品。 *

有了它,下游字典接口不通时下拉框仍然是下拉框,而不是退化成让用户填「请假类型编号」的数字框。 * 与 {@link #enumValues} 的区别:enumValues 是权威定义(如审批结论),本字段是「大概是这样,以实时字典为准」。 */ private List fallbackValues; /** 内置兜底枚举的中文名,与 fallbackValues 一一对应 */ private List fallbackLabels; /** 默认值(模型没给时兜底) */ private String defaultValue; /* ==================== 表单渲染元数据 ==================== */ /** 字段中文标签(表单里显示,如「开始时间」);缺省回退到 name */ private String label; /** 示例值(表单 placeholder,如 2026-10-11 08:00:00),也会进入给模型的描述 */ private String example; /** * 控件类型:text / textarea / number / date / datetime / select / multiselect / file; * 缺省按参数名与类型自动推断。 * *

*/ private String widget; /** * 数组值的序列化分隔符(仅 {@code POST_JSON} 生效)。 * *

★ 为什么需要:模块的很多字段在 wire 上是「逗号串」而不是 JSON 数组 * (如宿舍通知 {@code receiveList="1,2,3"}、附件 {@code fileIds="11,12"}), * 但表单里用户是多选的(天然是数组)。声明 {@code .join(",")} 后,执行器会在发请求前 * 把数组拼成模块要的字符串,避免"页面里选得到、提交后端判非法"。 */ private String joinSeparator; /** {@code widget=file} 时是否允许多个附件(缺省 true);对齐模块页 AttachmentUpload 的 multiple */ private Boolean multiple; /** * 「全部」哨兵值。 * *

★ 用途:模块的接收对象多选里都有一个「全部班级 / 全部宿舍」开关,wire 上是 {@code "ALL"}。 * 声明后:① 表单把它作为第一个选项摆进多选;② 提交时若用户既没选具体项、又没选它, * 视作「全部」(保持"默认发全体"的语义);③ 若同时选了具体项与它,以**具体项**为准(折叠掉它)。 */ private String allToken; /** 「全部」选项在表单里显示的中文名,如「全部班级」/「全部宿舍」 */ private String allTokenLabel; /** * 「推送范围」控件(widget=scopes)的节点类型。 * *

校园公告的接收对象 {@code pushScopes} 在 wire 上是 * {@code [{scopeType,scopeId,scopeName}]}(1 全校 / 2 学部 / 3 年级 / 4 班级 / 5 训练队 / 6 指定学员)。 * 声明后前端把多选结果拼成这个对象数组;选「全部」则发出 {@code [{scopeType:1,scopeId:0,scopeName:"全部班级"}]}。 */ private Integer scopeType; /** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */ private boolean hidden; /** * 该字段的下拉选项来自哪个只读工具(如 {@code leave.types})。 *

服务端在生成表单时会先执行这个工具,把返回的字典项填成下拉选项 —— * 用户就不用去猜「病假是几号」了。执行失败则回退为普通输入框,不影响主流程。 */ private String optionsFrom; /** * 选项取自字典返回对象的哪个字段(如 {@code classId} / {@code stuId})。 * *

★ 为什么必须显式声明:模块字典返回的是业务 VO({@code TeachingClassVo{classId,className}}、 * {@code ClassStudentVo{stuId,stuName}}),字段名五花八门,靠「猜常见键名」不可靠。 * 显式声明后,前端在浏览器里取到原始数据也能**确定性地**映射成下拉项。 */ private String optionValueKey; /** 选项显示名取自字典返回对象的哪个字段(如 {@code className} / {@code stuName}) */ private String optionLabelKey; /** * 本字段的选项依赖哪些其它字段(如「学生」下拉依赖先选好「班级」)。 * *

作用有两个:① 生成表单时把依赖名带给前端,前端据此清空/重新拉取选项; * ② 生成「浏览器代取选项」的地址时,把依赖字段位置留成占位符({@code ?classId={{classId}}})。 */ private List dependsOn; public ToolParam() { } public ToolParam(String name, String type, String description, boolean required) { this.name = name; this.type = type; this.description = description; this.required = required; } public static ToolParam required(String name, String type, String description) { return new ToolParam(name, type, description, true); } public static ToolParam optional(String name, String type, String description) { return new ToolParam(name, type, description, false); } public ToolParam enums(String... values) { this.enumValues = Arrays.asList(values); return this; } /** 枚举值 + 中文名(一一对应),表单会渲染成带中文选项的下拉框 */ public ToolParam options(String[] values, String[] labels) { this.enumValues = Arrays.asList(values); this.enumLabels = Arrays.asList(labels); return this; } /** * 内置兜底选项:配合 {@link #optionsFrom} 使用。 *

实时字典取到 → 用实时值;取不到 → 用这份兜底,下拉框不至于消失。 */ public ToolParam fallbackOptions(String[] values, String[] labels) { this.fallbackValues = Arrays.asList(values); this.fallbackLabels = Arrays.asList(labels); return this; } public ToolParam dft(String value) { this.defaultValue = value; return this; } /** 字段中文标签 */ public ToolParam label(String value) { this.label = value; return this; } /** 示例值(帮助用户按格式填写) */ public ToolParam eg(String value) { this.example = value; return this; } /** 指定控件类型 */ public ToolParam as(String value) { this.widget = value; return this; } public ToolParam hide() { this.hidden = true; return this; } /** 下拉选项来自某个只读字典工具 */ public ToolParam optionsFrom(String tool) { this.optionsFrom = tool; return this; } /** * 字典返回对象里,取哪个字段做「值」、哪个字段做「显示名」。 *

例:{@code optionsKeys("classId","className")} 对应 {@code {classId,className}} 这种业务 VO。 */ public ToolParam optionsKeys(String valueKey, String labelKey) { this.optionValueKey = valueKey; this.optionLabelKey = labelKey; return this; } /** 本字段的选项依赖的先序字段(用于联动下拉,如「学生」依赖「班级」) */ public ToolParam dependsOn(String... fieldNames) { this.dependsOn = Arrays.asList(fieldNames); return this; } /** * 数组值序列化分隔符:{@code POST_JSON} 时把多选的数组拼成模块要的「逗号串」。 * 例:{@code optional("fileIds","array",...).join(",")} → {@code "11,12,13"}。 */ public ToolParam join(String separator) { this.joinSeparator = separator; return this; } /** 附件是否可多选(仅 {@code widget=file} 有意义) */ public ToolParam multiple(boolean value) { this.multiple = value; return this; } /** * 「全部」哨兵值(如 {@code "ALL"}):表单会把它作为多选的第一个选项, * 提交时与具体项互斥(见 {@link #allToken} 字段说明)。 */ public ToolParam allOption(String token, String label) { this.allToken = token; this.allTokenLabel = label; return this; } /** * 声明为「推送范围」多选:提交时把选中的节点拼成 * {@code [{scopeType,scopeId,scopeName}]}(校园公告的 {@code pushScopes})。 * * @param type 节点类型:1 全校 / 2 学部 / 3 年级 / 4 班级 / 5 训练队 / 6 指定学员 */ public ToolParam scopes(int type) { this.widget = "scopes"; this.scopeType = type; return this; } /** 表单里显示的名字 */ public String labelOf() { return StringUtils.isNotBlank(label) ? label : name; } /** 当前生效的枚举值:权威枚举优先,其次内置兜底 */ private List effectiveValues() { if (enumValues != null && !enumValues.isEmpty()) return enumValues; return fallbackValues; } /** 当前生效的枚举中文名,与 {@link #effectiveValues()} 对应 */ private List effectiveLabels() { if (enumValues != null && !enumValues.isEmpty()) return enumLabels; return fallbackLabels; } /** 当前选项是否来自内置兜底(即实时字典没取到,用的是「大概是这样」的值) */ public boolean isUsingFallbackOptions() { return (enumValues == null || enumValues.isEmpty()) && fallbackValues != null && !fallbackValues.isEmpty(); } /** * 推断控件类型:显式指定优先,其次枚举→下拉,然后按类型与参数名语义判断。 *

参数名里的 date/time 是强信号(startDate / endDate / leaveTime), * 文本类词根(remarks / reason / content)则渲染成多行输入。 */ public String resolveWidget() { if (StringUtils.isNotBlank(widget)) return widget; List vals = effectiveValues(); boolean isArray = "array".equalsIgnoreCase(StringUtils.trimToEmpty(type)); if (isArray) { // 数组参数:有选项来源(枚举或字典)→ 多选下拉;否则退化成文本(用户手填逗号串) if ((vals != null && !vals.isEmpty()) || StringUtils.isNotBlank(optionsFrom)) return "multiselect"; return "text"; } if (vals != null && !vals.isEmpty()) return "select"; String t = StringUtils.trimToEmpty(type).toLowerCase(); if ("integer".equals(t) || "number".equals(t)) return "number"; if ("boolean".equals(t)) return "select"; String n = StringUtils.trimToEmpty(name).toLowerCase(); if (n.contains("date") || n.contains("time")) return "datetime"; if (n.contains("remark") || n.contains("reason") || n.contains("desc") || n.contains("memo") || n.contains("content") || n.contains("address") || n.contains("text")) { return "textarea"; } return "text"; } /** 枚举 → 前端下拉项 [{value,label}](权威枚举优先,其次内置兜底) */ public List> optionList() { List> list = new ArrayList<>(); List vals = effectiveValues(); List labs = effectiveLabels(); if (vals == null) return list; for (int i = 0; i < vals.size(); i++) { Map o = new LinkedHashMap<>(); o.put("value", vals.get(i)); String lab = (labs != null && i < labs.size()) ? labs.get(i) : null; o.put("label", StringUtils.isNotBlank(lab) ? lab : vals.get(i)); list.add(o); } return list; } /** 转成前端渲染用的表单字段 */ public FormField toFormField() { FormField f = new FormField(); f.setName(name); f.setLabel(labelOf()); f.setWidget(resolveWidget()); f.setType(type); f.setRequired(required); f.setDescription(description); f.setExample(example); f.setDefaultValue(defaultValue); f.setOptions(optionList()); // 把「动态选项来源」一并下发:前端据此区分「本来就没有选项」和「字典没取到」 f.setOptionsFrom(optionsFrom); f.setDependsOn(dependsOn); f.setValueKey(optionValueKey); f.setLabelKey(optionLabelKey); f.setJoinSeparator(joinSeparator); f.setMultiple(multiple == null || multiple); f.setAllToken(allToken); f.setAllTokenLabel(allTokenLabel); f.setScopeType(scopeType); // 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户 f.setOptionsFallback(isUsingFallbackOptions()); return f; } /** 转成 JSON Schema 的属性节点(给模型看) */ public JSONObject toSchema() { JSONObject o = new JSONObject(); o.put("type", type); if ("array".equalsIgnoreCase(StringUtils.trimToEmpty(type))) { JSONObject items = new JSONObject(); items.put("type", "string"); o.put("items", items); } String desc = description == null ? "" : description; List vals = effectiveValues(); if (vals != null && !vals.isEmpty()) { desc = desc + (StringUtils.isBlank(desc) ? "" : ";") + "可选值:" + String.join(" / ", vals); o.put("enum", new JSONArray() {{ addAll(vals); }}); } if (StringUtils.isNotBlank(example)) { desc = desc + (StringUtils.isBlank(desc) ? "" : ";") + "示例:" + example; } o.put("description", desc); return o; } }