feat: 提交校园智能助手后端工程源码(Spring Boot 2.7.18 + 平台 starter + JPA + Nacos)
build / build-push (push) Successful in 40s
build / build-push (push) Successful in 40s
- com.yunhai.agent,端口 8093,context-path /agent,制品 target/agent.jar;与 NoticeService 同构 - CAS 单点登录 + 三层角色权限(学生/教师/管理员/访客)+ 会话与工具调用审计落库 - DeepSeek 大模型编排 + 7 个业务模块工具(请假/公告/宿舍/活动/作业/资源库/教师发展) - 浏览器代执行协议(服务端挂起 → 前端用登录态代调 → toolResult 续跑)+ 调用失败深链兜底 - 登出修复:服务端真正 session.invalidate() + 前端两步登出 + logout-guard - 保留 projman 注入的 .gitea/workflows/build.yml 与 docker/Dockerfile;docker/application-prod.yml、run-app.sh 随工程入库
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
package com.yunhai.agent.tool;
|
||||
|
||||
import com.yunhai.agent.auth.AgentRole;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.Data;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import javax.servlet.http.Cookie;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 「谁在对话」——把当前请求的身份原样带着,转发到下游业务模块。
|
||||
*
|
||||
* <p>为什么必须转发:各业务模块的用户/学校/角色都由平台 SSO 会话(Cookie)与
|
||||
* {@code currentRole} 请求头解析。AgentService 只有把这些透传过去,
|
||||
* 下游才会以「当前登录人」的身份执行,做到权限收敛、数据范围正确。
|
||||
*
|
||||
* <p>身份有两个来源:① 平台自动装配的参数解析器注入的 {@link BoaUser}(权威);
|
||||
* ② 请求头 {@code currentRole/userId/roleId/schoolId}(兼容旧网关)。前者优先。
|
||||
*/
|
||||
@Data
|
||||
public class CallerContext {
|
||||
|
||||
private String currentRole;
|
||||
private String userId;
|
||||
private String roleId;
|
||||
private String schoolId;
|
||||
private String cookie;
|
||||
private String referer;
|
||||
private String userAgent;
|
||||
|
||||
/** 姓名(来自 BoaUser) */
|
||||
private String userName;
|
||||
|
||||
/** 登录账号(来自 BoaUser) */
|
||||
private String account;
|
||||
|
||||
/** 当前身份的角色名(平台 currentRole.roleName,如「教师」),用于展示「我现在是谁」 */
|
||||
private String currentRoleName;
|
||||
|
||||
/** 解析后的智能体角色(学生 / 教师 / 管理员 / 访客),由 RoleResolver 填充 */
|
||||
private Set<AgentRole> roles = new LinkedHashSet<>();
|
||||
|
||||
/** 是否拿到了可用身份(拿不到就只允许调用公共只读工具) */
|
||||
public boolean hasIdentity() {
|
||||
return StringUtils.isNotBlank(userId) || StringUtils.isNotBlank(currentRole);
|
||||
}
|
||||
|
||||
public Integer userIdAsInt() {
|
||||
return toInt(userId);
|
||||
}
|
||||
|
||||
public Integer roleIdAsInt() {
|
||||
return toInt(roleId);
|
||||
}
|
||||
|
||||
public Integer schoolIdAsInt() {
|
||||
return toInt(schoolId);
|
||||
}
|
||||
|
||||
public boolean hasRole(AgentRole role) {
|
||||
return roles != null && roles.contains(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用平台解析出的 {@link BoaUser} 补齐权威身份(比请求头更可信),并落定角色集合。
|
||||
*/
|
||||
public void fillFrom(BoaUser user, Set<AgentRole> resolvedRoles) {
|
||||
if (resolvedRoles != null && !resolvedRoles.isEmpty()) {
|
||||
this.roles = resolvedRoles;
|
||||
}
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
if (StringUtils.isNotBlank(user.getName())) {
|
||||
this.userName = user.getName();
|
||||
}
|
||||
if (StringUtils.isNotBlank(user.getAccount())) {
|
||||
this.account = user.getAccount();
|
||||
}
|
||||
if (user.getId() != null) {
|
||||
this.userId = String.valueOf(user.getId());
|
||||
}
|
||||
if (user.getCurrentRole() != null && user.getCurrentRole().getSchId() != null) {
|
||||
this.schoolId = String.valueOf(user.getCurrentRole().getSchId());
|
||||
}
|
||||
// roleId 兜底:优先用「当前身份」的角色号(下游老模块读该头时才是对的),
|
||||
// 没有当前身份时才退回 roleIds 里的第一个
|
||||
if (StringUtils.isBlank(this.roleId) && user.getCurrentRole() != null
|
||||
&& user.getCurrentRole().getRoleId() != null) {
|
||||
this.roleId = String.valueOf(user.getCurrentRole().getRoleId());
|
||||
}
|
||||
if (StringUtils.isBlank(this.roleId) && user.getRoleIds() != null) {
|
||||
for (Integer rid : user.getRoleIds()) {
|
||||
if (rid != null) {
|
||||
this.roleId = String.valueOf(rid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 当前身份名(角色名),便于前端展示「我现在是谁」
|
||||
if (user.getCurrentRole() != null && StringUtils.isNotBlank(user.getCurrentRole().getRoleName())) {
|
||||
this.currentRoleName = user.getCurrentRole().getRoleName();
|
||||
}
|
||||
}
|
||||
|
||||
public static CallerContext from(HttpServletRequest request) {
|
||||
CallerContext c = new CallerContext();
|
||||
c.currentRole = request.getHeader("currentRole");
|
||||
c.userId = request.getHeader("userId");
|
||||
c.roleId = request.getHeader("roleId");
|
||||
c.schoolId = request.getHeader("schoolId");
|
||||
c.referer = request.getHeader("yschreferer");
|
||||
c.userAgent = request.getHeader("User-Agent");
|
||||
|
||||
// Cookie 整体透传(平台 SSO 的登录态在 Cookie 里;SSO 亦可接受网关注入的会话)
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies != null && cookies.length > 0) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Cookie ck : cookies) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append("; ");
|
||||
}
|
||||
sb.append(ck.getName()).append("=").append(ck.getValue());
|
||||
}
|
||||
c.cookie = sb.toString();
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
private static Integer toInt(String s) {
|
||||
try {
|
||||
return StringUtils.isBlank(s) ? null : Integer.valueOf(s.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.yunhai.agent.tool;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 表单里的一个字段:由 {@link ToolParam} 生成,前端据此选择控件(下拉 / 日期时间 / 多行文本…)。
|
||||
*/
|
||||
@Data
|
||||
public class FormField {
|
||||
|
||||
/** 提交时的参数名(与业务接口一致) */
|
||||
private String name;
|
||||
|
||||
/** 中文标签,如「开始时间」 */
|
||||
private String label;
|
||||
|
||||
/** 控件类型:text / textarea / number / date / datetime / select */
|
||||
private String widget;
|
||||
|
||||
/** 数据类型:string / integer / number / boolean */
|
||||
private String type;
|
||||
|
||||
private boolean required;
|
||||
|
||||
/** 说明(来自工具定义,写清格式与含义) */
|
||||
private String description;
|
||||
|
||||
/** 格式示例,前端用作 placeholder,如 2026-10-11 08:00:00 */
|
||||
private String example;
|
||||
|
||||
private String defaultValue;
|
||||
|
||||
/**
|
||||
* 下拉选项的动态来源(字典工具名,如 leave.types)。
|
||||
* <p>有值但 {@link #options} 为空 ⇒ 字典接口没取到(多半是下游 302/超时),
|
||||
* 前端据此提示用户「可先手填编号」,而不是干瞪眼。
|
||||
*/
|
||||
private String optionsFrom;
|
||||
|
||||
/**
|
||||
* 当前 {@link #options} 是否来自内置兜底(而非学校实时字典)。
|
||||
* <p>为 true 时前端提示「用的是标准类型,自定义类型暂时没取到」,而不是静默当成权威值。
|
||||
*/
|
||||
private boolean optionsFallback;
|
||||
|
||||
/** 下拉选项 [{value,label}] */
|
||||
private List<Map<String, String>> options = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.yunhai.agent.tool;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 「信息不全」时下发给前端的补全表单。
|
||||
*
|
||||
* <p>设计意图:用户说「我要请假」时,与其让大模型自由发挥去追问(格式、必填项都不稳定),
|
||||
* 不如由后端从工具定义里**确定性地**推导出还缺哪些字段,连同中文名、格式示例、下拉选项
|
||||
* 一起交给前端渲染成表单 —— 用户填完即提交,链路最短、最准确。
|
||||
*/
|
||||
@Data
|
||||
public class FormRequest {
|
||||
|
||||
/** 目标工具名,如 leave.submit */
|
||||
private String tool;
|
||||
|
||||
/** 表单标题,如「提交请假申请」 */
|
||||
private String title;
|
||||
|
||||
/** 引导语,如「这些信息填好我就直接帮你提交」 */
|
||||
private String description;
|
||||
|
||||
private String module;
|
||||
|
||||
/** 是否写操作(前端据此做「提交」的视觉强调与二次确认) */
|
||||
private boolean write;
|
||||
|
||||
/** 提交成功后建议跳转的路由 */
|
||||
private String landingPath;
|
||||
|
||||
/** 待补全的字段(只含缺失的必填项;已由用户说出的参数放在 preset 里回显) */
|
||||
private List<FormField> fields = new ArrayList<>();
|
||||
|
||||
/** 已经从对话里确定的参数(表单里以只读方式回显,用户可确认后提交;提交时原样带上) */
|
||||
private Map<String, Object> preset = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 上述 preset 的「可读版」:[{name,label,text}],其中 text 已按字典翻译成中文
|
||||
* (如 leaveType=1 → 病假)。前端直接展示,不用自己维护中文映射。
|
||||
*/
|
||||
private List<Map<String, String>> recognized = new ArrayList<>();
|
||||
|
||||
/** 缺失字段的中文名,用于生成「还差:开始时间、事由」这类话术 */
|
||||
private List<String> missing = new ArrayList<>();
|
||||
|
||||
/** 补充提示(可选),如「时间格式为 2026-10-11 08:00:00」 */
|
||||
private String hint;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.yunhai.agent.tool;
|
||||
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.yunhai.agent.auth.AgentRole;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 一个「工具」= 一个可被大模型调用的业务动作(对应某个业务模块的一个 REST 接口)。
|
||||
*
|
||||
* <p>刻意做成**声明式**:新增模块只需要在 {@code defs} 包里声明工具,不用写新代码,
|
||||
* 执行统一走 {@link ToolExecutor}。这样"全量接入"才不会退化成复制粘贴。
|
||||
*
|
||||
* <p>2026-10 增补:{@link #roles} —— 声明该工具面向哪些身份(学生 / 教师 / 管理员)。
|
||||
* 未声明 = 所有角色可用;声明后既用于「模型侧过滤」,也用于「服务端越权拦截」。
|
||||
*/
|
||||
@Data
|
||||
public class ToolDef {
|
||||
|
||||
/**
|
||||
* 工具唯一名:{@code 模块.动作},如 leave.save / notice.list(小写点分,内部标识)。
|
||||
* <p>⚠️ 点号只是「内部」分隔符,**不能直接下发给大模型**,下发前必须过 {@link #wireName()}。
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/** 业务模块标识,对应 {agent.modules} 的 key,用于拼目标地址 */
|
||||
private String module;
|
||||
|
||||
/** 中文简称(前端展示"正在调用 XX"用) */
|
||||
private String title;
|
||||
|
||||
/** 给模型的能力说明:做什么、什么场景用、必填项的含义 */
|
||||
private String description;
|
||||
|
||||
/** 请求方式:GET / POST_JSON / POST_FORM */
|
||||
private String httpMethod = "GET";
|
||||
|
||||
/** 相对模块根地址的接口路径,如 /api/leave/Save */
|
||||
private String path;
|
||||
|
||||
/** 参数定义 */
|
||||
private List<ToolParam> params = new ArrayList<>();
|
||||
|
||||
/** 固定参数(接口要求但对话里无需用户提供,如学生申请固定 type=2) */
|
||||
private Map<String, String> fixed = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 平台菜单模块号(与 GetUserMod 的 modId 对齐)。
|
||||
* 非空时:该用户未被授权此模块,则不向模型暴露该工具(权限收敛,避免"越权调用"的错觉)。
|
||||
*/
|
||||
private Integer moduleId;
|
||||
|
||||
/**
|
||||
* 允许使用该工具的角色集合。空 = 所有角色可用。
|
||||
* <p>用于「分学生、教师权限」:学生侧工具(请假申请/交作业/报名)与
|
||||
* 教职工侧工具(审批/发布/登记)互不越界。
|
||||
*/
|
||||
private Set<AgentRole> roles = new LinkedHashSet<>();
|
||||
|
||||
/** 是否只读(查询类)。false = 会产生副作用(提交/审批/删除),前端会做二次确认提示 */
|
||||
private boolean readOnly = true;
|
||||
|
||||
/**
|
||||
* 建议前端跳转的模块内路由(模块前端的 hash 路由,如 {@code /my})。
|
||||
*
|
||||
* <p>两种来源:
|
||||
* <ul>
|
||||
* <li><b>工具显式声明</b>:写操作用 {@link #write(String)}(同时标记 {@code readOnly=false}),
|
||||
* 只读查询用 {@link #readAt(String)}(只设落地页、不改只读语义);</li>
|
||||
* <li><b>模块默认兜底</b>:两者都没声明时,由 {@code ToolRegistry} 在注册期用
|
||||
* {@code agent.module-landing.<模块>} 回填(见 {@code AgentSettings#moduleLanding})。</li>
|
||||
* </ul>
|
||||
* 兜底的意义:服务端调不通模块用户态接口(下游 {@code code=302})时,前端仍能给出
|
||||
* 「去系统里查看」入口,让用户带自己的登录态看到真实数据。
|
||||
*/
|
||||
private String landingPath;
|
||||
|
||||
public ToolDef() {
|
||||
}
|
||||
|
||||
public static ToolDef of(String name, String module, String title, String description,
|
||||
String httpMethod, String path) {
|
||||
ToolDef d = new ToolDef();
|
||||
d.name = name;
|
||||
d.module = module;
|
||||
d.title = title;
|
||||
d.description = description;
|
||||
d.httpMethod = httpMethod;
|
||||
d.path = path;
|
||||
return d;
|
||||
}
|
||||
|
||||
public ToolDef p(ToolParam... ps) {
|
||||
this.params.addAll(Arrays.asList(ps));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ToolDef fix(String key, String value) {
|
||||
this.fixed.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ToolDef modId(Integer moduleId) {
|
||||
this.moduleId = moduleId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 声明该工具面向的角色(可多个)。不调用 = 所有角色可用。 */
|
||||
public ToolDef allow(AgentRole... rs) {
|
||||
this.roles.addAll(Arrays.asList(rs));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ToolDef write(String landingPath) {
|
||||
this.readOnly = false;
|
||||
this.landingPath = landingPath;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 声明「只读工具」的落地页 —— <b>不改变 {@code readOnly} 语义</b>(仍标记为查询,前端不做二次确认)。
|
||||
*
|
||||
* <p>用途:查询失败(如下游 {@code code=302} 需要浏览器登录态)时,前端能给出
|
||||
* 「去系统里查看」入口。典型场景是教职工侧查询(如班级名单、请假统计)与学生侧
|
||||
* 看到默认页不是同一个页面,需要逐工具指明。
|
||||
*/
|
||||
public ToolDef readAt(String landingPath) {
|
||||
this.landingPath = landingPath;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 当前调用者(角色集合)是否可用该工具。未声明角色 = 人人可用。 */
|
||||
public boolean allowsAny(Set<AgentRole> callerRoles) {
|
||||
if (roles == null || roles.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
if (callerRoles == null || callerRoles.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (AgentRole r : callerRoles) {
|
||||
if (roles.contains(r)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 角色受限的可读描述,如「教师/管理员」 */
|
||||
public String roleLabel() {
|
||||
if (roles == null || roles.isEmpty()) {
|
||||
return "全部角色";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (AgentRole r : roles) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append("/");
|
||||
}
|
||||
sb.append(r.getLabel());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 下发给大模型时使用的工具名(wire name)。
|
||||
*
|
||||
* <p>★ 大模型对 {@code function.name} 有字符集硬约束:<b>只允许 {@code ^[a-zA-Z0-9_-]+$}</b>。
|
||||
* 点号是非法字符 —— 直接下发 {@code leave.myClasses} 会被服务端拒绝:
|
||||
* <pre>Invalid 'tools[0].function.name': string does not match pattern
|
||||
* . Expected a string that matches the pattern '^[a-zA-Z0-9_-]+$'.</pre>
|
||||
* 故对外统一把 {@code .} 替换为 {@code _}({@code leave.myClasses} → {@code leave_myClasses}),
|
||||
* 内部仍保留点分名(前端目录、审计日志、表单回传都不受影响)。
|
||||
* 回传的名字由 {@code ToolRegistry#get} 负责还原。
|
||||
*/
|
||||
public String wireName() {
|
||||
return name == null ? null : name.replace('.', '_');
|
||||
}
|
||||
|
||||
/** 转成 OpenAI / DeepSeek 的 tools 元素:{type:function, function:{name,description,parameters}} */
|
||||
public JSONObject toOpenAiTool() {
|
||||
JSONObject fn = new JSONObject();
|
||||
fn.put("name", wireName());
|
||||
fn.put("description", description);
|
||||
|
||||
JSONObject props = new JSONObject();
|
||||
JSONArray required = new JSONArray();
|
||||
for (ToolParam p : params) {
|
||||
props.put(p.getName(), p.toSchema());
|
||||
if (p.isRequired()) {
|
||||
required.add(p.getName());
|
||||
}
|
||||
}
|
||||
JSONObject parameters = new JSONObject();
|
||||
parameters.put("type", "object");
|
||||
parameters.put("properties", props);
|
||||
parameters.put("required", required);
|
||||
fn.put("parameters", parameters);
|
||||
|
||||
JSONObject tool = new JSONObject();
|
||||
tool.put("type", "function");
|
||||
tool.put("function", fn);
|
||||
return tool;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package com.yunhai.agent.tool;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.yunhai.agent.config.AgentSettings;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工具执行器:把「模型给的参数」变成「对业务模块的一次真实 HTTP 调用」,并把结果规范化。
|
||||
*
|
||||
* <p>统一约定(与前端各工程 alova 封装完全一致,保证行为可预期):
|
||||
* <ul>
|
||||
* <li>平台响应 {@code {code,msg,data}}:{@code code==0} 视为成功,取 {@code data};否则取 {@code msg} 作为错误</li>
|
||||
* <li>{@code POST_JSON} ↔ 后端 {@code @RequestBody}</li>
|
||||
* <li>{@code POST_FORM} ↔ 后端 {@code @RequestParam}(表单)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>★ 2026-10 增补:执行前做<b>角色强校验</b>——越权的工具调用直接拒绝、不发真实请求,
|
||||
* 这是「分学生、教师权限」的服务端硬约束(模型侧过滤只是第一道,这里才是兜底)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ToolExecutor {
|
||||
|
||||
private final AgentSettings settings;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public ToolExecutor(AgentSettings settings, @Qualifier("yhRestTemplate") RestTemplate restTemplate) {
|
||||
this.settings = settings;
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色门禁:当前身份无权使用该工具时返回拒绝结果(不发真实请求),有权返回 {@code null}。
|
||||
* <p>服务端执行({@link #execute})与浏览器代执行的续跑({@code parseBrowserResponse} 之前)
|
||||
* 共用这一道硬约束。
|
||||
*/
|
||||
public ToolResult checkAccess(ToolDef def, CallerContext caller) {
|
||||
if (settings.isEnforceRolePermission() && !def.allowsAny(caller == null ? null : caller.getRoles())) {
|
||||
String who = (caller == null || caller.getRoles() == null || caller.getRoles().isEmpty())
|
||||
? "访客" : caller.getRoles().toString();
|
||||
log.warn("[越权拦截] userId={} roles={} 尝试调用受限工具 {}(仅限 {})",
|
||||
caller == null ? null : caller.getUserId(), who, def.getName(), def.roleLabel());
|
||||
return ToolResult.fail(def,
|
||||
"当前身份无权执行「" + def.getTitle() + "」(该操作仅限:" + def.roleLabel() + ")。"
|
||||
+ "如需办理,请使用对应身份的账号,或到相应页面处理。", 0);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一个工具。
|
||||
*
|
||||
* @param def 工具定义
|
||||
* @param args 模型给出的参数(已容错解析成 Map)
|
||||
* @param caller 调用者身份(用于透传与角色校验)
|
||||
*/
|
||||
public ToolResult execute(ToolDef def, Map<String, Object> args, CallerContext caller) {
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
// ★ 角色强校验:当前身份无权使用该工具 → 拒绝执行,不发真实请求
|
||||
ToolResult denied = checkAccess(def, caller);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
|
||||
String base = settings.getModules().get(def.getModule());
|
||||
if (StringUtils.isBlank(base)) {
|
||||
return ToolResult.fail(def, "模块「" + def.getModule() + "」未配置服务地址(agent.modules)",
|
||||
System.currentTimeMillis() - start);
|
||||
}
|
||||
|
||||
// 合并参数:模型参数 + 固定参数(固定参数优先级更高,避免模型改坏接口约定)
|
||||
Map<String, Object> merged = new LinkedHashMap<>();
|
||||
if (args != null) {
|
||||
merged.putAll(args);
|
||||
}
|
||||
if (def.getFixed() != null) {
|
||||
merged.putAll(def.getFixed());
|
||||
}
|
||||
|
||||
// 缺参校验(必填项)
|
||||
for (ToolParam p : def.getParams()) {
|
||||
if (p.isRequired() && isBlank(merged.get(p.getName()))) {
|
||||
if (p.getDefaultValue() != null) {
|
||||
merged.put(p.getName(), p.getDefaultValue());
|
||||
} else {
|
||||
return ToolResult.fail(def, "缺少必填参数「" + p.getName() + "」(" + p.getDescription() + ")",
|
||||
System.currentTimeMillis() - start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 路径占位替换:如 /api/student/homeworks/{id}/submissions → 取 id 填进路径,并从参数表中移除
|
||||
HttpHeaders headers = buildHeaders(caller);
|
||||
String url = trimEnd(base) + def.getPath();
|
||||
|
||||
try {
|
||||
String resolvedPath = resolvePath(def.getPath(), merged);
|
||||
url = trimEnd(base) + resolvedPath;
|
||||
|
||||
ResponseEntity<String> resp;
|
||||
String method = def.getHttpMethod() == null ? "GET" : def.getHttpMethod().toUpperCase();
|
||||
|
||||
switch (method) {
|
||||
case "GET": {
|
||||
String fullUrl = appendQuery(url, merged, def);
|
||||
resp = restTemplate.exchange(fullUrl, HttpMethod.GET, new HttpEntity<>(headers), String.class);
|
||||
break;
|
||||
}
|
||||
case "POST_JSON": {
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
String body = JSON.toJSONString(normalizeJsonArgs(merged, def));
|
||||
resp = restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(body, headers), String.class);
|
||||
break;
|
||||
}
|
||||
case "POST_FORM": {
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
String body = toFormBody(merged);
|
||||
resp = restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(body, headers), String.class);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return ToolResult.fail(def, "不支持的请求方式:" + method, System.currentTimeMillis() - start);
|
||||
}
|
||||
|
||||
long cost = System.currentTimeMillis() - start;
|
||||
return parseResponse(def, resp, cost);
|
||||
|
||||
} catch (Exception e) {
|
||||
long cost = System.currentTimeMillis() - start;
|
||||
log.error("工具 {} 调用失败 url={} err={}", def.getName(), url, e.toString());
|
||||
String msg = e.getMessage() == null ? e.toString() : e.getMessage();
|
||||
if (msg.contains("Connection refused") || msg.contains("connect timed out")) {
|
||||
msg = "无法连接业务模块「" + def.getModule() + "」(" + base + "),请确认该服务已启动";
|
||||
}
|
||||
return ToolResult.fail(def, msg, cost);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 浏览器代执行(方案 A) ==================== */
|
||||
|
||||
/**
|
||||
* 把「模型参数」准备成一次可供<b>前端浏览器</b>执行的调用描述。
|
||||
*
|
||||
* <p>与服务端 {@link #execute} 完全同源的逻辑:合并固定参数、必填校验、路径占位替换、
|
||||
* query/请求体构造 —— 差别只在「谁来发这个 HTTP 请求」。参数不合法时抛
|
||||
* {@link IllegalArgumentException} / {@link IllegalStateException},调用方据此回退服务端执行。
|
||||
*
|
||||
* @return 描述里的 {@code pathAndQuery} 以 {@code /} 开头(已去掉协议与域名),
|
||||
* 前端拼 {@code location.origin} 即为最终地址(同站部署 ⇒ 同源)
|
||||
*/
|
||||
public com.yunhai.agent.model.BrowserToolRequest.BrowserCall prepareBrowserCall(
|
||||
ToolDef def, Map<String, Object> args) {
|
||||
// 合并参数:模型参数 + 固定参数(固定参数优先级更高,避免模型改坏接口约定)
|
||||
Map<String, Object> merged = new LinkedHashMap<>();
|
||||
if (args != null) {
|
||||
merged.putAll(args);
|
||||
}
|
||||
if (def.getFixed() != null) {
|
||||
merged.putAll(def.getFixed());
|
||||
}
|
||||
|
||||
// 缺参校验(必填项)
|
||||
for (ToolParam p : def.getParams()) {
|
||||
if (p.isRequired() && isBlank(merged.get(p.getName()))) {
|
||||
if (p.getDefaultValue() != null) {
|
||||
merged.put(p.getName(), p.getDefaultValue());
|
||||
} else {
|
||||
throw new IllegalArgumentException(
|
||||
"缺少必填参数「" + p.getName() + "」(" + p.getDescription() + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String base = trimEnd(settings.getModules().get(def.getModule()));
|
||||
String resolvedPath = resolvePath(def.getPath(), merged);
|
||||
String method = def.getHttpMethod() == null ? "GET" : def.getHttpMethod().toUpperCase();
|
||||
|
||||
com.yunhai.agent.model.BrowserToolRequest.BrowserCall call =
|
||||
new com.yunhai.agent.model.BrowserToolRequest.BrowserCall();
|
||||
call.setName(def.getName());
|
||||
call.setTitle(def.getTitle());
|
||||
call.setWrite(!def.isReadOnly());
|
||||
call.setArguments(args == null ? new LinkedHashMap<>() : args);
|
||||
|
||||
switch (method) {
|
||||
case "GET": {
|
||||
// 先按完整 URL 拼查询串,再剥掉协议与域名(前端只认相对路径)
|
||||
String full = appendQuery(base + resolvedPath, merged, def);
|
||||
call.setPathAndQuery(stripOrigin(full));
|
||||
break;
|
||||
}
|
||||
case "POST_JSON": {
|
||||
call.setPathAndQuery(stripOrigin(base + resolvedPath));
|
||||
call.setBody(JSON.toJSONString(normalizeJsonArgs(merged, def)));
|
||||
call.setContentType("application/json;charset=UTF-8");
|
||||
break;
|
||||
}
|
||||
case "POST_FORM": {
|
||||
call.setPathAndQuery(stripOrigin(base + resolvedPath));
|
||||
call.setBody(toFormBody(merged));
|
||||
call.setContentType("application/x-www-form-urlencoded;charset=UTF-8");
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new IllegalArgumentException("不支持的请求方式:" + method);
|
||||
}
|
||||
call.setMethod(method);
|
||||
return call;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析浏览器回传的模块响应 —— 解析规则与服务端直调 {@link #execute} 完全一致
|
||||
* ({@code {code,msg,data}} 包装、{@code code=302} 识别、非包装响应原样返回)。
|
||||
*
|
||||
* @param status 浏览器 fetch 拿到的 HTTP 状态码(网络层失败时为 null)
|
||||
* @param body 响应体原文
|
||||
*/
|
||||
public ToolResult parseBrowserResponse(ToolDef def, Integer status, String body, long costMs) {
|
||||
if (status == null) {
|
||||
return ToolResult.fail(def, "浏览器调用没有完成(未取得模块响应),请稍后重试", costMs);
|
||||
}
|
||||
if (status < 200 || status >= 300) {
|
||||
return ToolResult.fail(def, "模块接口返回 HTTP " + status, costMs);
|
||||
}
|
||||
// ★ 浏览器通道专属守卫:拿回网页而不是数据,说明请求根本没落到模块接口上
|
||||
// (未同站部署 / 落到了 SPA 首页或登录页)。此时绝不能当"成功"喂给模型 ——
|
||||
// 服务端直调不会出现这种形态(它直接打模块 API),故只在本方法里拦。
|
||||
if (body != null && body.trim().startsWith("<")) {
|
||||
return ToolResult.fail(def,
|
||||
"通过浏览器调用「" + def.getTitle() + "」拿回的是网页而不是数据,"
|
||||
+ "多半是请求没落到模块接口上(本应用与模块不同源,或落到了登录页/单页应用首页)。"
|
||||
+ "请点下方「去系统里自己查看」,用你自己的登录态直接打开该模块页面。", costMs);
|
||||
}
|
||||
org.springframework.http.HttpStatus hs = org.springframework.http.HttpStatus.resolve(status);
|
||||
if (hs == null) {
|
||||
return ToolResult.fail(def, "模块接口返回 HTTP " + status, costMs);
|
||||
}
|
||||
return parseResponse(def, new ResponseEntity<>(body == null ? "" : body, hs), costMs);
|
||||
}
|
||||
|
||||
/** 去掉 URL 里的协议与域名,只留 path+query(本服务的模块地址都由配置拼出,格式可控) */
|
||||
private String stripOrigin(String fullUrl) {
|
||||
int idx = fullUrl.indexOf("://");
|
||||
if (idx < 0) {
|
||||
return fullUrl;
|
||||
}
|
||||
int slash = fullUrl.indexOf('/', idx + 3);
|
||||
return slash < 0 ? "/" : fullUrl.substring(slash);
|
||||
}
|
||||
|
||||
/* ==================== 内部实现 ==================== */
|
||||
|
||||
/**
|
||||
* 把路径里的 {@code {name}} 占位符替换成参数值,并把已消费的参数从参数表移除。
|
||||
* <p>示例:{@code /api/student/homeworks/{id}/submissions} + {@code {id:12,text:"..."}}
|
||||
* → 路径 {@code /api/student/homeworks/12/submissions},剩余参数 {@code {text:"..."}}。
|
||||
*/
|
||||
private String resolvePath(String path, Map<String, Object> args) {
|
||||
if (path == null || path.indexOf('{') < 0) {
|
||||
return path;
|
||||
}
|
||||
String result = path;
|
||||
List<String> consumed = new java.util.ArrayList<>();
|
||||
for (Map.Entry<String, Object> e : args.entrySet()) {
|
||||
String token = "{" + e.getKey() + "}";
|
||||
if (result.contains(token)) {
|
||||
Object v = e.getValue();
|
||||
result = result.replace(token, v == null ? "" : urlEncodePath(stringify(v)));
|
||||
consumed.add(e.getKey());
|
||||
}
|
||||
}
|
||||
for (String k : consumed) {
|
||||
args.remove(k);
|
||||
}
|
||||
if (result.contains("{")) {
|
||||
// 还有未替换的占位 → 视为参数缺失,交给上层报错更清晰
|
||||
throw new IllegalStateException("接口路径存在未填充的占位符:" + result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 路径段编码:保留常见安全字符,中文等必须编码 */
|
||||
private String urlEncodePath(String s) {
|
||||
try {
|
||||
return URLEncoder.encode(s, "UTF-8").replace("+", "%20");
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
private HttpHeaders buildHeaders(CallerContext caller) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("yschapirequest", "1");
|
||||
if (caller != null) {
|
||||
if (StringUtils.isNotBlank(caller.getCurrentRole())) {
|
||||
headers.set("currentRole", caller.getCurrentRole());
|
||||
}
|
||||
if (StringUtils.isNotBlank(caller.getUserId())) {
|
||||
headers.set("userId", caller.getUserId());
|
||||
}
|
||||
if (StringUtils.isNotBlank(caller.getRoleId())) {
|
||||
headers.set("roleId", caller.getRoleId());
|
||||
}
|
||||
if (StringUtils.isNotBlank(caller.getSchoolId())) {
|
||||
headers.set("schoolId", caller.getSchoolId());
|
||||
}
|
||||
if (StringUtils.isNotBlank(caller.getCookie())) {
|
||||
headers.set("Cookie", caller.getCookie());
|
||||
}
|
||||
if (StringUtils.isNotBlank(caller.getReferer())) {
|
||||
headers.set("yschreferer", caller.getReferer());
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** 解析平台统一响应 {code,msg,data} */
|
||||
private ToolResult parseResponse(ToolDef def, ResponseEntity<String> resp, long cost) {
|
||||
String raw = resp.getBody();
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return ToolResult.ok(def, "{\"success\":true,\"data\":null}", null, cost);
|
||||
}
|
||||
|
||||
JSONObject json;
|
||||
try {
|
||||
json = JSON.parseObject(raw);
|
||||
} catch (Exception e) {
|
||||
// 非包装响应(如导出 CSV 文本)直接原样返回
|
||||
return ToolResult.ok(def, truncate(raw), raw, cost);
|
||||
}
|
||||
if (json == null || !json.containsKey("code")) {
|
||||
return ToolResult.ok(def, truncate(raw), json, cost);
|
||||
}
|
||||
|
||||
int code = json.getIntValue("code");
|
||||
// 302 = 平台 CAS 过滤器要求「浏览器登录态」。服务端调用没有用户的业务系统会话,
|
||||
// 应用间签名也**不能**替代会话(签名只对 /oapi/* 生效)——把话说清楚,别让使用者以为是网络问题。
|
||||
if (code == 302) {
|
||||
return ToolResult.fail(def, "读取「" + def.getTitle() + "」需要你在业务系统里的登录态(下游返回 code=302)。"
|
||||
+ "服务端无法代表你登录业务模块 —— 点下方「去系统里查看」,用你自己的登录态直接打开该模块页面即可。", cost);
|
||||
}
|
||||
if (code != 0) {
|
||||
String msg = json.getString("msg");
|
||||
return ToolResult.fail(def, StringUtils.isBlank(msg) ? ("业务错误 code=" + code) : msg, cost);
|
||||
}
|
||||
|
||||
Object data = json.get("data");
|
||||
String content = data == null ? "{\"success\":true,\"data\":null}"
|
||||
: truncate(JSON.toJSONString(data));
|
||||
// 写操作没有返回体时,补一句明确结果,避免模型以为失败
|
||||
if (data == null && !def.isReadOnly()) {
|
||||
content = "{\"success\":true,\"message\":\"" + def.getTitle() + " 已执行成功\"}";
|
||||
}
|
||||
return ToolResult.ok(def, content, data, cost);
|
||||
}
|
||||
|
||||
/** GET:把参数拼到 query(跳过空值与固定参数里已给的重复项由 Map 天然覆盖) */
|
||||
private String appendQuery(String url, Map<String, Object> args, ToolDef def) {
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url);
|
||||
for (Map.Entry<String, Object> e : args.entrySet()) {
|
||||
Object v = e.getValue();
|
||||
if (v == null || StringUtils.isBlank(String.valueOf(v))) {
|
||||
continue;
|
||||
}
|
||||
builder.queryParam(e.getKey(), stringify(v));
|
||||
}
|
||||
return builder.build(false).toUriString();
|
||||
}
|
||||
|
||||
/** POST_JSON:只保留非空参数 */
|
||||
private Map<String, Object> normalizeJsonArgs(Map<String, Object> args, ToolDef def) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> e : args.entrySet()) {
|
||||
Object v = e.getValue();
|
||||
if (v == null || StringUtils.isBlank(String.valueOf(v))) {
|
||||
continue;
|
||||
}
|
||||
out.put(e.getKey(), v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private String toFormBody(Map<String, Object> args) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Map.Entry<String, Object> e : args.entrySet()) {
|
||||
Object v = e.getValue();
|
||||
if (v == null || StringUtils.isBlank(String.valueOf(v))) {
|
||||
continue;
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.append("&");
|
||||
}
|
||||
sb.append(urlEncode(e.getKey())).append("=").append(urlEncode(stringify(v)));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 数组 → 逗号串;其余直接 toString */
|
||||
private String stringify(Object v) {
|
||||
if (v instanceof List) {
|
||||
List<?> list = (List<?>) v;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Object o : list) {
|
||||
if (o == null) {
|
||||
continue;
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.append(",");
|
||||
}
|
||||
sb.append(o);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
if (v instanceof JSONArray) {
|
||||
return String.join(",", ((JSONArray) v).toJavaList(String.class));
|
||||
}
|
||||
return String.valueOf(v);
|
||||
}
|
||||
|
||||
private boolean isBlank(Object v) {
|
||||
return v == null || StringUtils.isBlank(String.valueOf(v));
|
||||
}
|
||||
|
||||
private String urlEncode(String s) {
|
||||
try {
|
||||
return URLEncoder.encode(s, "UTF-8");
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
private String truncate(String s) {
|
||||
int max = settings.getLoop().getToolResultMaxChars();
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
if (s.length() <= max) {
|
||||
return s;
|
||||
}
|
||||
return s.substring(0, max) + "...[已截断,共 " + s.length() + " 字符]";
|
||||
}
|
||||
|
||||
private String trimEnd(String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
while (s.endsWith("/")) {
|
||||
s = s.substring(0, s.length() - 1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 工具参数定义。一份声明,三处复用:
|
||||
*
|
||||
* <ol>
|
||||
* <li>生成给大模型的 JSON Schema({@link #toSchema()})</li>
|
||||
* <li>入参必填校验</li>
|
||||
* <li><b>生成让用户直接填写的表单</b>({@link #toFormField()})—— 信息不全时不再靠模型口头追问,
|
||||
* 而是把字段的中文名、格式示例、可选值直接摆到界面上</li>
|
||||
* </ol>
|
||||
*/
|
||||
@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<String> enumValues;
|
||||
|
||||
/** 枚举的中文名,与 enumValues 一一对应(表单下拉展示用;缺省时直接显示值) */
|
||||
private List<String> enumLabels;
|
||||
|
||||
/**
|
||||
* 内置兜底枚举:只在「学校可自定义的字典」上使用,是 {@link #optionsFrom} 取不到实时值时的顶替品。
|
||||
* <p>有了它,下游字典接口不通时下拉框仍然是下拉框,而不是退化成让用户填「请假类型编号」的数字框。
|
||||
* 与 {@link #enumValues} 的区别:enumValues 是权威定义(如审批结论),本字段是「大概是这样,以实时字典为准」。
|
||||
*/
|
||||
private List<String> fallbackValues;
|
||||
|
||||
/** 内置兜底枚举的中文名,与 fallbackValues 一一对应 */
|
||||
private List<String> fallbackLabels;
|
||||
|
||||
/** 默认值(模型没给时兜底) */
|
||||
private String defaultValue;
|
||||
|
||||
/* ==================== 表单渲染元数据 ==================== */
|
||||
|
||||
/** 字段中文标签(表单里显示,如「开始时间」);缺省回退到 name */
|
||||
private String label;
|
||||
|
||||
/** 示例值(表单 placeholder,如 2026-10-11 08:00:00),也会进入给模型的描述 */
|
||||
private String example;
|
||||
|
||||
/** 控件类型:text / textarea / number / date / datetime / select;缺省按参数名与类型自动推断 */
|
||||
private String widget;
|
||||
|
||||
/** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */
|
||||
private boolean hidden;
|
||||
|
||||
/**
|
||||
* 该字段的下拉选项来自哪个只读工具(如 {@code leave.types})。
|
||||
* <p>服务端在生成表单时会先执行这个工具,把返回的字典项填成下拉选项 ——
|
||||
* 用户就不用去猜「病假是几号」了。执行失败则回退为普通输入框,不影响主流程。
|
||||
*/
|
||||
private String optionsFrom;
|
||||
|
||||
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} 使用。
|
||||
* <p>实时字典取到 → 用实时值;取不到 → 用这份兜底,下拉框不至于消失。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** 表单里显示的名字 */
|
||||
public String labelOf() {
|
||||
return StringUtils.isNotBlank(label) ? label : name;
|
||||
}
|
||||
|
||||
/** 当前生效的枚举值:权威枚举优先,其次内置兜底 */
|
||||
private List<String> effectiveValues() {
|
||||
if (enumValues != null && !enumValues.isEmpty()) return enumValues;
|
||||
return fallbackValues;
|
||||
}
|
||||
|
||||
/** 当前生效的枚举中文名,与 {@link #effectiveValues()} 对应 */
|
||||
private List<String> effectiveLabels() {
|
||||
if (enumValues != null && !enumValues.isEmpty()) return enumLabels;
|
||||
return fallbackLabels;
|
||||
}
|
||||
|
||||
/** 当前选项是否来自内置兜底(即实时字典没取到,用的是「大概是这样」的值) */
|
||||
public boolean isUsingFallbackOptions() {
|
||||
return (enumValues == null || enumValues.isEmpty())
|
||||
&& fallbackValues != null && !fallbackValues.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 推断控件类型:显式指定优先,其次枚举→下拉,然后按类型与参数名语义判断。
|
||||
* <p>参数名里的 date/time 是强信号(startDate / endDate / leaveTime),
|
||||
* 文本类词根(remarks / reason / content)则渲染成多行输入。
|
||||
*/
|
||||
public String resolveWidget() {
|
||||
if (StringUtils.isNotBlank(widget)) return widget;
|
||||
List<String> vals = effectiveValues();
|
||||
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<Map<String, String>> optionList() {
|
||||
List<Map<String, String>> list = new ArrayList<>();
|
||||
List<String> vals = effectiveValues();
|
||||
List<String> labs = effectiveLabels();
|
||||
if (vals == null) return list;
|
||||
for (int i = 0; i < vals.size(); i++) {
|
||||
Map<String, String> 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.setOptionsFallback(isUsingFallbackOptions());
|
||||
return f;
|
||||
}
|
||||
|
||||
/** 转成 JSON Schema 的属性节点(给模型看) */
|
||||
public JSONObject toSchema() {
|
||||
JSONObject o = new JSONObject();
|
||||
o.put("type", type);
|
||||
String desc = description == null ? "" : description;
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.yunhai.agent.tool;
|
||||
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.yunhai.agent.auth.AgentRole;
|
||||
import com.yunhai.agent.auth.RolePolicy;
|
||||
import com.yunhai.agent.config.AgentSettings;
|
||||
import com.yunhai.agent.tool.defs.ActivityTools;
|
||||
import com.yunhai.agent.tool.defs.CampusNoticeTools;
|
||||
import com.yunhai.agent.tool.defs.DormNoticeTools;
|
||||
import com.yunhai.agent.tool.defs.HomeworkTools;
|
||||
import com.yunhai.agent.tool.defs.LeaveTools;
|
||||
import com.yunhai.agent.tool.defs.ProfessionTools;
|
||||
import com.yunhai.agent.tool.defs.ResourceTools;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 工具注册表:所有可被大模型调用的业务动作都在这里汇总。
|
||||
*
|
||||
* <p>新增一个模块的接入方式:写一个 {@code XxxTools#defs()} 返回 {@code List<ToolDef>},
|
||||
* 在下面的 {@link #build()} 里加一行。执行、鉴权、schema 生成全部复用通用实现。
|
||||
*
|
||||
* <p>可见性由两个维度共同决定:
|
||||
* <ol>
|
||||
* <li>{@code modId} —— 平台菜单授权({@code agent.filter-tools-by-authorized-module=true} 时生效);</li>
|
||||
* <li>{@code roles} —— 学生 / 教师 / 管理员角色(「分学生、教师权限」的落点)。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ToolRegistry {
|
||||
|
||||
private final AgentSettings settings;
|
||||
|
||||
/** name → ToolDef(保持插入顺序,便于目录展示) */
|
||||
private final Map<String, ToolDef> tools = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 大模型侧工具名(点换下划线)→ 内部点分名。
|
||||
*
|
||||
* <p>大模型对 {@code function.name} 只接受 {@code ^[a-zA-Z0-9_-]+$},点号非法,
|
||||
* 所以下发时用 {@link ToolDef#wireName()},模型回传的也是这个名字,这里负责还原。
|
||||
*/
|
||||
private final Map<String, String> wireAliases = new LinkedHashMap<>();
|
||||
|
||||
public ToolRegistry(AgentSettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
build();
|
||||
log.info("智能体工具注册完成,共 {} 个:{}", tools.size(), String.join(", ", tools.keySet()));
|
||||
// 校验:工具引用的模块必须在 agent.modules 里配了地址,否则运行期必然失败
|
||||
for (ToolDef d : tools.values()) {
|
||||
if (!settings.getModules().containsKey(d.getModule())) {
|
||||
log.warn("工具 {} 引用了未配置地址的模块「{}」,请检查 agent.modules", d.getName(), d.getModule());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void build() {
|
||||
register(LeaveTools.defs());
|
||||
register(CampusNoticeTools.defs());
|
||||
register(DormNoticeTools.defs());
|
||||
register(ActivityTools.defs());
|
||||
register(HomeworkTools.defs());
|
||||
register(ResourceTools.defs());
|
||||
register(ProfessionTools.defs());
|
||||
// 套用「学生 / 教师」角色能力策略(未显式声明角色的工具在此落定)
|
||||
RolePolicy.apply(tools.values());
|
||||
}
|
||||
|
||||
private void register(List<ToolDef> defs) {
|
||||
for (ToolDef d : defs) {
|
||||
if (tools.containsKey(d.getName())) {
|
||||
throw new IllegalStateException("工具名重复:" + d.getName());
|
||||
}
|
||||
String wire = d.wireName();
|
||||
// wire 名撞车会导致模型回传无法还原(点→下划线是单向压缩),注册期就拦下来
|
||||
if (tools.containsKey(wire)) {
|
||||
throw new IllegalStateException("工具名冲突:" + d.getName() + " 的模型侧名字「" + wire
|
||||
+ "」与已有工具同名,请改名");
|
||||
}
|
||||
if (wire != null && !wire.equals(d.getName())) {
|
||||
String prev = wireAliases.put(wire, d.getName());
|
||||
if (prev != null) {
|
||||
throw new IllegalStateException("模型侧工具名冲突:" + d.getName() + " 与 " + prev
|
||||
+ " 换成模型可用名后都是「" + wire + "」,请改名");
|
||||
}
|
||||
}
|
||||
// ★ 深链兜底:工具没声明落地页时,取该模块的默认页面(agent.module-landing)。
|
||||
// 这样"查询失败 / 越权拦截"也能带着一个真实可打开的模块地址回到前端。
|
||||
if (StringUtils.isBlank(d.getLandingPath())) {
|
||||
d.setLandingPath(settings.getModuleLanding().get(d.getModule()));
|
||||
}
|
||||
tools.put(d.getName(), d);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名字取工具:**同时兼容内部点分名与大模型侧下划线名**。
|
||||
*
|
||||
* <p>模型回传的 {@code function.name} 是 {@link ToolDef#wireName()}(点已换成下划线),
|
||||
* 而前端表单回传、字典工具引用用的是内部点分名,两种都要能命中。
|
||||
*/
|
||||
public ToolDef get(String name) {
|
||||
if (name == null) {
|
||||
return null;
|
||||
}
|
||||
ToolDef d = tools.get(name);
|
||||
if (d != null) {
|
||||
return d;
|
||||
}
|
||||
String real = wireAliases.get(name);
|
||||
return real == null ? null : tools.get(real);
|
||||
}
|
||||
|
||||
public Collection<ToolDef> all() {
|
||||
return tools.values();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按「已授权模块 + 角色」过滤后,转成给模型看的 tools schema。
|
||||
*
|
||||
* @param authorizedModIds 当前用户已授权的 modId 集合;为 null 表示不按 modId 过滤
|
||||
* @param roles 当前用户角色集合;为 null 表示不按角色过滤(排障用)
|
||||
*/
|
||||
public JSONArray toOpenAiTools(Set<Integer> authorizedModIds, Set<AgentRole> roles) {
|
||||
JSONArray arr = new JSONArray();
|
||||
for (ToolDef d : tools.values()) {
|
||||
if (!visible(d, authorizedModIds, roles)) {
|
||||
continue;
|
||||
}
|
||||
arr.add(d.toOpenAiTool());
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/** 供前端「我能用这个智能体做什么」的目录展示 */
|
||||
public List<ToolDef> visibleTools(Set<Integer> authorizedModIds, Set<AgentRole> roles) {
|
||||
return tools.values().stream()
|
||||
.filter(d -> visible(d, authorizedModIds, roles))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private boolean visible(ToolDef d, Set<Integer> authorizedModIds, Set<AgentRole> roles) {
|
||||
// 1) modId 维度:仅在开启「按授权模块过滤」时才过滤(modId 为 null 的工具不参与该维度)
|
||||
if (d.getModuleId() != null && authorizedModIds != null && !authorizedModIds.contains(d.getModuleId())) {
|
||||
return false;
|
||||
}
|
||||
// 2) 角色维度:未声明角色的工具人人可用
|
||||
return d.allowsAny(roles);
|
||||
}
|
||||
|
||||
/** 按模块分组统计(目录接口用) */
|
||||
public Map<String, List<ToolDef>> groupByModule(Set<Integer> authorizedModIds, Set<AgentRole> roles) {
|
||||
Map<String, List<ToolDef>> map = new LinkedHashMap<>();
|
||||
for (ToolDef d : visibleTools(authorizedModIds, roles)) {
|
||||
map.computeIfAbsent(d.getModule(), k -> new ArrayList<>()).add(d);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.yunhai.agent.tool;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 一次工具执行的结果。既要回填给模型(content),也要回传给前端(展示"做了什么")。
|
||||
*/
|
||||
@Data
|
||||
public class ToolResult {
|
||||
|
||||
private String toolName;
|
||||
private String title;
|
||||
private boolean success = true;
|
||||
private String error;
|
||||
|
||||
/** 回填给模型的内容(已截断、已结构化) */
|
||||
private String content;
|
||||
|
||||
/** 回传给前端的原始 data(供渲染卡片 / 跳转) */
|
||||
private Object data;
|
||||
|
||||
private String landingPath;
|
||||
private long costMs;
|
||||
|
||||
public static ToolResult ok(ToolDef def, String content, Object data, long costMs) {
|
||||
ToolResult r = new ToolResult();
|
||||
r.toolName = def.getName();
|
||||
r.title = def.getTitle();
|
||||
r.content = content;
|
||||
r.data = data;
|
||||
r.landingPath = def.getLandingPath();
|
||||
r.costMs = costMs;
|
||||
return r;
|
||||
}
|
||||
|
||||
public static ToolResult fail(ToolDef def, String error, long costMs) {
|
||||
ToolResult r = new ToolResult();
|
||||
r.toolName = def.getName();
|
||||
r.title = def.getTitle();
|
||||
r.success = false;
|
||||
r.error = error;
|
||||
// ★ 失败也要带落地页:下游 302(需浏览器登录态)、越权拦截、缺参等情况,
|
||||
// 前端据此给出「去系统里查看」,让用户用自己的登录态把事办完。
|
||||
r.landingPath = def.getLandingPath();
|
||||
// 回填给模型的内容里必须明确写"失败+原因",否则模型会编造成功
|
||||
r.content = "{\"success\":false,\"error\":\"" + escape(error) + "\"}";
|
||||
r.costMs = costMs;
|
||||
return r;
|
||||
}
|
||||
|
||||
private static String escape(String s) {
|
||||
return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.yunhai.agent.tool.defs;
|
||||
|
||||
import com.yunhai.agent.tool.ToolDef;
|
||||
import com.yunhai.agent.tool.ToolParam;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 家校活动模块工具 —— 后端 FamilyActivity,前缀 /api/activity、/api/base。
|
||||
*
|
||||
* <p>核心闭环:活动广场 → 家长报名 → 学校确认。
|
||||
*/
|
||||
public final class ActivityTools {
|
||||
|
||||
public static final String MODULE = "familyactivity";
|
||||
|
||||
private ActivityTools() {
|
||||
}
|
||||
|
||||
public static List<ToolDef> defs() {
|
||||
List<ToolDef> list = new ArrayList<>();
|
||||
|
||||
list.add(ToolDef.of("activity.plaza", MODULE, "活动广场",
|
||||
"当前家长/学生视角的活动广场(含我的报名状态与分桶计数)。用户问“最近有什么活动”“我能报什么”时用它。",
|
||||
"GET", "/api/activity/MyActivities")
|
||||
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
|
||||
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
|
||||
ToolParam.optional("keyword", "string", "活动名称关键词"))
|
||||
.readAt("/par/activities"));
|
||||
|
||||
list.add(ToolDef.of("activity.list", MODULE, "已发布活动列表",
|
||||
"已发布活动列表(家长端收件箱口径)。",
|
||||
"GET", "/api/activity/List")
|
||||
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
|
||||
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
|
||||
ToolParam.optional("keyword", "string", "关键词"))
|
||||
.readAt("/list"));
|
||||
|
||||
list.add(ToolDef.of("activity.detail", MODULE, "活动详情",
|
||||
"查看活动详情(时间、地点、说明、附件)。",
|
||||
"GET", "/api/activity/Detail")
|
||||
.p(ToolParam.required("serid", "integer", "活动 id"),
|
||||
ToolParam.optional("type", "integer", "1 查看并标读(默认) / 0 仅查看").dft("1"))
|
||||
.readAt("/par/activities"));
|
||||
|
||||
list.add(ToolDef.of("activity.mySignup", MODULE, "我的报名详情",
|
||||
"查看我对某个活动的报名信息与状态。",
|
||||
"GET", "/api/activity/MySignup")
|
||||
.p(ToolParam.required("serid", "integer", "活动 id"))
|
||||
.readAt("/par/mine"));
|
||||
|
||||
list.add(ToolDef.of("activity.signup", MODULE, "报名活动",
|
||||
"家长为学生报名参加活动。调用前必须确认活动 serid;参加人数 joinCount 与联系人信息可选但建议询问。",
|
||||
"POST_JSON", "/api/activity/Signup")
|
||||
.p(ToolParam.required("serid", "integer", "活动 id")
|
||||
.label("活动"),
|
||||
ToolParam.optional("joinCount", "integer", "参加人数(含学生本人)")
|
||||
.label("参加人数").dft("1"),
|
||||
ToolParam.optional("guardianName", "string", "监护人姓名")
|
||||
.label("监护人姓名"),
|
||||
ToolParam.optional("guardianPhone", "string", "监护人电话")
|
||||
.label("监护人电话").eg("13800000000"),
|
||||
ToolParam.optional("remark", "string", "备注")
|
||||
.label("备注").as("textarea"))
|
||||
.write("/par/mine"));
|
||||
|
||||
list.add(ToolDef.of("activity.cancelSignup", MODULE, "取消报名",
|
||||
"取消我对某个活动的报名。",
|
||||
"POST_FORM", "/api/activity/CancelSignup")
|
||||
.p(ToolParam.required("serid", "integer", "活动 id")
|
||||
.label("活动"))
|
||||
.write("/par/mine"));
|
||||
|
||||
list.add(ToolDef.of("activity.classTree", MODULE, "年级班级树",
|
||||
"年级 → 班级 树(发布活动时选接收班级用)。",
|
||||
"GET", "/api/base/GetClassTree")
|
||||
.readAt("/list/new"));
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.yunhai.agent.tool.defs;
|
||||
|
||||
import com.yunhai.agent.tool.ToolDef;
|
||||
import com.yunhai.agent.tool.ToolParam;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 校园公告(校园通知)模块工具 —— 后端 CampusAnnouncement / NoticeFront,前缀 /api/notice。
|
||||
*/
|
||||
public final class CampusNoticeTools {
|
||||
|
||||
public static final String MODULE = "campusnotice";
|
||||
|
||||
private CampusNoticeTools() {
|
||||
}
|
||||
|
||||
public static List<ToolDef> defs() {
|
||||
List<ToolDef> list = new ArrayList<>();
|
||||
|
||||
list.add(ToolDef.of("notice.received", MODULE, "我收到的公告",
|
||||
"分页查询当前用户收到的校园公告。用户问“最近有什么通知”“有没有未读公告”时用它。",
|
||||
"GET", "/api/notice/List")
|
||||
.p(ToolParam.optional("pageIndex", "integer", "页码,从 1 开始").dft("1"),
|
||||
ToolParam.optional("pageSize", "integer", "每页条数,默认 10").dft("10"),
|
||||
ToolParam.optional("keyword", "string", "标题关键词"),
|
||||
ToolParam.optional("type", "integer", "重要程度:1 普通 / 2 重要"),
|
||||
ToolParam.optional("readType", "integer", "阅读状态:0 全部 / 1 未读 / 2 已读"))
|
||||
.readAt("/")
|
||||
.modId(null));
|
||||
|
||||
list.add(ToolDef.of("notice.readTabCount", MODULE, "公告未读统计",
|
||||
"查询当前用户公告的已收总数 / 未读数 / 已读数(回答“我还有几条没看”)。",
|
||||
"GET", "/api/notice/ReadTabCount")
|
||||
.readAt("/"));
|
||||
|
||||
list.add(ToolDef.of("notice.sent", MODULE, "我发出的公告",
|
||||
"分页查询我发布(或有权限查看)的公告,可按状态筛选(草稿/已发布/待审批/已驳回)。",
|
||||
"GET", "/api/notice/SendList")
|
||||
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
|
||||
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
|
||||
ToolParam.optional("scope", "string", "传 user 只查自己发布的"),
|
||||
ToolParam.optional("status", "integer", "0 草稿 / 1 已发布 / 2 待审批 / 3 已驳回"),
|
||||
ToolParam.optional("keyword", "string", "标题关键词"))
|
||||
.readAt("/list")
|
||||
.modId(null));
|
||||
|
||||
list.add(ToolDef.of("notice.detail", MODULE, "公告详情",
|
||||
"查看一条公告的正文、附件与已读情况。",
|
||||
"GET", "/api/notice/Detail")
|
||||
.p(ToolParam.required("serid", "integer", "公告 id"),
|
||||
ToolParam.optional("type", "integer", "1 查看并标记已读(默认) / 0 仅查看不标读").dft("1"))
|
||||
.readAt("/"));
|
||||
|
||||
list.add(ToolDef.of("notice.deptDics", MODULE, "发文部门字典",
|
||||
"查询可选的发文部门(发布公告时填 deptId 用)。",
|
||||
"GET", "/api/dict/List")
|
||||
.fix("type", "1")
|
||||
.readAt("/list/new"));
|
||||
|
||||
list.add(ToolDef.of("notice.publish", MODULE, "发布校园公告",
|
||||
"发布一条校园公告。调用前必须确认:标题 title、正文 content、发文部门 deptId;"
|
||||
+ "缺失就问用户,不要编造部门。",
|
||||
"POST_JSON", "/api/notice/Create")
|
||||
.p(ToolParam.required("title", "string", "公告标题")
|
||||
.label("公告标题").eg("关于国庆假期安排的通知"),
|
||||
ToolParam.required("content", "string", "公告正文(纯文本)")
|
||||
.label("公告正文").as("textarea").eg("各位老师、同学:经学校研究决定……"),
|
||||
ToolParam.required("deptId", "string", "发文部门 id,来自 notice.deptDics")
|
||||
.label("发文部门").optionsFrom("notice.deptDics"),
|
||||
ToolParam.optional("deptName", "string", "发文部门名称")
|
||||
.label("发文部门名称"),
|
||||
ToolParam.optional("type", "integer", "重要程度")
|
||||
.label("重要程度").dft("1")
|
||||
.options(new String[]{"1", "2"}, new String[]{"普通", "重要"}),
|
||||
ToolParam.optional("receiptFlag", "integer", "是否需要阅读回执")
|
||||
.label("需要阅读回执").dft("0")
|
||||
.options(new String[]{"0", "1"}, new String[]{"否", "是"}),
|
||||
ToolParam.optional("smsFlag", "integer", "是否同时发短信")
|
||||
.label("同时发短信").dft("0")
|
||||
.options(new String[]{"0", "1"}, new String[]{"否", "是"}),
|
||||
ToolParam.optional("publishType", "string", "发布方式")
|
||||
.label("发布方式").dft("direct")
|
||||
.options(new String[]{"direct", "draft", "approve"},
|
||||
new String[]{"直接发布", "存草稿", "提交审批"}))
|
||||
.write("/list/new")
|
||||
.modId(null));
|
||||
|
||||
list.add(ToolDef.of("notice.approvalList", MODULE, "待我审批的公告",
|
||||
"查询待我审批的公告列表(公告审批中心)。",
|
||||
"GET", "/api/notice/ApprovalList")
|
||||
.readAt("/approval")
|
||||
.p(ToolParam.optional("status", "string", "pending 待审批(默认) / approved 已通过 / rejected 已驳回 / all 全部").dft("pending"),
|
||||
ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
|
||||
ToolParam.optional("pageSize", "integer", "每页条数").dft("10")));
|
||||
|
||||
list.add(ToolDef.of("notice.approve", MODULE, "通过公告审批",
|
||||
"审批通过一条公告(末步通过即真实发布)。",
|
||||
"POST_FORM", "/api/notice/Approve")
|
||||
.p(ToolParam.required("serid", "integer", "公告 id")
|
||||
.label("公告"),
|
||||
ToolParam.optional("opinion", "string", "审批意见")
|
||||
.label("审批意见").as("textarea").eg("内容符合要求,同意发布"))
|
||||
.write("/approval")
|
||||
.modId(null));
|
||||
|
||||
list.add(ToolDef.of("notice.reject", MODULE, "驳回公告审批",
|
||||
"驳回一条公告审批(意见必填,会退回发布人)。",
|
||||
"POST_FORM", "/api/notice/Reject")
|
||||
.p(ToolParam.required("serid", "integer", "公告 id")
|
||||
.label("公告"),
|
||||
ToolParam.required("remark", "string", "驳回意见")
|
||||
.label("驳回意见").as("textarea").eg("标题表述不清,请修改后重新提交"))
|
||||
.write("/approval")
|
||||
.modId(null));
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.yunhai.agent.tool.defs;
|
||||
|
||||
import com.yunhai.agent.tool.ToolDef;
|
||||
import com.yunhai.agent.tool.ToolParam;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 宿舍通知模块工具 —— 后端 DormNotice,前缀 /api/notice、/api/dorm、/api/base。
|
||||
*
|
||||
* <p>家校闭环:宿管发通知 → 家长查看 → 家长回执/填报。
|
||||
*/
|
||||
public final class DormNoticeTools {
|
||||
|
||||
public static final String MODULE = "dormnotice";
|
||||
|
||||
private DormNoticeTools() {
|
||||
}
|
||||
|
||||
public static List<ToolDef> defs() {
|
||||
List<ToolDef> list = new ArrayList<>();
|
||||
|
||||
list.add(ToolDef.of("dorm.myNotices", MODULE, "我的宿舍通知箱",
|
||||
"当前家长/学生视角的宿舍通知箱(含我的回执状态与各分桶计数)。用户问“宿舍有什么通知”“我要回执”时用它。",
|
||||
"GET", "/api/notice/MyNotices")
|
||||
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
|
||||
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
|
||||
ToolParam.optional("keyword", "string", "关键词"))
|
||||
.readAt("/par/notices"));
|
||||
|
||||
list.add(ToolDef.of("dorm.myNotice", MODULE, "宿舍通知详情(含我的回执)",
|
||||
"查看某条宿舍通知的详情,以及我当前的填报表单与回执状态。",
|
||||
"GET", "/api/notice/MyNotice")
|
||||
.p(ToolParam.required("serid", "integer", "通知 id"))
|
||||
.readAt("/par/notices"));
|
||||
|
||||
list.add(ToolDef.of("dorm.sendList", MODULE, "宿舍通知发布列表",
|
||||
"查询已发布的宿舍通知(家长端收件箱 / 管理端全部)。",
|
||||
"GET", "/api/notice/List")
|
||||
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
|
||||
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
|
||||
ToolParam.optional("keyword", "string", "标题关键词"))
|
||||
.readAt("/list"));
|
||||
|
||||
list.add(ToolDef.of("dorm.submitReceipt", MODULE, "提交宿舍通知回执",
|
||||
"家长提交某条宿舍通知的回执 / 填报。调用前须确认 serid;如该通知需填报,还需填 fillData 内容。",
|
||||
"POST_JSON", "/api/notice/SubmitReceipt")
|
||||
.p(ToolParam.required("serid", "integer", "通知 id")
|
||||
.label("宿舍通知"),
|
||||
ToolParam.optional("guardianName", "string", "监护人姓名")
|
||||
.label("监护人姓名"),
|
||||
ToolParam.optional("guardianPhone", "string", "监护人电话")
|
||||
.label("监护人电话").eg("13800000000"),
|
||||
ToolParam.optional("relation", "string", "与学生关系")
|
||||
.label("与学生关系").eg("父亲"),
|
||||
ToolParam.optional("remark", "string", "备注")
|
||||
.label("备注").as("textarea"),
|
||||
ToolParam.optional("fillData", "object", "填报内容,键值对(需填报类通知必传)")
|
||||
.label("填报内容"))
|
||||
.write("/par/mine"));
|
||||
|
||||
list.add(ToolDef.of("dorm.cancelReceipt", MODULE, "取消宿舍通知回执",
|
||||
"撤回我已提交的宿舍通知回执。",
|
||||
"POST_FORM", "/api/notice/CancelReceipt")
|
||||
.p(ToolParam.required("serid", "integer", "通知 id")
|
||||
.label("宿舍通知"))
|
||||
.write("/par/mine"));
|
||||
|
||||
list.add(ToolDef.of("dorm.buildings", MODULE, "宿舍楼栋列表",
|
||||
"查询宿舍楼栋列表(含房间数/床位数/入住数)。",
|
||||
"GET", "/api/dorm/BuildingList")
|
||||
.readAt("/setting"));
|
||||
|
||||
list.add(ToolDef.of("dorm.rooms", MODULE, "宿舍房间列表",
|
||||
"查询某楼栋下的房间,或按关键词搜索房间。",
|
||||
"GET", "/api/dorm/RoomList")
|
||||
.p(ToolParam.optional("buildingId", "integer", "楼栋 id"),
|
||||
ToolParam.optional("keyword", "string", "房间号关键词"))
|
||||
.readAt("/setting"));
|
||||
|
||||
list.add(ToolDef.of("dorm.roomStudents", MODULE, "房间住宿生名单",
|
||||
"查询某个宿舍房间内的住宿生名单(含宿舍长标识)。",
|
||||
"GET", "/api/dorm/RoomStudents")
|
||||
.p(ToolParam.required("roomId", "integer", "房间 id"))
|
||||
.readAt("/setting"));
|
||||
|
||||
list.add(ToolDef.of("dorm.dormTree", MODULE, "宿舍楼栋房间树",
|
||||
"楼栋 → 房间 的完整树(发布宿舍通知时选接收房间用)。",
|
||||
"GET", "/api/base/GetDormTree")
|
||||
.readAt("/list/new"));
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.yunhai.agent.tool.defs;
|
||||
|
||||
import com.yunhai.agent.tool.ToolDef;
|
||||
import com.yunhai.agent.tool.ToolParam;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 课程作业模块工具(学生端 V2)—— 后端 CourseHomework,前缀 /api/student/homeworks。
|
||||
*
|
||||
* <p>学生端接口路径带资源 id({@code /{id}/...}),执行器支持 {@code {name}} 路径占位。
|
||||
*/
|
||||
public final class HomeworkTools {
|
||||
|
||||
public static final String MODULE = "coursehomework";
|
||||
|
||||
private HomeworkTools() {
|
||||
}
|
||||
|
||||
public static List<ToolDef> defs() {
|
||||
List<ToolDef> list = new ArrayList<>();
|
||||
|
||||
list.add(ToolDef.of("homework.myList", MODULE, "我的作业列表",
|
||||
"查询我的作业:待做 / 已完成 / 已截止 / 全部。用户问“今天有什么作业”“还有几项没交”时用它。",
|
||||
"GET", "/api/student/homeworks")
|
||||
.p(ToolParam.optional("tab", "string", "all 全部(默认) / todo 待做 / done 已完成 / closed 已截止").dft("all"),
|
||||
ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
|
||||
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
|
||||
ToolParam.optional("keyword", "string", "作业标题关键词"))
|
||||
.readAt("/stu/today"));
|
||||
|
||||
list.add(ToolDef.of("homework.detail", MODULE, "作业详情",
|
||||
"查看一项作业的说明、附件要求与我当前的提交内容。",
|
||||
"GET", "/api/student/homeworks/{id}")
|
||||
.p(ToolParam.required("id", "integer", "作业 id"))
|
||||
.readAt("/stu/today"));
|
||||
|
||||
list.add(ToolDef.of("homework.result", MODULE, "作业批阅结果",
|
||||
"查看我的作业批阅结果与教师评语。",
|
||||
"GET", "/api/student/homeworks/{id}/result")
|
||||
.p(ToolParam.required("id", "integer", "作业 id"))
|
||||
.readAt("/stu/redo"));
|
||||
|
||||
list.add(ToolDef.of("homework.submit", MODULE, "提交作业",
|
||||
"提交一项作业的作答文本。调用前必须确认作业 id 与作答内容;"
|
||||
+ "若该作业要求上传图片/附件,告知用户需到作业页面上传,智能体只处理文本作答。",
|
||||
"POST_JSON", "/api/student/homeworks/{id}/submissions")
|
||||
.p(ToolParam.required("id", "integer", "作业 id")
|
||||
.label("作业"),
|
||||
ToolParam.optional("text", "string", "作答文本内容")
|
||||
.label("作答内容").as("textarea").eg("我的解题思路是……"))
|
||||
.write("/stu/today"));
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.yunhai.agent.tool.defs;
|
||||
|
||||
import com.yunhai.agent.tool.ToolDef;
|
||||
import com.yunhai.agent.tool.ToolParam;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 学生请假模块工具(后端 HomeSchLeave,前缀 /api/leave、/api/dict、/api/leavestats)。
|
||||
*
|
||||
* <p>平台菜单 modId(HomeLeave routes.ts 契约):2 请假申请 / 3 我的请假 / 4 请假审批 / 5 请假登记 / 6 请假统计 / 7 应用设置
|
||||
*/
|
||||
public final class LeaveTools {
|
||||
|
||||
public static final String MODULE = "homeleave";
|
||||
|
||||
/** 日期时间格式:后端统一 yyyy-MM-dd HH:mm:ss */
|
||||
private static final String DT_HINT = "格式 yyyy-MM-dd HH:mm:ss(如 2026-10-12 08:00:00)";
|
||||
|
||||
/**
|
||||
* 请假类型内置兜底(与 HomeLeave 前端 {@code LEAVE_TYPE_OPTIONS}、后端字典种子一致)。
|
||||
* <p>权威来源是字典接口 {@code GET /api/dict/LeaveTypeOptions}(学校可在「应用设置」里自定义),
|
||||
* 但该接口需登录会话、Agent 侧常取不到;没有兜底时表单会退化成让用户填「类型编号」的数字框。
|
||||
*/
|
||||
private static final String[] LEAVE_TYPE_VALUES = {"1", "2"};
|
||||
|
||||
private static final String[] LEAVE_TYPE_LABELS = {"病假", "事假及其他"};
|
||||
|
||||
private LeaveTools() {
|
||||
}
|
||||
|
||||
public static List<ToolDef> defs() {
|
||||
List<ToolDef> list = new ArrayList<>();
|
||||
|
||||
// ---------- 只读:辅助信息 ----------
|
||||
list.add(ToolDef.of("leave.types", MODULE, "请假类型",
|
||||
"查询本校本模块可选的请假类型(病假/事假等)及其编号。用户说“请假”但没给类型时,先调它拿到 leaveType 编号。",
|
||||
"GET", "/api/dict/LeaveTypeOptions")
|
||||
.readAt("/apply")
|
||||
.modId(2));
|
||||
|
||||
list.add(ToolDef.of("leave.myClasses", MODULE, "我的班级",
|
||||
"查询当前用户关联的班级(学生=所在班级;教师=任教班级;校管理员=全校班级)。登记请假、筛选前先调它拿 classId。",
|
||||
"GET", "/api/leave/MyClasses")
|
||||
.readAt("/apply")
|
||||
.modId(2));
|
||||
|
||||
list.add(ToolDef.of("leave.classStudents", MODULE, "班级学生名单",
|
||||
"查询某班级的学生名单(教师给学生登记请假时选人用)。",
|
||||
"GET", "/api/leave/ClassStudents")
|
||||
.p(ToolParam.required("classId", "integer", "班级 id,来自 leave.myClasses"))
|
||||
.readAt("/register")
|
||||
.modId(5));
|
||||
|
||||
// ---------- 只读:查询 ----------
|
||||
list.add(ToolDef.of("leave.myLeaves", MODULE, "查询请假记录",
|
||||
"分页查询请假记录。学生查本人、教师查任教班级、管理员查全校(数据范围由后端按身份收敛)。"
|
||||
+ "用户问“我请了几天假”“上次请假通过没”时用它。",
|
||||
"GET", "/api/leave/List")
|
||||
.p(ToolParam.optional("pageIndex", "integer", "页码,从 1 开始").dft("1"),
|
||||
ToolParam.optional("pageSize", "integer", "每页条数,默认 10").dft("10"),
|
||||
ToolParam.optional("status", "integer", "按状态筛选:0待审核 1已通过 2待撤销 3已撤销 4撤销拒绝 5待销假 6已销假 7销假拒绝 8已拒绝"),
|
||||
ToolParam.optional("leaveType", "integer", "按请假类型筛选:1 病假、2 事假及其他")
|
||||
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS),
|
||||
ToolParam.optional("startDate", "string", "开始日期(含),yyyy-MM-dd"),
|
||||
ToolParam.optional("endDate", "string", "结束日期(含),yyyy-MM-dd"),
|
||||
ToolParam.optional("keyword", "string", "关键词(学生姓名/事由)"))
|
||||
.modId(3));
|
||||
|
||||
list.add(ToolDef.of("leave.detail", MODULE, "请假详情",
|
||||
"查询一条请假的完整详情(时间段、销假时间、附件、可执行按钮)。",
|
||||
"GET", "/api/leave/Details")
|
||||
.p(ToolParam.required("id", "integer", "请假记录 id,来自 leave.myLeaves"))
|
||||
.modId(3));
|
||||
|
||||
list.add(ToolDef.of("leave.stats", MODULE, "请假统计",
|
||||
"按日期/班级/类型统计请假情况。",
|
||||
"GET", "/api/leavestats/Stats")
|
||||
.p(ToolParam.optional("startDate", "string", "开始日期 yyyy-MM-dd"),
|
||||
ToolParam.optional("endDate", "string", "结束日期 yyyy-MM-dd"),
|
||||
ToolParam.optional("classId", "integer", "班级 id"),
|
||||
ToolParam.optional("leaveType", "integer", "请假类型:1 病假、2 事假及其他")
|
||||
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS))
|
||||
.readAt("/stats")
|
||||
.modId(6));
|
||||
|
||||
// ---------- 写:提交 / 审批 / 销假 ----------
|
||||
list.add(ToolDef.of("leave.submit", MODULE, "提交请假申请",
|
||||
"学生提交一条请假申请(提交后状态为待审核,等班主任审批)。"
|
||||
+ "尽量确认:请假类型 leaveType、开始时间 startDate、结束时间 endDate、事由 remarks;"
|
||||
+ "不确定的字段直接省略,系统会弹表单让用户补齐,不要用文字反复追问。",
|
||||
"POST_JSON", "/api/leave/Save")
|
||||
.p(ToolParam.required("leaveType", "integer", "请假类型:1 病假、2 事假及其他")
|
||||
.label("请假类型")
|
||||
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS)
|
||||
.optionsFrom("leave.types"),
|
||||
ToolParam.required("startDate", "string", "请假开始时间," + DT_HINT)
|
||||
.label("开始时间").eg("2026-10-12 08:00:00"),
|
||||
ToolParam.required("endDate", "string", "请假结束时间," + DT_HINT)
|
||||
.label("结束时间").eg("2026-10-12 17:00:00"),
|
||||
ToolParam.required("remarks", "string", "请假事由")
|
||||
.label("请假事由").as("textarea").eg("发烧就医,需在家休息"),
|
||||
ToolParam.optional("classId", "integer", "班级 id;用户只有唯一班级时可不传")
|
||||
.label("班级"),
|
||||
ToolParam.optional("stuId", "integer", "学生 id(教师代学生登记时用)")
|
||||
.label("学生"))
|
||||
.fix("type", "2")
|
||||
.fix("continuousLeave", "0")
|
||||
.write("/my")
|
||||
.modId(2));
|
||||
|
||||
list.add(ToolDef.of("leave.withdraw", MODULE, "撤销请假",
|
||||
"撤销一条请假申请。待审核可直接撤销;已通过则转为待撤销(需教师审批)。",
|
||||
"POST_FORM", "/api/leave/Withdraw")
|
||||
.p(ToolParam.required("id", "integer", "请假记录 id")
|
||||
.label("请假记录"))
|
||||
.write("/my")
|
||||
.modId(3));
|
||||
|
||||
list.add(ToolDef.of("leave.reportBack", MODULE, "申请销假",
|
||||
"学生请完假返校后申请销假(状态转待销假,教师确认后回写实际时间)。",
|
||||
"POST_JSON", "/api/leave/ReportBack")
|
||||
.p(ToolParam.required("id", "integer", "请假记录 id")
|
||||
.label("请假记录"),
|
||||
ToolParam.optional("startDate", "string", "实际返校时间," + DT_HINT)
|
||||
.label("实际返校时间").eg("2026-10-12 08:00:00"),
|
||||
ToolParam.optional("endDate", "string", "实际结束时间," + DT_HINT)
|
||||
.label("实际结束时间").eg("2026-10-12 17:00:00"))
|
||||
.fix("continuousLeave", "0")
|
||||
.write("/my")
|
||||
.modId(3));
|
||||
|
||||
list.add(ToolDef.of("leave.audit", MODULE, "审批请假",
|
||||
"教师/管理员审批一条请假。",
|
||||
"POST_JSON", "/api/leave/Audit")
|
||||
.p(ToolParam.required("id", "integer", "请假记录 id")
|
||||
.label("请假记录"),
|
||||
ToolParam.required("status", "integer", "审批结论")
|
||||
.label("审批结论")
|
||||
.options(new String[]{"1", "8", "3", "4", "6", "7"},
|
||||
new String[]{"通过", "拒绝", "撤销通过", "撤销拒绝", "销假通过", "销假拒绝"}))
|
||||
.write("/audit")
|
||||
.modId(4));
|
||||
|
||||
list.add(ToolDef.of("leave.batchPass", MODULE, "批量通过请假",
|
||||
"一键通过某班级全部待审核请假,或按 id 列表批量通过。",
|
||||
"POST_JSON", "/api/leave/BatchPass")
|
||||
.p(ToolParam.optional("classId", "integer", "班级 id:通过该班全部待审核")
|
||||
.label("班级"),
|
||||
ToolParam.optional("ids", "array", "请假 id 数组:仅通过勾选的这些")
|
||||
.label("请假记录").eg("101,102"))
|
||||
.write("/audit")
|
||||
.modId(4));
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 供文档/目录使用 */
|
||||
public static List<String> moduleAlias() {
|
||||
return Arrays.asList("请假", "学生请假", "homeleave");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.yunhai.agent.tool.defs;
|
||||
|
||||
import com.yunhai.agent.tool.ToolDef;
|
||||
import com.yunhai.agent.tool.ToolParam;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 教师专业发展模块工具 —— 后端 Profession,前缀 /api/profession、/api/moudule、/api/points、/api/stats。
|
||||
*/
|
||||
public final class ProfessionTools {
|
||||
|
||||
public static final String MODULE = "profession";
|
||||
|
||||
private ProfessionTools() {
|
||||
}
|
||||
|
||||
public static List<ToolDef> defs() {
|
||||
List<ToolDef> list = new ArrayList<>();
|
||||
|
||||
list.add(ToolDef.of("profession.homeRecords", MODULE, "栏目最新记录",
|
||||
"各栏目最新发布的记录块(专业发展首页内容)。",
|
||||
"GET", "/api/profession/HomeModuleRecords")
|
||||
.p(ToolParam.optional("size", "integer", "每栏取几条").dft("6"))
|
||||
.readAt("/home"));
|
||||
|
||||
list.add(ToolDef.of("profession.list", MODULE, "专业发展记录列表",
|
||||
"后台口径的专业发展记录分页查询,可按栏目/教师/时间筛选。",
|
||||
"GET", "/api/profession/ProfessionList")
|
||||
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
|
||||
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
|
||||
ToolParam.optional("keyword", "string", "标题关键词"),
|
||||
ToolParam.optional("moduleId", "integer", "栏目 id"),
|
||||
ToolParam.optional("starttime", "string", "开始时间 yyyy-MM-dd"),
|
||||
ToolParam.optional("endtime", "string", "结束时间 yyyy-MM-dd"))
|
||||
.readAt("/module/list"));
|
||||
|
||||
list.add(ToolDef.of("profession.moduleRecords", MODULE, "栏目文章列表",
|
||||
"某个栏目下的文章分页(附同校其他栏目)。",
|
||||
"GET", "/api/profession/ModuleRecordList")
|
||||
.p(ToolParam.required("moduleId", "integer", "栏目 id"),
|
||||
ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
|
||||
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"))
|
||||
.readAt("/module/list"));
|
||||
|
||||
list.add(ToolDef.of("profession.myRecords", MODULE, "我的栏目文章",
|
||||
"查询我在某栏目下发表的文章。",
|
||||
"GET", "/api/profession/MyModuleRecordList")
|
||||
.p(ToolParam.required("moduleId", "integer", "栏目 id"),
|
||||
ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
|
||||
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"))
|
||||
.readAt("/timeline"));
|
||||
|
||||
list.add(ToolDef.of("profession.detail", MODULE, "记录详情",
|
||||
"查看一条专业发展记录的详情。",
|
||||
"GET", "/api/profession/ProfessionDetail")
|
||||
.p(ToolParam.required("SerID", "integer", "记录 id"))
|
||||
.readAt("/module/list"));
|
||||
|
||||
list.add(ToolDef.of("profession.moduleTree", MODULE, "栏目树",
|
||||
"专业发展的栏目树(含我的栏目),发布记录时选栏目用。",
|
||||
"GET", "/api/moudule/ModuleTree")
|
||||
.readAt("/record/new"));
|
||||
|
||||
list.add(ToolDef.of("profession.myPoints", MODULE, "我的积分",
|
||||
"查询我的专业发展积分与来源明细。",
|
||||
"GET", "/api/profession/MyPoints")
|
||||
.readAt("/timeline"));
|
||||
|
||||
list.add(ToolDef.of("profession.pointsRank", MODULE, "积分排行",
|
||||
"积分排行榜。",
|
||||
"GET", "/api/profession/PointsRank")
|
||||
.p(ToolParam.optional("top", "integer", "取前几名").dft("8"))
|
||||
.readAt("/home"));
|
||||
|
||||
list.add(ToolDef.of("profession.publish", MODULE, "发布专业发展记录",
|
||||
"发布一条专业发展记录(教案/学习资料/反思等)。调用前必须确认栏目 moduleId、标题与内容。",
|
||||
"POST_FORM", "/api/profession/Create")
|
||||
.p(ToolParam.required("moduleId", "integer", "栏目 id,来自 profession.moduleTree")
|
||||
.label("所属栏目").optionsFrom("profession.moduleTree"),
|
||||
ToolParam.required("title", "string", "记录标题")
|
||||
.label("记录标题").eg("2026年秋季公开课教学设计"),
|
||||
ToolParam.optional("content", "string", "记录正文")
|
||||
.label("记录正文").as("textarea"))
|
||||
.write("/record/new"));
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.yunhai.agent.tool.defs;
|
||||
|
||||
import com.yunhai.agent.tool.ToolDef;
|
||||
import com.yunhai.agent.tool.ToolParam;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 资源库模块工具 —— 后端 ResourceLibrary,前缀 /api/resource、/api/category、/api/textbook。
|
||||
*/
|
||||
public final class ResourceTools {
|
||||
|
||||
public static final String MODULE = "resourcelibrary";
|
||||
|
||||
private ResourceTools() {
|
||||
}
|
||||
|
||||
public static List<ToolDef> defs() {
|
||||
List<ToolDef> list = new ArrayList<>();
|
||||
|
||||
list.add(ToolDef.of("resource.search", MODULE, "检索资源",
|
||||
"按关键词/分类/学科检索教学资源(课件、教案、试题等)。用户问“找一份 XX 的课件”“有没有 XX 资料”时用它。",
|
||||
"GET", "/api/resource/List")
|
||||
.p(ToolParam.optional("keyword", "string", "关键词:资源名称 / 编码 / 上传人"),
|
||||
ToolParam.optional("categoryId", "integer", "分类节点 id(含子节点)"),
|
||||
ToolParam.optional("subject", "string", "学科"),
|
||||
ToolParam.optional("type", "string", "资源类型"),
|
||||
ToolParam.optional("orderBy", "string", "排序:time_desc 最新 / download_desc 下载最多 / view_desc 浏览最多"),
|
||||
ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
|
||||
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"))
|
||||
.readAt("/resource"));
|
||||
|
||||
list.add(ToolDef.of("resource.detail", MODULE, "资源详情",
|
||||
"查看一份资源的详细信息(分类、学科、格式、下载量等)。",
|
||||
"GET", "/api/resource/Detail")
|
||||
.p(ToolParam.required("id", "integer", "资源 id,来自 resource.search"))
|
||||
.readAt("/resource"));
|
||||
|
||||
list.add(ToolDef.of("resource.categoryTree", MODULE, "资源分类树",
|
||||
"资源分类树(学段 → 学科 → 年级册次 → 章节),用于按分类筛选资源。",
|
||||
"GET", "/api/category/Tree")
|
||||
.readAt("/resource"));
|
||||
|
||||
list.add(ToolDef.of("resource.collect", MODULE, "收藏资源",
|
||||
"收藏或取消收藏一份资源。",
|
||||
"POST_FORM", "/api/resource/Collect")
|
||||
.p(ToolParam.required("id", "integer", "资源 id")
|
||||
.label("资源"))
|
||||
.write("/resource/collect"));
|
||||
|
||||
list.add(ToolDef.of("resource.textbookOptions", MODULE, "教材选项",
|
||||
"查询可选的教材版本 / 学科 / 出版社。",
|
||||
"GET", "/api/textbook/Options")
|
||||
.readAt("/textbook"));
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user