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,35 @@
|
||||
package com.yunhai.agent;
|
||||
|
||||
import com.alibaba.fastjson.parser.ParserConfig;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
|
||||
/**
|
||||
* 校园智能助手启动类(与 NoticeService / UserBase 同族)。
|
||||
*
|
||||
* <p>Spring Boot 2.7.18 + {@code yh-spring-boot-starter}:统一响应 {@code {code,msg,data}}
|
||||
* 由平台 {@code ApiResponseAdvice} 包装;单点登录(CAS/SSO)与当前用户上下文
|
||||
* ({@code BoaUser} 方法参数、{@code UserContextHolder})由平台 starter 自动装配。
|
||||
*
|
||||
* <p>本服务承载两类职责:
|
||||
* <ol>
|
||||
* <li>把用户自然语言交给大模型(DeepSeek),由模型决定调用哪些工具;</li>
|
||||
* <li>把模型给出的工具调用,真正转发到对应业务模块的 REST 接口(带用户身份),
|
||||
* 并按「学生 / 教师」角色在服务端做能力过滤与越权拦截。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = "com.yunhai.agent")
|
||||
@EnableTransactionManagement
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
public class AgentApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
// 开启 fastjson 安全模式
|
||||
ParserConfig.getGlobalInstance().setSafeMode(true);
|
||||
SpringApplication.run(AgentApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yunhai.agent.auth;
|
||||
|
||||
/**
|
||||
* 智能体侧的角色分类(由平台 {@code BoaUser} 解析而来)。
|
||||
*
|
||||
* <p>用途:决定「这个身份能用哪些工具」——学生侧(请假申请 / 交作业 / 报名活动 / 看公告)
|
||||
* 与教职工侧(发公告 / 审批请假 / 发布资源 / 专业发展)严格分开。
|
||||
*/
|
||||
public enum AgentRole {
|
||||
|
||||
/** 学生 */
|
||||
STUDENT("学生"),
|
||||
|
||||
/** 教师(含班主任) */
|
||||
TEACHER("教师"),
|
||||
|
||||
/** 管理员(校管 / 分管领导 / 教务 / 校长) */
|
||||
ADMIN("管理员"),
|
||||
|
||||
/** 访客:未登录或无法识别角色,只能使用公共只读能力 */
|
||||
GUEST("访客");
|
||||
|
||||
private final String label;
|
||||
|
||||
AgentRole(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.yunhai.agent.auth;
|
||||
|
||||
import com.yunhai.agent.tool.ToolDef;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 角色能力策略:集中声明「哪些工具面向学生、哪些面向教职工」。
|
||||
*
|
||||
* <p>为什么集中在一处:角色划分本质是<b>一所学校的治理策略</b>(会随学校调整),
|
||||
* 集中成一张可读的表比散落在 7 个模块的工具定义里更容易审阅与改动;
|
||||
* 若某个工具需要就近声明,用 {@code ToolDef.allow(...)} 显式指定即可(本策略会尊重已有声明)。
|
||||
*
|
||||
* <p>三类名单:
|
||||
* <ul>
|
||||
* <li>{@link #STAFF_ONLY} —— 教职工侧(教师 + 管理员):审批、发布、统计、宿管、专业发展;</li>
|
||||
* <li>{@link #STUDENT_ONLY} —— 学生侧:交作业、报名活动、销假;</li>
|
||||
* <li>{@link #SHARED} —— 学生与教职工都可能发起的:请假申请/撤销。</li>
|
||||
* </ul>
|
||||
* 未列入任何名单的工具 = 所有已识别角色都可用(如各类只读查询)。
|
||||
*/
|
||||
public final class RolePolicy {
|
||||
|
||||
private RolePolicy() {
|
||||
}
|
||||
|
||||
/** 教职工专属(教师 + 管理员) */
|
||||
private static final Set<String> STAFF_ONLY = new HashSet<>(Arrays.asList(
|
||||
// 请假:审批与统计
|
||||
"leave.classStudents", "leave.stats", "leave.audit", "leave.batchPass",
|
||||
// 公告:发布与审批
|
||||
"notice.sent", "notice.deptDics", "notice.publish",
|
||||
"notice.approvalList", "notice.approve", "notice.reject",
|
||||
// 宿舍通知:楼栋/房间/住宿生管理侧
|
||||
"dorm.buildings", "dorm.rooms", "dorm.roomStudents", "dorm.dormTree",
|
||||
// 家校活动:发布时选班级
|
||||
"activity.classTree"
|
||||
));
|
||||
|
||||
/** 学生专属 */
|
||||
private static final Set<String> STUDENT_ONLY = new HashSet<>(Arrays.asList(
|
||||
"homework.myList", "homework.detail", "homework.result", "homework.submit",
|
||||
"activity.signup", "activity.cancelSignup",
|
||||
"leave.reportBack"
|
||||
));
|
||||
|
||||
/** 学生与教职工共用(发起/撤销自己的事项) */
|
||||
private static final Set<String> SHARED = new HashSet<>(Arrays.asList(
|
||||
"leave.submit", "leave.withdraw"
|
||||
));
|
||||
|
||||
/** 模块级规则:整个模块仅教职工可用 */
|
||||
private static final String[] STAFF_MODULE_PREFIXES = {"profession."};
|
||||
|
||||
/**
|
||||
* 为未显式声明角色的工具套用默认角色策略。
|
||||
*/
|
||||
public static void apply(Collection<ToolDef> tools) {
|
||||
if (tools == null) {
|
||||
return;
|
||||
}
|
||||
for (ToolDef d : tools) {
|
||||
if (d.getRoles() != null && !d.getRoles().isEmpty()) {
|
||||
continue; // 工具自己声明过,尊重
|
||||
}
|
||||
String n = d.getName();
|
||||
if (matchesPrefix(n, STAFF_MODULE_PREFIXES) || STAFF_ONLY.contains(n)) {
|
||||
d.allow(AgentRole.TEACHER, AgentRole.ADMIN);
|
||||
} else if (STUDENT_ONLY.contains(n)) {
|
||||
d.allow(AgentRole.STUDENT);
|
||||
} else if (SHARED.contains(n)) {
|
||||
d.allow(AgentRole.STUDENT, AgentRole.TEACHER, AgentRole.ADMIN);
|
||||
}
|
||||
// 其余 = 不限制(所有已识别角色可用)
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean matchesPrefix(String name, String[] prefixes) {
|
||||
for (String p : prefixes) {
|
||||
if (name.startsWith(p)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.yunhai.agent.auth;
|
||||
|
||||
import com.yunhai.agent.config.AgentSettings;
|
||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 角色解析器:把平台 {@code BoaUser} 映射成智能体的角色集合。
|
||||
*
|
||||
* <h3>为什么以「当前身份」为准</h3>
|
||||
* 平台里一个账号可以同时挂多个身份:{@code BoaUser.roleIds} 是<b>全部</b>身份的角色号,
|
||||
* {@code BoaUser.currentRole} 是用户在门户里<b>当前激活</b>的那个身份
|
||||
* (等价于 {@code YschBaseOpenApi.getCurrentIdentity(userId)})。
|
||||
* 例如实测账号 10008 同时是「学生 + 管理员 + 教师」——若把所有身份并集,
|
||||
* 权限只增不减、能力边界完全模糊(所有人都是全量 54 项)。
|
||||
* 因此这里<b>只认当前身份</b>:与学校门户、其它业务模块的口径保持一致。
|
||||
*
|
||||
* <h3>判定规则(角色号可在 {@code agent.roles.*} 配置)</h3>
|
||||
* <ol>
|
||||
* <li>优先取 {@code currentRole.getRoleId()},命中 {@code agent.roles.student/teacher/admin}
|
||||
* 即获得对应角色,且<b>只</b>获得这一个身份;</li>
|
||||
* <li>当前身份是管理员时,按 {@code agent.roles.admin-also-teacher=true} 追加教师能力;</li>
|
||||
* <li>当前身份号未在配置里声明(或账号没有当前身份)→ 回退按 {@code roleIds}/{@code type}
|
||||
* 的旧口径判定,并打 WARN 日志提示补配置;</li>
|
||||
* <li>什么都识别不出 → 访客(仅公共只读能力)。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class RoleResolver {
|
||||
|
||||
private final AgentSettings settings;
|
||||
|
||||
/** 每个用户上次记录过的身份描述,避免每请求刷屏(只在身份变化时打一次 INFO) */
|
||||
private final Map<Integer, String> lastLogged = new ConcurrentHashMap<>();
|
||||
|
||||
public RoleResolver(AgentSettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/** 解析用户角色集合(永不为空,至少含 GUEST) */
|
||||
public Set<AgentRole> resolve(BoaUser user) {
|
||||
Set<AgentRole> roles = new LinkedHashSet<>();
|
||||
if (user == null) {
|
||||
roles.add(AgentRole.GUEST);
|
||||
return roles;
|
||||
}
|
||||
|
||||
AgentSettings.Roles cfg = settings.getRoles();
|
||||
|
||||
// 1) ★ 平台「当前身份」优先 —— 一个账号只生效一个身份
|
||||
BoaRoleInfo current = user.getCurrentRole();
|
||||
if (current != null && current.getRoleId() != null) {
|
||||
Set<AgentRole> hit = match(current.getRoleId(), cfg);
|
||||
if (!hit.isEmpty()) {
|
||||
applyAdminAlsoTeacher(hit, cfg);
|
||||
logResolved(user, current, hit, "当前身份");
|
||||
return hit;
|
||||
}
|
||||
log.warn("[身份] 当前身份 roleId={} roleName={} 未命中 agent.roles.* 配置,"
|
||||
+ "暂按账号全部身份兜底(userId={}, roleIds={})—— 建议把该角色号补进配置",
|
||||
current.getRoleId(), current.getRoleName(), user.getId(), user.getRoleIds());
|
||||
}
|
||||
|
||||
// 2) 兜底:没有当前身份,或当前身份号未配置
|
||||
if (user.getType() != null && user.getType() == 1) {
|
||||
roles.add(AgentRole.ADMIN);
|
||||
}
|
||||
List<Integer> roleIds = user.getRoleIds();
|
||||
if (roleIds != null) {
|
||||
for (Integer rid : roleIds) {
|
||||
if (rid != null) {
|
||||
roles.addAll(match(rid, cfg));
|
||||
}
|
||||
}
|
||||
}
|
||||
applyAdminAlsoTeacher(roles, cfg);
|
||||
|
||||
if (roles.isEmpty()) {
|
||||
roles.add(AgentRole.GUEST);
|
||||
}
|
||||
logResolved(user, current, roles,
|
||||
current == null ? "无当前身份,按账号全部身份" : "当前身份未配置,按账号全部身份");
|
||||
return roles;
|
||||
}
|
||||
|
||||
/** 角色集合的可读描述,如「教师」/「管理员+教师」 */
|
||||
public String describe(Set<AgentRole> roles) {
|
||||
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();
|
||||
}
|
||||
|
||||
/** 单个角色号 → 智能体角色(一个号理论上可命中多个分组,全部保留) */
|
||||
private Set<AgentRole> match(Integer roleId, AgentSettings.Roles cfg) {
|
||||
Set<AgentRole> hit = new LinkedHashSet<>();
|
||||
if (roleId == null) {
|
||||
return hit;
|
||||
}
|
||||
if (contains(cfg.getAdmin(), roleId)) {
|
||||
hit.add(AgentRole.ADMIN);
|
||||
}
|
||||
if (contains(cfg.getTeacher(), roleId)) {
|
||||
hit.add(AgentRole.TEACHER);
|
||||
}
|
||||
if (contains(cfg.getStudent(), roleId)) {
|
||||
hit.add(AgentRole.STUDENT);
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
private void applyAdminAlsoTeacher(Set<AgentRole> roles, AgentSettings.Roles cfg) {
|
||||
if (roles.contains(AgentRole.ADMIN) && cfg.isAdminAlsoTeacher()) {
|
||||
roles.add(AgentRole.TEACHER);
|
||||
}
|
||||
}
|
||||
|
||||
/** 身份只在「变化时」记一次 INFO,避免每个请求刷屏,同时便于核对角色号配置 */
|
||||
private void logResolved(BoaUser user, BoaRoleInfo current, Set<AgentRole> roles, String source) {
|
||||
String desc = describe(roles);
|
||||
String line = source + " → " + desc
|
||||
+ (current == null ? "" : "(roleId=" + current.getRoleId()
|
||||
+ ", roleName=" + current.getRoleName() + ", schId=" + current.getSchId() + ")");
|
||||
Integer uid = user.getId();
|
||||
if (uid == null) {
|
||||
log.info("[身份] {}", line);
|
||||
return;
|
||||
}
|
||||
String prev = lastLogged.put(uid, desc);
|
||||
if (!desc.equals(prev)) {
|
||||
log.info("[身份] userId={} {}", uid, line);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean contains(List<Integer> list, int v) {
|
||||
return list != null && list.contains(v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.yunhai.agent.common.listener;
|
||||
|
||||
import com.yunhai.agent.entity.BaseEntity;
|
||||
import com.yunhai.platform.auth.LoginUser;
|
||||
import com.yunhai.platform.auth.UserContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.persistence.PrePersist;
|
||||
import javax.persistence.PreUpdate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人(平台当前登录人)与时间戳。
|
||||
* 对应 {@link BaseEntity} 的 createBy / updateBy / createTime / updateTime 字段。
|
||||
* 当前登录人取自基础平台 {@link UserContextHolder}(SSE 异步线程里可能为空,此时只填时间)。
|
||||
*/
|
||||
@Component
|
||||
public class AgentAuditListener {
|
||||
|
||||
@PrePersist
|
||||
public void prePersist(BaseEntity entity) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
entity.setCreateTime(now);
|
||||
entity.setUpdateTime(now);
|
||||
String account = currentAccount();
|
||||
if (account != null) {
|
||||
if (entity.getCreateBy() == null) {
|
||||
entity.setCreateBy(account);
|
||||
}
|
||||
entity.setUpdateBy(account);
|
||||
}
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
public void preUpdate(BaseEntity entity) {
|
||||
entity.setUpdateTime(LocalDateTime.now());
|
||||
String account = currentAccount();
|
||||
if (account != null) {
|
||||
entity.setUpdateBy(account);
|
||||
}
|
||||
}
|
||||
|
||||
private String currentAccount() {
|
||||
LoginUser user = UserContextHolder.get();
|
||||
return user == null ? null : user.getLoginName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.yunhai.agent.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 智能体配置:大模型 + 各业务模块基地址 + 角色权限映射。
|
||||
*
|
||||
* <p>全部落在 {@code application.yml} 的 {@code agent.*} 下,便于运维按环境改配置而不动代码。
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "agent")
|
||||
public class AgentSettings {
|
||||
|
||||
/** 大模型配置 */
|
||||
private Llm llm = new Llm();
|
||||
|
||||
/** 编排参数 */
|
||||
private Loop loop = new Loop();
|
||||
|
||||
/** 角色权限映射(学生 / 教师 / 管理员 的角色号) */
|
||||
private Roles roles = new Roles();
|
||||
|
||||
/**
|
||||
* 业务模块基地址表:key = 模块标识(工具定义里引用),value = 该模块的根地址(不含 /api)。
|
||||
* <p>例:{@code homeleave: http://base.yunhaixingtong.com/home-leave}
|
||||
* <p>本地联调把 value 改成 {@code http://localhost:8106} 之类即可。
|
||||
*/
|
||||
private Map<String, String> modules = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 各业务模块的「默认落地页」——即该模块前端的一个路由(如 {@code /my})。
|
||||
*
|
||||
* <p><b>★ 深链兜底(2026-10 新增)</b>:业务模块的用户态接口 {@code /<模块>/api/*} 只认
|
||||
* <b>浏览器 CAS 会话</b>,服务端调用拿不到,必然回 {@code code=302}(详见 README 第 11 节)。
|
||||
* 此时智能体不再只丢一句报错,而是把<b>模块页面地址</b>随轨迹回传前端,
|
||||
* 让用户用<b>自己的浏览器登录态</b>一键打开真实页面看数据 —— 这比"死等打通"务实得多。
|
||||
*
|
||||
* <p>取值已按各模块前端 {@code src/router/routes.ts} 的真实路由核对(2026-10-10):
|
||||
* <pre>
|
||||
* homeleave → /my 我的请假
|
||||
* campusnotice → / 我的公告(收件箱)
|
||||
* dormnotice → /par/notices 宿舍通知箱
|
||||
* familyactivity → /par/activities 活动广场
|
||||
* coursehomework → /stu/today 今日作业
|
||||
* resourcelibrary → /resource 资源管理
|
||||
* profession → /home 发展首页
|
||||
* </pre>
|
||||
* 运维可在 {@code application.yml} 用 {@code agent.module-landing.<模块>=<路由>} 覆盖单个模块;
|
||||
* 工具自身声明的 {@code landingPath}({@code write(...)} / {@code readAt(...)})优先级更高。
|
||||
*/
|
||||
private Map<String, String> moduleLanding = defaultModuleLanding();
|
||||
|
||||
/** 模块默认落地页的出厂值(见 {@link #moduleLanding}) */
|
||||
private static Map<String, String> defaultModuleLanding() {
|
||||
Map<String, String> m = new LinkedHashMap<>();
|
||||
m.put("homeleave", "/my");
|
||||
m.put("campusnotice", "/");
|
||||
m.put("dormnotice", "/par/notices");
|
||||
m.put("familyactivity", "/par/activities");
|
||||
m.put("coursehomework", "/stu/today");
|
||||
m.put("resourcelibrary", "/resource");
|
||||
m.put("profession", "/home");
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否按「用户已授权模块」过滤工具(modId 维度)。
|
||||
* <p>默认 false:不向模型隐藏工具,越权与否由各业务模块按透传身份自行裁决。
|
||||
* <p>置 true 时:用平台 GetUserMod 的 modId 与 {@code ToolDef.moduleId} 比对,未授权模块的工具不暴露。
|
||||
*/
|
||||
private boolean filterToolsByAuthorizedModule = false;
|
||||
|
||||
/**
|
||||
* 是否开启「角色强校验」。
|
||||
* <p>默认 true:模型若调用了当前角色无权使用的工具,服务端直接拒绝执行(不发真实请求),
|
||||
* 并在轨迹里留下 FORBIDDEN 记录 —— 这是「分学生、教师权限」的硬约束。
|
||||
* <p>置 false 可临时放开(排障用),此时仅按角色过滤列表,不做执行拦截。
|
||||
*/
|
||||
private boolean enforceRolePermission = true;
|
||||
|
||||
/**
|
||||
* 浏览器代执行(方案 A,2026-10-12 新增)。
|
||||
*
|
||||
* <p>业务模块的用户态接口只认<b>浏览器 CAS 会话</b>,服务端调用必然 {@code code=302}
|
||||
* (README 第 11 节的四条实测证据)。开启后,模块类工具不再由服务端发请求,而是:
|
||||
* <ol>
|
||||
* <li>模型决定调用 → 服务端挂起,把「接口描述(方法/路径/参数)」下发前端;</li>
|
||||
* <li>前端用<b>用户自己的登录态</b>调用模块接口(同站部署 ⇒ 同源,Cookie 自动携带);</li>
|
||||
* <li>结果回传服务端 → 喂回模型 → 生成最终回答。</li>
|
||||
* </ol>
|
||||
* 本地 dev(localhost:8081)因跨域取不到 base 域会话,代执行会失败并自动落回
|
||||
* 「去系统里查看」的深链兜底;同站部署后即真实生效。
|
||||
*/
|
||||
private BrowserExec browserExec = new BrowserExec();
|
||||
|
||||
@Data
|
||||
public static class BrowserExec {
|
||||
/** 是否启用浏览器代执行(false = 维持服务端直调的旧行为) */
|
||||
private boolean enabled = true;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Llm {
|
||||
/** OpenAI 兼容的 chat/completions 地址(DeepSeek 官方:https://api.deepseek.com/chat/completions) */
|
||||
private String host = "https://api.deepseek.com/chat/completions";
|
||||
|
||||
/** 多模态/推理兼用的兜底地址(可选,默认同上) */
|
||||
private String fallbackHost;
|
||||
|
||||
private String apiKey;
|
||||
|
||||
/** 默认模型:deepseek-flash(DeepSeek-V4.1-Flash,支持 function calling)。
|
||||
* 以官方实时清单为准:GET https://api.deepseek.com/models(2026-10-10:deepseek-flash / deepseek-v4-pro) */
|
||||
private String model = "deepseek-flash";
|
||||
|
||||
private int maxTokens = 2048;
|
||||
|
||||
private double temperature = 0.3;
|
||||
|
||||
/** 单次 HTTP 超时(毫秒)。⚠️ 实际出站超时已由平台统一客户端接管,见 {@code yh.http.read-timeout-ms};本字段保留仅作文档参考 */
|
||||
private int timeout = 60000;
|
||||
|
||||
/** 是否开启深度思考(旧的 deepseek-reasoner 独立模型已下线;V4 系列改为在请求里带 effort 档位,本开关暂未接线) */
|
||||
private boolean thinking = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Loop {
|
||||
/** 单轮对话内最多允许的「模型思考 → 调工具」循环次数,防死循环 */
|
||||
private int maxRounds = 6;
|
||||
|
||||
/** 每个工具调用结果塞回模型时的最大字符数(防撑爆上下文) */
|
||||
private int toolResultMaxChars = 6000;
|
||||
|
||||
/** 会话上下文最多保留的历史消息条数 */
|
||||
private int historyMaxMessages = 20;
|
||||
|
||||
/** 会话空闲过期时间(分钟) */
|
||||
private int sessionTtlMinutes = 120;
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色映射:UserBase 的角色号(roleId)→ 智能体角色。
|
||||
* <p><b>★ 上线前必须按本校 UserBase 的角色体系校准</b>:
|
||||
* 平台 {@code BoaUser.getType() == 1} 恒为超管/校管;其余按 {@code roleIds} 命中下面的集合判定。
|
||||
* 一个用户可同时命中多个(如既是教师又是管理员),能力取并集。
|
||||
*/
|
||||
@Data
|
||||
public static class Roles {
|
||||
/** 学生角色号(默认 1;请按 UserBase 校准) */
|
||||
private List<Integer> student = new ArrayList<>(Arrays.asList(1));
|
||||
|
||||
/** 教师角色号(默认 2=教师、3=班主任;请按 UserBase 校准) */
|
||||
private List<Integer> teacher = new ArrayList<>(Arrays.asList(2, 3));
|
||||
|
||||
/** 管理员角色号(默认 4=校管、5=分管领导、6=教务、7=校长;请按 UserBase 校准) */
|
||||
private List<Integer> admin = new ArrayList<>(Arrays.asList(4, 5, 6, 7));
|
||||
|
||||
/** 管理员是否天然拥有教师能力(默认 true:校管/领导可用教师侧工具) */
|
||||
private boolean adminAlsoTeacher = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.yunhai.agent.config;
|
||||
|
||||
/**
|
||||
* 全局路径常量。与 UserBase / Notice 一致:API 统一挂在 {@code /{platform}/} 之下
|
||||
* (platform = api / manage / mobiapi),前端固定走 {@code /api/...} 同源代理。
|
||||
*/
|
||||
public enum Config {
|
||||
Inst;
|
||||
|
||||
/** 平台统一前缀:manage / api / mobiapi,前面有 / 后面无 /,拼接子路径时用 / 分隔 */
|
||||
public static final String API_PATH = "/{platform}";
|
||||
|
||||
/** 智能体域 */
|
||||
public static final String AGENT = "/agent";
|
||||
|
||||
/** 平台壳层域(GetUserInfo / GetUserMod / Logout,与各业务模块同形) */
|
||||
public static final String BASE = "/base";
|
||||
|
||||
/** 应用间开放域(签名鉴权,留给后续对第三方开放用) */
|
||||
public static final String OPEN_API_PATH = "/oapi";
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.yunhai.agent.controller;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.yunhai.agent.auth.AgentRole;
|
||||
import com.yunhai.agent.auth.RoleResolver;
|
||||
import com.yunhai.agent.config.AgentSettings;
|
||||
import com.yunhai.agent.config.Config;
|
||||
import com.yunhai.agent.model.ChatRequest;
|
||||
import com.yunhai.agent.model.ChatResponse;
|
||||
import com.yunhai.agent.service.AgentEvent;
|
||||
import com.yunhai.agent.service.AgentOrchestrator;
|
||||
import com.yunhai.agent.service.SessionStore;
|
||||
import com.yunhai.agent.tool.CallerContext;
|
||||
import com.yunhai.agent.tool.ToolDef;
|
||||
import com.yunhai.agent.tool.ToolRegistry;
|
||||
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||
import com.yunhai.platform.utils.model.BoaModule;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* 智能体对外接口(路径前缀 {@code /{platform}/agent/},前端固定走 {@code /api/agent/...})。
|
||||
*
|
||||
* <ul>
|
||||
* <li>POST {@code /api/agent/chat} —— 一问一答(阻塞返回完整结果)</li>
|
||||
* <li>POST {@code /api/agent/chat/stream} —— SSE,实时推送「正在思考 / 正在调用 XX / 最终回复」</li>
|
||||
* <li>GET {@code /api/agent/tools} —— 能力目录(按当前角色过滤后返回)</li>
|
||||
* <li>POST {@code /api/agent/session/clear} —— 清空当前会话上下文</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>返回裸对象,由平台 {@code ApiResponseAdvice} 自动包装为 {@code {code, msg, data}}(与 NoticeService 一致)。
|
||||
* 当前登录用户由平台 CAS 会话注入 {@link BoaUser},角色由 {@link RoleResolver} 解析。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping(Config.API_PATH + Config.AGENT + "/")
|
||||
public class AgentController {
|
||||
|
||||
@Autowired
|
||||
private AgentOrchestrator orchestrator;
|
||||
@Autowired
|
||||
private ToolRegistry registry;
|
||||
@Autowired
|
||||
private SessionStore sessionStore;
|
||||
@Autowired
|
||||
private AgentSettings settings;
|
||||
@Autowired
|
||||
private RoleResolver roleResolver;
|
||||
|
||||
/** 一问一答 */
|
||||
@PostMapping("chat")
|
||||
public ChatResponse chat(@RequestBody ChatRequest req, BoaUser authUser, HttpServletRequest request) {
|
||||
CallerContext caller = buildCaller(authUser, request);
|
||||
return orchestrator.chat(req, describeUser(authUser, caller), caller,
|
||||
authorizedModIds(authUser), null);
|
||||
}
|
||||
|
||||
/** SSE 流式:阶段事件实时推送 */
|
||||
@PostMapping(value = "chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter chatStream(@RequestBody ChatRequest req, BoaUser authUser, HttpServletRequest request) {
|
||||
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
|
||||
CallerContext caller = buildCaller(authUser, request);
|
||||
String userDesc = describeUser(authUser, caller);
|
||||
Set<Integer> mods = authorizedModIds(authUser);
|
||||
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
orchestrator.chat(req, userDesc, caller, mods, event -> {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name(event.getType())
|
||||
.data(JSON.toJSONString(event), MediaType.APPLICATION_JSON));
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
// 客户端断开:后续事件丢弃即可
|
||||
}
|
||||
});
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
log.error("SSE 对话异常", e);
|
||||
try {
|
||||
AgentEvent err = AgentEvent.of("error", e.getMessage() == null ? "服务异常" : e.getMessage());
|
||||
emitter.send(SseEmitter.event().name("error")
|
||||
.data(JSON.toJSONString(err), MediaType.APPLICATION_JSON));
|
||||
} catch (Exception ignore) {
|
||||
// 忽略
|
||||
}
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
|
||||
// 客户端断开时释放
|
||||
emitter.onTimeout(emitter::complete);
|
||||
emitter.onError(e -> log.debug("SSE 连接错误:{}", e.toString()));
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/** 能力目录:按当前角色 + 授权模块过滤后,按模块分组,前端用来渲染快捷指令与"能做什么" */
|
||||
@GetMapping("tools")
|
||||
public Map<String, Object> tools(BoaUser authUser, HttpServletRequest request) {
|
||||
CallerContext caller = buildCaller(authUser, request);
|
||||
Set<Integer> mods = authorizedModIds(authUser);
|
||||
Set<AgentRole> roles = caller.getRoles();
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
Map<String, List<ToolDef>> grouped = registry.groupByModule(mods, roles);
|
||||
Map<String, Object> modules = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<ToolDef>> e : grouped.entrySet()) {
|
||||
List<Map<String, Object>> items = new ArrayList<>();
|
||||
for (ToolDef d : e.getValue()) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("name", d.getName());
|
||||
item.put("title", d.getTitle());
|
||||
item.put("description", d.getDescription());
|
||||
item.put("write", !d.isReadOnly());
|
||||
item.put("landingPath", d.getLandingPath());
|
||||
item.put("roles", d.roleLabel());
|
||||
items.add(item);
|
||||
}
|
||||
modules.put(e.getKey(), items);
|
||||
}
|
||||
result.put("modules", modules);
|
||||
result.put("total", registry.visibleTools(mods, roles).size());
|
||||
result.put("roles", roleResolver.describe(roles));
|
||||
|
||||
// 当前身份明细:前端展示「我现在是谁」,也方便现场核对角色号配置
|
||||
Map<String, Object> identity = new LinkedHashMap<>();
|
||||
identity.put("userId", caller.getUserId());
|
||||
identity.put("userName", caller.getUserName());
|
||||
identity.put("schoolId", caller.getSchoolId());
|
||||
identity.put("roleId", caller.getRoleId());
|
||||
identity.put("roleName", caller.getCurrentRoleName());
|
||||
identity.put("roles", roleResolver.describe(roles));
|
||||
result.put("identity", identity);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 清空会话 */
|
||||
@PostMapping("session/clear")
|
||||
public Map<String, Object> clearSession(@RequestBody(required = false) ChatRequest req) {
|
||||
Map<String, Object> ok = new LinkedHashMap<>();
|
||||
if (req != null) {
|
||||
sessionStore.clear(req.getSessionId());
|
||||
}
|
||||
ok.put("cleared", true);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* ==================== 内部:身份与权限 ==================== */
|
||||
|
||||
/** 组装调用者身份:请求头 + 平台 BoaUser(后者权威),并解析角色 */
|
||||
private CallerContext buildCaller(BoaUser authUser, HttpServletRequest request) {
|
||||
CallerContext caller = CallerContext.from(request);
|
||||
caller.fillFrom(authUser, roleResolver.resolve(authUser));
|
||||
return caller;
|
||||
}
|
||||
|
||||
/** 用户身份描述,写进 system prompt */
|
||||
private String describeUser(BoaUser user, CallerContext caller) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String name = user != null ? user.getName() : caller.getUserName();
|
||||
if (StringUtils.isNotBlank(name)) {
|
||||
sb.append("姓名 ").append(name);
|
||||
} else {
|
||||
sb.append("用户ID ").append(caller.getUserId());
|
||||
}
|
||||
sb.append(",身份 ").append(roleResolver.describe(caller.getRoles()));
|
||||
if (caller.schoolIdAsInt() != null) {
|
||||
sb.append(",学校ID ").append(caller.getSchoolId());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 已授权 modId 集合;未开启过滤或无身份时返回 null(=不按 modId 过滤) */
|
||||
private Set<Integer> authorizedModIds(BoaUser user) {
|
||||
if (!settings.isFilterToolsByAuthorizedModule() || user == null || user.getId() == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Integer schId = user.getCurrentRole() != null ? user.getCurrentRole().getSchId() : null;
|
||||
List<BoaModule> mods = YschBaseOpenApi.getUserModule(user.getId(), schId);
|
||||
if (mods == null) {
|
||||
return new HashSet<>();
|
||||
}
|
||||
Set<Integer> ids = new HashSet<>();
|
||||
for (BoaModule m : mods) {
|
||||
if (m.getId() != null) {
|
||||
ids.add(m.getId());
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
} catch (Exception e) {
|
||||
log.warn("获取用户授权模块失败,本轮不按 modId 过滤工具:{}", e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.yunhai.agent.controller;
|
||||
|
||||
import com.yunhai.agent.config.Config;
|
||||
import com.yunhai.platform.auth.LoginUser;
|
||||
import com.yunhai.platform.auth.UserContextHolder;
|
||||
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||
import com.yunhai.platform.utils.model.BoaModule;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 平台壳层接口(路径前缀 {@code /{platform}/base/},与各业务模块同名同义,前端外壳 layout 复用同一套调用)。
|
||||
*
|
||||
* <p>{@code BoaUser} 由平台 starter 的参数解析器从 CAS 会话注入(与 NoticeService 完全一致)。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping(Config.API_PATH + Config.BASE + "/")
|
||||
public class BaseApiController {
|
||||
|
||||
/** 当前登录用户信息 */
|
||||
@GetMapping("GetUserInfo")
|
||||
public BoaUser getUserInfo(BoaUser authUserWithDept) {
|
||||
return authUserWithDept;
|
||||
}
|
||||
|
||||
/** 当前用户已授权的模块列表(左侧菜单 / 权限过滤数据源) */
|
||||
@GetMapping("GetUserMod")
|
||||
public List<BoaModule> getUserMod(BoaUser user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Integer schId = user.getCurrentRole() != null ? user.getCurrentRole().getSchId() : null;
|
||||
return YschBaseOpenApi.getUserModule(user.getId(), schId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录:销毁本应用的 CAS 会话。
|
||||
*
|
||||
* <p>★ 必须真的把会话销毁掉,不能只留痕。本应用的登录态就是服务端 session 里的
|
||||
* {@code _const_cas_assertion_}(平台 {@code CasSsoApiRedirectFilter} 就是拿它判断
|
||||
* 「登录了没」,{@code CasSessionAuthStrategy} 再经 {@code getRemoteUser()} 解析出身份)。
|
||||
* 前端跳到 CAS 的 {@code /logout} 只能销毁 SSO 侧的票据,本应用的 JSESSIONID 依旧有效,
|
||||
* 返回页面时仍是登录态 —— 用户看到的现象就是「点了退出没用 / 退不出去」。
|
||||
*
|
||||
* <p>为什么不能指望 CAS 的 SLO 回推:平台虽然注册了 {@code SingleSignOutFilter},
|
||||
* 但没注册配套的会话监听,且回推要求本服务地址在 CAS 侧登记过(本地联调是
|
||||
* {@code http://localhost:8093},必然登记不了)。平台自带的业务模块
|
||||
* (如 HomeSchLeave 的 {@code LoginUserController#logout})同样是自己
|
||||
* {@code session.invalidate()},这里与其保持同形。
|
||||
*
|
||||
* <p>同时保留 GET / POST 两种方法:前端外壳走 POST,平台老壳层沿用 GET。
|
||||
*/
|
||||
@RequestMapping(value = "Logout", method = {RequestMethod.GET, RequestMethod.POST})
|
||||
public void logout(HttpServletRequest request) {
|
||||
LoginUser user = UserContextHolder.get();
|
||||
log.info("[logout] 用户退出登录: {}", user == null ? null : user.getLoginName());
|
||||
invalidateSession(request);
|
||||
}
|
||||
|
||||
/** 销毁当前会话;会话本来就没有/已失效时不报错(登出接口必须幂等) */
|
||||
private void invalidateSession(HttpServletRequest request) {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
session.invalidate();
|
||||
log.info("[logout] 本地会话已销毁");
|
||||
} catch (IllegalStateException e) {
|
||||
log.debug("[logout] 会话已失效,无需重复销毁");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.yunhai.agent.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Lob;
|
||||
import javax.persistence.Table;
|
||||
|
||||
/**
|
||||
* 会话里的一条消息(system / user / assistant / tool),按 seq_no 保序。
|
||||
*
|
||||
* <p>assistant 消息可能携带工具调用请求,序列化进 {@link #toolCalls};
|
||||
* tool 消息用于回填工具执行结果,关联 {@link #toolCallId}。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "agent_message", indexes = {
|
||||
@Index(name = "idx_agent_msg_sid", columnList = "session_id,seq_no")
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
public class AgentMessage extends BaseEntity {
|
||||
|
||||
/** 所属会话 id */
|
||||
@Column(name = "session_id", nullable = false, length = 64)
|
||||
private String sessionId;
|
||||
|
||||
/** 会话内顺序(从 0 递增) */
|
||||
@Column(name = "seq_no")
|
||||
private Integer seqNo;
|
||||
|
||||
/** 角色:system / user / assistant / tool */
|
||||
@Column(name = "role", length = 16)
|
||||
private String role;
|
||||
|
||||
/** 正文(可能为空:assistant 只想调工具时) */
|
||||
@Lob
|
||||
@Column(name = "content", columnDefinition = "TEXT")
|
||||
private String content;
|
||||
|
||||
/** assistant 的 tool_calls(JSON 数组字符串) */
|
||||
@Lob
|
||||
@Column(name = "tool_calls", columnDefinition = "TEXT")
|
||||
private String toolCalls;
|
||||
|
||||
/** role=tool 时关联的调用 id */
|
||||
@Column(name = "tool_call_id", length = 64)
|
||||
private String toolCallId;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.yunhai.agent.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 智能体会话:一个用户在「一段连续对话」里的上下文台账。
|
||||
*
|
||||
* <p>会话与 userId 绑定(跨用户复用同一 sessionId 会被拒绝并重建),承载多轮对话的归属与审计信息;
|
||||
* 具体消息见 {@link AgentMessage}。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "agent_session", indexes = {
|
||||
@Index(name = "idx_agent_session_sid", columnList = "session_id", unique = true),
|
||||
@Index(name = "idx_agent_session_user", columnList = "user_id")
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
public class AgentSession extends BaseEntity {
|
||||
|
||||
/** 会话 id(前端持有并复用) */
|
||||
@Column(name = "session_id", nullable = false, length = 64)
|
||||
private String sessionId;
|
||||
|
||||
/** 归属用户 id */
|
||||
@Column(name = "user_id")
|
||||
private Integer userId;
|
||||
|
||||
/** 学校 id */
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
/** 会话标题(取首条用户输入,便于列表展示) */
|
||||
@Column(name = "title", length = 200)
|
||||
private String title;
|
||||
|
||||
/** 角色快照(学生 / 教师 / 管理员),审计留痕用 */
|
||||
@Column(name = "role_snapshot", length = 64)
|
||||
private String roleSnapshot;
|
||||
|
||||
/** 最后访问时间(空闲过期依据) */
|
||||
@Column(name = "last_access")
|
||||
private LocalDateTime lastAccess;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.yunhai.agent.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Lob;
|
||||
import javax.persistence.Table;
|
||||
|
||||
/**
|
||||
* 工具调用审计:每次对业务模块的真实调用留一条痕(含越权被拒的调用)。
|
||||
*
|
||||
* <p>用途:可追溯「谁、什么身份、在哪个会话、调了哪个模块的什么接口、传了什么、成没成、耗时多少」,
|
||||
* 既是运维排障依据,也是「分学生、教师权限」的合规凭证。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "agent_tool_call_log", indexes = {
|
||||
@Index(name = "idx_agent_log_sid", columnList = "session_id"),
|
||||
@Index(name = "idx_agent_log_tool", columnList = "tool_name"),
|
||||
@Index(name = "idx_agent_log_user", columnList = "user_id")
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
public class AgentToolCallLog extends BaseEntity {
|
||||
|
||||
/** 所属会话 id */
|
||||
@Column(name = "session_id", length = 64)
|
||||
private String sessionId;
|
||||
|
||||
/** 调用者用户 id */
|
||||
@Column(name = "user_id")
|
||||
private Integer userId;
|
||||
|
||||
/** 学校 id */
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
/** 调用者角色(学生 / 教师 / 管理员) */
|
||||
@Column(name = "role", length = 64)
|
||||
private String role;
|
||||
|
||||
/** 业务模块标识 */
|
||||
@Column(name = "module", length = 64)
|
||||
private String module;
|
||||
|
||||
/** 工具名,如 leave.submit */
|
||||
@Column(name = "tool_name", length = 128)
|
||||
private String toolName;
|
||||
|
||||
/** 工具中文名 */
|
||||
@Column(name = "tool_title", length = 128)
|
||||
private String toolTitle;
|
||||
|
||||
/** 是否写操作 */
|
||||
@Column(name = "is_write")
|
||||
private Boolean write;
|
||||
|
||||
/** 调用参数(JSON) */
|
||||
@Lob
|
||||
@Column(name = "arguments", columnDefinition = "TEXT")
|
||||
private String arguments;
|
||||
|
||||
/** 是否成功(越权拦截为 false) */
|
||||
@Column(name = "success")
|
||||
private Boolean success;
|
||||
|
||||
/** 失败原因 */
|
||||
@Lob
|
||||
@Column(name = "error", columnDefinition = "TEXT")
|
||||
private String error;
|
||||
|
||||
/** 耗时(毫秒) */
|
||||
@Column(name = "cost_ms")
|
||||
private Long costMs;
|
||||
|
||||
/** 建议跳转路由 */
|
||||
@Column(name = "landing_path", length = 128)
|
||||
private String landingPath;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.yunhai.agent.entity;
|
||||
|
||||
import com.yunhai.agent.common.listener.AgentAuditListener;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.EntityListeners;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.MappedSuperclass;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link AgentAuditListener} 自动填充)。
|
||||
*/
|
||||
@MappedSuperclass
|
||||
@Getter
|
||||
@Setter
|
||||
@EntityListeners(AgentAuditListener.class)
|
||||
public abstract class BaseEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
/** 创建人登录账号 */
|
||||
@Column(name = "create_by", updatable = false)
|
||||
private String createBy;
|
||||
|
||||
/** 创建时间 */
|
||||
@Column(name = "create_time", updatable = false)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/** 更新人登录账号 */
|
||||
@Column(name = "update_by")
|
||||
private String updateBy;
|
||||
|
||||
/** 更新时间 */
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.yunhai.agent.llm;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.yunhai.agent.config.AgentSettings;
|
||||
import com.yunhai.agent.llm.dto.ChatMessage;
|
||||
import com.yunhai.agent.llm.dto.ChatResult;
|
||||
import com.yunhai.agent.llm.dto.ToolCall;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
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.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 大模型客户端(OpenAI 兼容协议):默认对接 DeepSeek 官方
|
||||
* {@code https://api.deepseek.com/chat/completions},模型 {@code deepseek-flash}。
|
||||
* <p>模型标识以官方实时清单为准({@code GET https://api.deepseek.com/models})。
|
||||
*
|
||||
* <p>协议要点:
|
||||
* <ul>
|
||||
* <li>请求体 {@code {model, messages, tools, tool_choice, max_tokens, temperature}}</li>
|
||||
* <li>响应 {@code choices[0].message.content} 为正文,
|
||||
* {@code choices[0].message.tool_calls[]} 为工具调用请求</li>
|
||||
* <li>模型要求调用工具时 {@code finish_reason == "tool_calls"},此时 content 通常为空</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>换供应商只要改 {@code agent.llm.host}(豆包方舟 / 千问兼容模式 / 自有网关都遵循该协议)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DeepSeekClient implements LlmClient {
|
||||
|
||||
private static final String TOOL_CHOICE_AUTO = "auto";
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final AgentSettings settings;
|
||||
|
||||
public DeepSeekClient(@Qualifier("yhRestTemplate") RestTemplate restTemplate, AgentSettings settings) {
|
||||
this.restTemplate = restTemplate;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatResult chat(List<ChatMessage> messages, JSONArray tools) {
|
||||
AgentSettings.Llm llm = settings.getLlm();
|
||||
if (StringUtils.isBlank(llm.getApiKey())) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
"未配置大模型 api-key(agent.llm.api-key)");
|
||||
}
|
||||
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("model", llm.getModel());
|
||||
body.put("max_tokens", llm.getMaxTokens());
|
||||
|
||||
JSONArray msgArr = new JSONArray();
|
||||
for (ChatMessage m : messages) {
|
||||
msgArr.add(m.toJson());
|
||||
}
|
||||
body.put("messages", msgArr);
|
||||
|
||||
// 现役 V4 系列(deepseek-flash / deepseek-v4-pro)temperature 与 tools 都支持。
|
||||
// 保留 isReasoner 判断只为兼容历史上"reasoner 不支持二者"的模型名,正常情况下恒为 false。
|
||||
if (!isReasoner(llm.getModel())) {
|
||||
body.put("temperature", llm.getTemperature());
|
||||
if (tools != null && !tools.isEmpty()) {
|
||||
body.put("tools", tools);
|
||||
body.put("tool_choice", TOOL_CHOICE_AUTO);
|
||||
}
|
||||
}
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("Authorization", "Bearer " + llm.getApiKey());
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
String url = StringUtils.isNotBlank(llm.getHost()) ? llm.getHost() : llm.getFallbackHost();
|
||||
String requestBody = JSON.toJSONString(body);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
ResponseEntity<String> resp;
|
||||
try {
|
||||
resp = restTemplate.postForEntity(url, new HttpEntity<>(requestBody, headers), String.class);
|
||||
} catch (Exception e) {
|
||||
log.error("调用大模型失败 url={} model={} err={}", url, llm.getModel(), e.toString());
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, humanize(e, url));
|
||||
}
|
||||
long cost = System.currentTimeMillis() - start;
|
||||
|
||||
String raw = resp.getBody();
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "大模型返回为空");
|
||||
}
|
||||
|
||||
JSONObject json;
|
||||
try {
|
||||
json = JSON.parseObject(raw);
|
||||
} catch (Exception e) {
|
||||
log.error("大模型返回非 JSON:{}", StringUtils.abbreviate(raw, 500));
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "大模型返回格式异常");
|
||||
}
|
||||
|
||||
if (json.containsKey("error")) {
|
||||
JSONObject err = json.getJSONObject("error");
|
||||
String msg = err == null ? null : err.getString("message");
|
||||
String code = err == null ? null : err.getString("code");
|
||||
log.error("大模型返回错误:code={} msg={}", code, msg);
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, humanizeApiError(msg));
|
||||
}
|
||||
|
||||
ChatResult result = new ChatResult();
|
||||
result.setRaw(raw);
|
||||
|
||||
JSONArray choices = json.getJSONArray("choices");
|
||||
if (choices == null || choices.isEmpty()) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "大模型未返回任何选择");
|
||||
}
|
||||
JSONObject message = choices.getJSONObject(0).getJSONObject("message");
|
||||
if (message != null) {
|
||||
// 部分模型把 content 放在嵌套结构里(兼容历史网关),这里做一次宽容取值
|
||||
Object contentObj = message.get("content");
|
||||
if (contentObj instanceof String) {
|
||||
result.setContent((String) contentObj);
|
||||
} else if (contentObj != null) {
|
||||
result.setContent(JSON.toJSONString(contentObj));
|
||||
}
|
||||
JSONArray toolCalls = message.getJSONArray("tool_calls");
|
||||
if (toolCalls != null) {
|
||||
List<ToolCall> list = new ArrayList<>();
|
||||
for (int i = 0; i < toolCalls.size(); i++) {
|
||||
JSONObject tc = toolCalls.getJSONObject(i);
|
||||
ToolCall call = new ToolCall();
|
||||
call.setId(tc.getString("id"));
|
||||
call.setType(tc.getString("type"));
|
||||
JSONObject fn = tc.getJSONObject("function");
|
||||
if (fn != null) {
|
||||
ToolCall.Function f = new ToolCall.Function();
|
||||
f.setName(fn.getString("name"));
|
||||
f.setArguments(fn.getString("arguments"));
|
||||
call.setFunction(f);
|
||||
}
|
||||
list.add(call);
|
||||
}
|
||||
result.setToolCalls(list);
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject usage = json.getJSONObject("usage");
|
||||
if (usage != null) {
|
||||
result.setPromptTokens(usage.getInteger("prompt_tokens"));
|
||||
result.setCompletionTokens(usage.getInteger("completion_tokens"));
|
||||
}
|
||||
|
||||
log.info("大模型调用完成 cost={}ms tools={} tokens={}/{}",
|
||||
cost, result.getToolCalls().size(),
|
||||
result.getPromptTokens(), result.getCompletionTokens());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把底层 HTTP 异常翻译成使用者能看懂的话。
|
||||
*
|
||||
* <p>典型场景:密钥无效时服务端回 401,而请求体是流式发送的,JDK 的 {@code HttpURLConnection}
|
||||
* 会抛出 {@code HttpRetryException: cannot retry due to server authentication, in streaming mode}
|
||||
* —— 这句话直接抛给前端毫无信息量,需要还原成「密钥无效」。
|
||||
*/
|
||||
private static String humanize(Throwable e, String url) {
|
||||
Integer status = findStatus(e);
|
||||
if (status != null && (status == 401 || status == 403)) {
|
||||
return "大模型鉴权失败(HTTP " + status + "):api-key 无效、已过期或无权限,"
|
||||
+ "请检查 agent.llm.api-key(或环境变量 DEEPSEEK_API_KEY)";
|
||||
}
|
||||
if (status != null && status == 402) {
|
||||
return "大模型账户余额不足(HTTP 402):请前往大模型平台充值后重试";
|
||||
}
|
||||
if (status != null && status == 429) {
|
||||
return "大模型请求过于频繁或账户余额不足(HTTP 429),请稍后重试或检查账户额度";
|
||||
}
|
||||
if (status != null && status >= 500) {
|
||||
return "大模型服务端异常(HTTP " + status + "),请稍后重试";
|
||||
}
|
||||
|
||||
String m = e.getMessage() == null ? e.toString() : e.getMessage();
|
||||
if (m.contains("cannot retry due to server authentication")) {
|
||||
return "大模型鉴权失败:api-key 无效或未配置,请检查 agent.llm.api-key";
|
||||
}
|
||||
if (m.contains("UnknownHost")) {
|
||||
return "无法解析大模型地址 " + url + ",请检查网络或 agent.llm.host 配置";
|
||||
}
|
||||
if (m.contains("onnection refused") || m.contains("connect timed out")) {
|
||||
return "无法连接大模型地址 " + url + ",请检查网络与代理设置";
|
||||
}
|
||||
if (m.contains("imed out") || m.contains("imeout")) {
|
||||
return "调用大模型超时,请稍后重试(可调大 yh.http.read-timeout-ms)";
|
||||
}
|
||||
return "大模型服务不可用:" + m;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把大模型返回体里的 {@code error.message} 翻成人话。
|
||||
*
|
||||
* <p>该分支拿不到 HTTP 状态码(平台 RestTemplate 对 4xx 不回抛),只能按文案特征识别。
|
||||
* 典型:余额为 0 时 DeepSeek 回 {@code Insufficient Balance}(对应 HTTP 402),
|
||||
* 原样抛给使用者等于没说清「要充值」。
|
||||
*/
|
||||
private static String humanizeApiError(String msg) {
|
||||
String m = msg == null ? "" : msg;
|
||||
String lower = m.toLowerCase();
|
||||
if (lower.contains("insufficient balance") || lower.contains("insufficient_quota")
|
||||
|| lower.contains("exceeded your current quota") || lower.contains("payment required")) {
|
||||
return "大模型账户余额不足(" + m + "):请前往大模型平台充值后再试";
|
||||
}
|
||||
if (lower.contains("authentication fails") || lower.contains("invalid api key")
|
||||
|| lower.contains("invalid_api_key") || lower.contains("unauthorized")) {
|
||||
return "大模型鉴权失败(" + m + "):api-key 无效或已过期,请检查 agent.llm.api-key";
|
||||
}
|
||||
if (lower.contains("rate limit") || lower.contains("too many requests")) {
|
||||
return "大模型请求过于频繁(" + m + "),请稍后重试";
|
||||
}
|
||||
if (lower.contains("model not exist") || lower.contains("model_not_found")
|
||||
|| lower.contains("invalid model") || lower.contains("does not exist")) {
|
||||
return "大模型模型标识无效(" + m + "):请用官方实时清单 GET https://api.deepseek.com/models "
|
||||
+ "核对 agent.llm.model(当前应为 deepseek-flash 或 deepseek-v4-pro)";
|
||||
}
|
||||
return "大模型错误:" + m;
|
||||
}
|
||||
|
||||
/** 从异常链里挖出 HTTP 状态码(HttpRetryException / HttpStatusCodeException 两种常见载体) */
|
||||
private static Integer findStatus(Throwable e) {
|
||||
for (Throwable t = e; t != null && t != t.getCause(); t = t.getCause()) {
|
||||
if (t instanceof java.net.HttpRetryException) {
|
||||
int code = ((java.net.HttpRetryException) t).responseCode();
|
||||
if (code > 0) {
|
||||
return code;
|
||||
}
|
||||
} else if (t instanceof org.springframework.web.client.HttpStatusCodeException) {
|
||||
return ((org.springframework.web.client.HttpStatusCodeException) t).getRawStatusCode();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isReasoner(String model) {
|
||||
return model != null && model.toLowerCase().contains("reasoner");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.yunhai.agent.llm;
|
||||
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.yunhai.agent.llm.dto.ChatMessage;
|
||||
import com.yunhai.agent.llm.dto.ChatResult;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 大模型客户端抽象:便于在 DeepSeek / 豆包 / 自有网关之间切换(配置驱动)。
|
||||
*/
|
||||
public interface LlmClient {
|
||||
|
||||
/**
|
||||
* 发起一次对话补全。
|
||||
*
|
||||
* @param messages 对话上下文(含 system)
|
||||
* @param tools 可调用的工具 schema(OpenAI tools 格式);为空表示本轮不开放工具
|
||||
* @return 模型结果(正文 + 工具调用请求)
|
||||
*/
|
||||
ChatResult chat(List<ChatMessage> messages, JSONArray tools);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.yunhai.agent.llm.dto;
|
||||
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 一条对话消息(OpenAI / DeepSeek chat 格式)。
|
||||
*
|
||||
* <p>四种角色:
|
||||
* <ul>
|
||||
* <li>{@code system} — 系统提示词</li>
|
||||
* <li>{@code user} — 用户输入</li>
|
||||
* <li>{@code assistant} — 模型输出(可能带 {@link #toolCalls})</li>
|
||||
* <li>{@code tool} — 工具执行结果回填(必须带 {@link #toolCallId})</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ChatMessage {
|
||||
|
||||
public static final String ROLE_SYSTEM = "system";
|
||||
public static final String ROLE_USER = "user";
|
||||
public static final String ROLE_ASSISTANT = "assistant";
|
||||
public static final String ROLE_TOOL = "tool";
|
||||
|
||||
private String role;
|
||||
private String content;
|
||||
|
||||
/** assistant 消息里的工具调用请求 */
|
||||
private List<ToolCall> toolCalls;
|
||||
|
||||
/** role=tool 时必填:对应哪一次工具调用 */
|
||||
private String toolCallId;
|
||||
|
||||
public ChatMessage(String role, String content) {
|
||||
this.role = role;
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public static ChatMessage system(String content) {
|
||||
return new ChatMessage(ROLE_SYSTEM, content);
|
||||
}
|
||||
|
||||
public static ChatMessage user(String content) {
|
||||
return new ChatMessage(ROLE_USER, content);
|
||||
}
|
||||
|
||||
public static ChatMessage tool(String toolCallId, String content) {
|
||||
ChatMessage m = new ChatMessage(ROLE_TOOL, content);
|
||||
m.setToolCallId(toolCallId);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** 序列化为 DeepSeek 要求的消息结构(null 字段不输出) */
|
||||
public JSONObject toJson() {
|
||||
JSONObject o = new JSONObject();
|
||||
o.put("role", role);
|
||||
if (content != null) {
|
||||
o.put("content", content);
|
||||
} else if (ROLE_ASSISTANT.equals(role)) {
|
||||
// 带工具调用的 assistant 消息 content 允许为 null,但部分网关要求显式空串
|
||||
o.put("content", "");
|
||||
}
|
||||
if (toolCalls != null && !toolCalls.isEmpty()) {
|
||||
JSONArray arr = new JSONArray();
|
||||
for (ToolCall tc : toolCalls) {
|
||||
arr.add(tc.toJson());
|
||||
}
|
||||
o.put("tool_calls", arr);
|
||||
}
|
||||
if (StringUtils.isNotBlank(toolCallId)) {
|
||||
o.put("tool_call_id", toolCallId);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.yunhai.agent.llm.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 一次模型调用的结果:要么是自然语言回复,要么是若干工具调用请求(也可能两者都有)。
|
||||
*/
|
||||
@Data
|
||||
public class ChatResult {
|
||||
|
||||
/** 模型回答正文(可能为空:当它只想调工具时) */
|
||||
private String content;
|
||||
|
||||
/** 模型要调用的工具 */
|
||||
private List<ToolCall> toolCalls = new ArrayList<>();
|
||||
|
||||
/** 原始响应(排障用) */
|
||||
private String raw;
|
||||
|
||||
/** token 用量,便于成本观测 */
|
||||
private Integer promptTokens;
|
||||
private Integer completionTokens;
|
||||
|
||||
public boolean hasToolCalls() {
|
||||
return toolCalls != null && !toolCalls.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yunhai.agent.llm.dto;
|
||||
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 模型请求调用某个工具。
|
||||
*/
|
||||
@Data
|
||||
public class ToolCall {
|
||||
|
||||
/** 调用 id,回填结果时原样带回 */
|
||||
private String id;
|
||||
|
||||
/** 固定为 function */
|
||||
private String type = "function";
|
||||
|
||||
private Function function;
|
||||
|
||||
@Data
|
||||
public static class Function {
|
||||
/** 工具名(对应 ToolRegistry 里注册的 name) */
|
||||
private String name;
|
||||
/** 参数 JSON 字符串(由模型生成,不一定合法,执行前要容错解析) */
|
||||
private String arguments;
|
||||
}
|
||||
|
||||
public JSONObject toJson() {
|
||||
JSONObject o = new JSONObject();
|
||||
o.put("id", id);
|
||||
o.put("type", type == null ? "function" : type);
|
||||
JSONObject f = new JSONObject();
|
||||
f.put("name", function == null ? null : function.getName());
|
||||
f.put("arguments", function == null ? "{}" : function.getArguments());
|
||||
o.put("function", f);
|
||||
return o;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.yunhai.agent.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 「浏览器代执行」挂起载荷(方案 A)。
|
||||
*
|
||||
* <p>业务模块的用户态接口只认浏览器 CAS 会话,服务端代调必然 302。开启代执行后,
|
||||
* 模型决定调用模块工具时,服务端<b>不执行</b>,而是把接口描述放进本载荷下发前端;
|
||||
* 前端用用户自己的登录态调用模块接口,再把结果通过
|
||||
* {@link ChatRequest.ToolResultItem} 回传,服务端续跑模型生成最终回答。
|
||||
*
|
||||
* <p>★ 安全边界:本载荷里的 {@code pathAndQuery} 只供前端调用使用;
|
||||
* 回传续跑时服务端<b>只信任工具名</b>(重新查注册表拿方法/路径并做角色校验),
|
||||
* 前端回传的任何路径信息都不会被采信,防止被改写成任意地址。
|
||||
*/
|
||||
@Data
|
||||
public class BrowserToolRequest {
|
||||
|
||||
/** 本批代执行的工具调用(同一轮模型可能连调多个,如先查列表再查详情) */
|
||||
private List<BrowserCall> calls = new ArrayList<>();
|
||||
|
||||
/** 给用户看的一句话说明(前端在等待期展示) */
|
||||
private String notice;
|
||||
|
||||
/**
|
||||
* 一次代执行调用的完整描述。
|
||||
* <p>{@code pathAndQuery} 以 {@code /} 开头({@code /<模块ctx>/api/...?query}),
|
||||
* 前端拼上当前页面的 {@code location.origin} 即为最终地址 ——
|
||||
* 同站部署时天然同源;本地 dev 则会打到 vite 代理(404)→ 自动落回深链兜底。
|
||||
*/
|
||||
@Data
|
||||
public static class BrowserCall {
|
||||
/** 工具内部名(点分),回传结果时原样带回 */
|
||||
private String name;
|
||||
/** 中文简称(进度展示) */
|
||||
private String title;
|
||||
/** 模型给的参数(回显给用户看「在查什么」;回传时原样带回) */
|
||||
private Map<String, Object> arguments;
|
||||
/** 是否写操作 */
|
||||
private boolean write;
|
||||
|
||||
/** GET / POST_JSON / POST_FORM */
|
||||
private String method;
|
||||
/** 相对当前站点根的路径与查询串,如 /home-leave/api/activity/List?keyword=运动会 */
|
||||
private String pathAndQuery;
|
||||
/** POST 时的请求体(GET 为 null) */
|
||||
private String body;
|
||||
/** POST 时的 Content-Type */
|
||||
private String contentType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.yunhai.agent.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 前端发起的一次对话请求。
|
||||
*/
|
||||
@Data
|
||||
public class ChatRequest {
|
||||
|
||||
/** 会话 id;首次为空,服务端生成并在响应里带回,前端后续复用 */
|
||||
private String sessionId;
|
||||
|
||||
/** 用户输入的自然语言 */
|
||||
private String message;
|
||||
|
||||
/** 是否允许调用工具(默认允许);调试/纯问答可置 false */
|
||||
private Boolean enableTools = true;
|
||||
|
||||
/** 用户对上一次写操作的确认结果(预留:二次确认场景) */
|
||||
private Boolean confirmed;
|
||||
|
||||
/**
|
||||
* 表单提交:用户在补全表单里填完直接提交。
|
||||
* <p>非空时服务端<b>直接执行</b>指定工具(不再让模型猜该不该调、参数是什么),
|
||||
* 保证「填了就一定按填的提交」。
|
||||
*/
|
||||
private FormSubmit formSubmit;
|
||||
|
||||
/**
|
||||
* 浏览器代执行的结果回传(方案 A):与 {@link #formSubmit} 同级的「续跑」入口。
|
||||
* <p>前端用用户登录态调用模块接口后,把每个调用的原始响应带回;
|
||||
* 服务端重新查注册表解析结果、喂回模型生成最终回答。
|
||||
*/
|
||||
private java.util.List<ToolResultItem> toolResult;
|
||||
|
||||
/** 一次代执行调用的回传项(与挂起载荷里的 BrowserCall 按 name 对应) */
|
||||
@Data
|
||||
public static class ToolResultItem {
|
||||
/** 工具内部名(点分),与挂起时一致 */
|
||||
private String name;
|
||||
/** 模型参数原样带回(审计与轨迹展示用) */
|
||||
private Map<String, Object> arguments;
|
||||
/** HTTP 状态码;网络层失败时为 null */
|
||||
private Integer status;
|
||||
/** 响应体原文 */
|
||||
private String body;
|
||||
/** 网络层错误(fetch 失败/超时/跨域),有值时 status 无意义 */
|
||||
private String error;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class FormSubmit {
|
||||
/** 目标工具名,如 leave.submit */
|
||||
private String tool;
|
||||
/** 用户填写的参数(键与业务接口参数名一致) */
|
||||
private Map<String, Object> args;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.yunhai.agent.model;
|
||||
|
||||
import com.yunhai.agent.tool.FormRequest;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 一次对话的完整结果。
|
||||
*/
|
||||
@Data
|
||||
public class ChatResponse {
|
||||
|
||||
private String sessionId;
|
||||
|
||||
/** 智能体最终回复(自然语言) */
|
||||
private String reply;
|
||||
|
||||
/** 本轮执行过的工具调用(按顺序) */
|
||||
private List<ToolCallTrace> toolCalls = new ArrayList<>();
|
||||
|
||||
/** 是否发生了写操作(前端据此提示「已为你提交」) */
|
||||
private boolean wroteSomething;
|
||||
|
||||
/** 建议跳转的路由(写操作后给「去查看」按钮用) */
|
||||
private String landingPath;
|
||||
|
||||
/** 循环轮数 / token 用量,便于观测 */
|
||||
private int rounds;
|
||||
private Integer promptTokens;
|
||||
private Integer completionTokens;
|
||||
|
||||
/** 会话被重置或异常时的提示 */
|
||||
private String warning;
|
||||
|
||||
/**
|
||||
* 信息不全时下发的「补全表单」:前端渲染成可填卡片,用户填完直接提交。
|
||||
* <p>本轮正常执行完毕(或纯问答)时为 null。
|
||||
*/
|
||||
private FormRequest form;
|
||||
|
||||
/**
|
||||
* 「浏览器代执行」挂起载荷(方案 A):非空表示本轮工具调用需要
|
||||
* 前端用用户登录态调用模块接口并回传结果({@code toolResult})续跑。
|
||||
*/
|
||||
private BrowserToolRequest browserTool;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yunhai.agent.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 一次工具调用的执行轨迹(回传前端渲染「智能体做了什么」)。
|
||||
*/
|
||||
@Data
|
||||
public class ToolCallTrace {
|
||||
|
||||
private String name;
|
||||
private String title;
|
||||
|
||||
/** 业务模块标识(前端据此拼出该模块页面地址做「去处理」跳转) */
|
||||
private String module;
|
||||
|
||||
/** 模型给出的参数(已脱敏:不含身份信息) */
|
||||
private Map<String, Object> arguments;
|
||||
|
||||
private boolean success = true;
|
||||
private String error;
|
||||
|
||||
/** 是否产生副作用的写操作 */
|
||||
private boolean write;
|
||||
|
||||
/** 执行后建议跳转的前端路由 */
|
||||
private String landingPath;
|
||||
|
||||
private long costMs;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.yunhai.agent.repository;
|
||||
|
||||
import com.yunhai.agent.entity.AgentMessage;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 会话消息仓储。
|
||||
*/
|
||||
public interface AgentMessageRepository extends JpaRepository<AgentMessage, Integer> {
|
||||
|
||||
List<AgentMessage> findBySessionIdOrderBySeqNoAsc(String sessionId);
|
||||
|
||||
@Transactional
|
||||
@Modifying
|
||||
@Query("delete from AgentMessage m where m.sessionId = :sessionId")
|
||||
int deleteBySessionId(@Param("sessionId") String sessionId);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.yunhai.agent.repository;
|
||||
|
||||
import com.yunhai.agent.entity.AgentSession;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 智能体会话仓储。
|
||||
*/
|
||||
public interface AgentSessionRepository extends JpaRepository<AgentSession, Integer> {
|
||||
|
||||
Optional<AgentSession> findBySessionId(String sessionId);
|
||||
|
||||
/** 空闲过期扫描:最后访问时间早于给定时刻的会话 */
|
||||
List<AgentSession> findByLastAccessBefore(LocalDateTime time);
|
||||
|
||||
/** 窄列更新最后访问时间 */
|
||||
@Transactional
|
||||
@Modifying
|
||||
@Query("update AgentSession s set s.lastAccess = :now where s.sessionId = :sessionId")
|
||||
int touch(@Param("sessionId") String sessionId, @Param("now") LocalDateTime now);
|
||||
|
||||
@Transactional
|
||||
@Modifying
|
||||
@Query("delete from AgentSession s where s.sessionId = :sessionId")
|
||||
int deleteBySessionId(@Param("sessionId") String sessionId);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.agent.repository;
|
||||
|
||||
import com.yunhai.agent.entity.AgentToolCallLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 工具调用审计仓储。
|
||||
*/
|
||||
public interface AgentToolCallLogRepository extends JpaRepository<AgentToolCallLog, Integer> {
|
||||
|
||||
List<AgentToolCallLog> findBySessionIdOrderByIdAsc(String sessionId);
|
||||
|
||||
long countByToolName(String toolName);
|
||||
|
||||
long countBySuccess(Boolean success);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.yunhai.agent.service;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.yunhai.agent.auth.AgentRole;
|
||||
import com.yunhai.agent.entity.AgentToolCallLog;
|
||||
import com.yunhai.agent.repository.AgentToolCallLogRepository;
|
||||
import com.yunhai.agent.tool.CallerContext;
|
||||
import com.yunhai.agent.tool.ToolDef;
|
||||
import com.yunhai.agent.tool.ToolResult;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 工具调用审计服务:把每一次工具执行(含被角色拦截的)写入 {@code agent_tool_call_log}。
|
||||
*
|
||||
* <p>用独立事务({@code REQUIRES_NEW})落库,避免审计写入失败影响主对话链路。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AgentAuditService {
|
||||
|
||||
private final AgentToolCallLogRepository logRepository;
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void record(String sessionId, CallerContext caller, ToolDef def,
|
||||
Map<String, Object> args, ToolResult result) {
|
||||
try {
|
||||
AgentToolCallLog e = new AgentToolCallLog();
|
||||
e.setSessionId(sessionId);
|
||||
e.setUserId(caller == null ? null : caller.userIdAsInt());
|
||||
e.setSchId(caller == null ? null : caller.schoolIdAsInt());
|
||||
e.setRole(roleText(caller == null ? null : caller.getRoles()));
|
||||
e.setModule(def.getModule());
|
||||
e.setToolName(def.getName());
|
||||
e.setToolTitle(def.getTitle());
|
||||
e.setWrite(!def.isReadOnly());
|
||||
e.setArguments(args == null ? null : JSON.toJSONString(args));
|
||||
e.setSuccess(result.isSuccess());
|
||||
e.setError(result.getError());
|
||||
e.setCostMs(result.getCostMs());
|
||||
e.setLandingPath(def.getLandingPath());
|
||||
logRepository.save(e);
|
||||
} catch (Exception ex) {
|
||||
// 审计失败不阻断主流程
|
||||
log.warn("工具调用审计落库失败:{}", ex.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private String roleText(Set<AgentRole> roles) {
|
||||
if (roles == null || roles.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (AgentRole r : roles) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append(",");
|
||||
}
|
||||
sb.append(r.name());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.yunhai.agent.service;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 编排过程中的一个阶段事件(用于 SSE 推送给前端做「正在思考 / 正在调用 XX」的实时反馈)。
|
||||
*/
|
||||
@Data
|
||||
public class AgentEvent {
|
||||
|
||||
/** thinking / tool_call / tool_result / message / done / error */
|
||||
private String type;
|
||||
|
||||
/** 事件文本(展示用) */
|
||||
private String text;
|
||||
|
||||
/** 结构化负载(工具名、参数、结果等) */
|
||||
private Object payload;
|
||||
|
||||
public static AgentEvent of(String type, String text) {
|
||||
AgentEvent e = new AgentEvent();
|
||||
e.type = type;
|
||||
e.text = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
public static AgentEvent of(String type, String text, Object payload) {
|
||||
AgentEvent e = new AgentEvent();
|
||||
e.type = type;
|
||||
e.text = text;
|
||||
e.payload = payload;
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,930 @@
|
||||
package com.yunhai.agent.service;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.yunhai.agent.auth.AgentRole;
|
||||
import com.yunhai.agent.config.AgentSettings;
|
||||
import com.yunhai.agent.llm.LlmClient;
|
||||
import com.yunhai.agent.llm.dto.ChatMessage;
|
||||
import com.yunhai.agent.llm.dto.ChatResult;
|
||||
import com.yunhai.agent.llm.dto.ToolCall;
|
||||
import com.yunhai.agent.model.BrowserToolRequest;
|
||||
import com.yunhai.agent.model.ChatRequest;
|
||||
import com.yunhai.agent.model.ChatResponse;
|
||||
import com.yunhai.agent.model.ToolCallTrace;
|
||||
import com.yunhai.agent.tool.CallerContext;
|
||||
import com.yunhai.agent.tool.FormField;
|
||||
import com.yunhai.agent.tool.FormRequest;
|
||||
import com.yunhai.agent.tool.ToolDef;
|
||||
import com.yunhai.agent.tool.ToolExecutor;
|
||||
import com.yunhai.agent.tool.ToolParam;
|
||||
import com.yunhai.agent.tool.ToolRegistry;
|
||||
import com.yunhai.agent.tool.ToolResult;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 智能体编排核心(ReAct 风格的工具调用循环):
|
||||
*
|
||||
* <pre>
|
||||
* 用户输入
|
||||
* └─► 组装 system + 历史 + 本轮输入
|
||||
* └─► 调大模型(带上「按角色过滤后」的可用工具 schema)
|
||||
* ├─ 模型返回 tool_calls ─┬─ 写操作缺必填参数 ─► 【回补全表单】给用户填(不再口头追问)
|
||||
* │ └─ 参数齐全 ─► 逐个执行(转成对该模块的真实 HTTP 调用)
|
||||
* │ ├─ 角色越权 ─► 服务端直接拒绝(留痕)
|
||||
* │ └─► 结果回填为 role=tool 消息 ─► 再次调模型
|
||||
* └─ 模型返回普通文本 ─► 这就是最终回复,结束
|
||||
* </pre>
|
||||
*
|
||||
* <p>表单提交({@link ChatRequest#getFormSubmit()})走另一条短路:<b>直接执行</b>指定工具,
|
||||
* 不再让模型二次决策,保证「用户填了什么就提交什么」。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AgentOrchestrator {
|
||||
|
||||
private final AgentSettings settings;
|
||||
private final LlmClient llmClient;
|
||||
private final ToolRegistry registry;
|
||||
private final ToolExecutor executor;
|
||||
private final SessionStore sessionStore;
|
||||
private final AgentAuditService auditService;
|
||||
|
||||
@Autowired
|
||||
public AgentOrchestrator(AgentSettings settings, LlmClient llmClient, ToolRegistry registry,
|
||||
ToolExecutor executor, SessionStore sessionStore,
|
||||
AgentAuditService auditService) {
|
||||
this.settings = settings;
|
||||
this.llmClient = llmClient;
|
||||
this.registry = registry;
|
||||
this.executor = executor;
|
||||
this.sessionStore = sessionStore;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一轮对话。
|
||||
*
|
||||
* @param req 请求(含用户输入、会话 id,或表单提交)
|
||||
* @param userDesc 用户身份描述(姓名/角色/学校),用于写进 system prompt
|
||||
* @param caller 调用者身份(透传给业务模块 + 角色过滤/校验)
|
||||
* @param authorizedModIds 已授权模块号集合(可空 = 不过滤)
|
||||
* @param sink 阶段事件出口(同步传 NOOP,SSE 传真实推送)
|
||||
*/
|
||||
public ChatResponse chat(ChatRequest req, String userDesc, CallerContext caller,
|
||||
Set<Integer> authorizedModIds, EventSink sink) {
|
||||
EventSink out = sink == null ? EventSink.NOOP : sink;
|
||||
|
||||
// 浏览器代执行结果回传:续跑(方案 A)
|
||||
if (req != null && req.getToolResult() != null && !req.getToolResult().isEmpty()) {
|
||||
return resumeBrowser(req, caller, authorizedModIds, out);
|
||||
}
|
||||
|
||||
// 表单提交:短路直达执行
|
||||
if (req != null && req.getFormSubmit() != null
|
||||
&& StringUtils.isNotBlank(req.getFormSubmit().getTool())) {
|
||||
return submitForm(req, userDesc, caller, authorizedModIds, out);
|
||||
}
|
||||
|
||||
if (req == null || StringUtils.isBlank(req.getMessage())) {
|
||||
throw new IllegalArgumentException("请输入内容");
|
||||
}
|
||||
|
||||
Set<AgentRole> roles = caller.getRoles();
|
||||
SessionStore.SessionState session = sessionStore.getOrCreate(req.getSessionId(),
|
||||
caller.userIdAsInt(), caller.schoolIdAsInt(), rolesText(roles));
|
||||
session.setTitle(abbreviate(req.getMessage()));
|
||||
|
||||
ChatResponse resp = new ChatResponse();
|
||||
resp.setSessionId(session.getSessionId());
|
||||
|
||||
List<ChatMessage> messages = session.getMessages();
|
||||
if (messages.isEmpty()) {
|
||||
messages.add(ChatMessage.system(buildSystemPrompt(userDesc, authorizedModIds, roles)));
|
||||
}
|
||||
messages.add(ChatMessage.user(req.getMessage()));
|
||||
|
||||
boolean enableTools = req.getEnableTools() == null || req.getEnableTools();
|
||||
return runLoop(session, messages, resp, caller, authorizedModIds, roles, enableTools, false, out);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型主循环(ReAct):思考 → 需要工具时「挂起等浏览器」或「服务端执行」→ 再思考,直到给出自然语言回复。
|
||||
*
|
||||
* <p>★ 浏览器代执行(方案 A):业务模块的用户态接口只认<b>浏览器 CAS 会话</b>,
|
||||
* 服务端直调必然 {@code code=302}(README 第 11 节四条证据)。开启 {@code agent.browser-exec.enabled}
|
||||
* 且本轮工具都能准备成功时,整批挂起下发给前端,用用户自己的登录态执行后回传续跑({@link #resumeBrowser});
|
||||
* 准备失败(未知工具 / 模块未配置地址 / 缺参 / 越权)则回退服务端直调老路径(行为与历史版本一致)。
|
||||
*/
|
||||
private ChatResponse runLoop(SessionStore.SessionState session, List<ChatMessage> messages,
|
||||
ChatResponse resp, CallerContext caller, Set<Integer> authorizedModIds,
|
||||
Set<AgentRole> roles, boolean enableTools, boolean resuming, EventSink out) {
|
||||
JSONArray tools = enableTools ? registry.toOpenAiTools(authorizedModIds, roles) : null;
|
||||
|
||||
int maxRounds = settings.getLoop().getMaxRounds();
|
||||
int totalPrompt = 0;
|
||||
int totalCompletion = 0;
|
||||
String lastContent = null;
|
||||
|
||||
for (int round = 1; round <= maxRounds; round++) {
|
||||
resp.setRounds(round);
|
||||
String thinking = round == 1
|
||||
? (resuming ? "正在根据查询结果整理回答…" : "正在理解你的需求…")
|
||||
: "正在根据结果继续处理…";
|
||||
out.emit(AgentEvent.of("thinking", thinking));
|
||||
|
||||
ChatResult result;
|
||||
try {
|
||||
result = llmClient.chat(messages, tools);
|
||||
} catch (Exception e) {
|
||||
String msg = e.getMessage() == null ? "大模型调用失败" : e.getMessage();
|
||||
out.emit(AgentEvent.of("error", msg));
|
||||
resp.setReply("抱歉,我这边连接智能服务失败了:" + msg);
|
||||
resp.setWarning(msg);
|
||||
return resp;
|
||||
}
|
||||
|
||||
totalPrompt += nz(result.getPromptTokens());
|
||||
totalCompletion += nz(result.getCompletionTokens());
|
||||
lastContent = StringUtils.isNotBlank(result.getContent()) ? result.getContent() : lastContent;
|
||||
|
||||
if (!result.hasToolCalls()) {
|
||||
// 没有工具调用 → 这一条就是最终回复
|
||||
String reply = StringUtils.isNotBlank(result.getContent())
|
||||
? result.getContent()
|
||||
: "(模型没有返回内容,请换个说法再试一次)";
|
||||
messages.add(new ChatMessage(ChatMessage.ROLE_ASSISTANT, reply));
|
||||
resp.setReply(reply);
|
||||
finish(resp, session, totalPrompt, totalCompletion, out);
|
||||
return resp;
|
||||
}
|
||||
|
||||
// 记录 assistant 的 tool_calls(后续必须以 role=tool 逐条应答,否则上下文非法)
|
||||
ChatMessage assistantMsg = new ChatMessage(ChatMessage.ROLE_ASSISTANT, result.getContent());
|
||||
assistantMsg.setToolCalls(result.getToolCalls());
|
||||
messages.add(assistantMsg);
|
||||
|
||||
// ★ 预检:只要有一个「写操作」缺必填参数,就整体中断并改出表单。
|
||||
// 放在执行任何工具之前,避免消息历史里出现「有 tool_call 却没有对应 tool 结果」的非法上下文。
|
||||
ChatResponse pending = interceptMissing(result.getToolCalls(), messages, caller,
|
||||
session.getSessionId(), out);
|
||||
if (pending != null) {
|
||||
pending.setSessionId(session.getSessionId());
|
||||
pending.setRounds(round);
|
||||
finish(pending, session, totalPrompt, totalCompletion, out);
|
||||
return pending;
|
||||
}
|
||||
|
||||
// ★ 浏览器代执行:整批可准备成功 → 挂起(撤销本轮 assistant(tool_calls),等浏览器回传续跑)
|
||||
if (settings.getBrowserExec().isEnabled()) {
|
||||
List<BrowserToolRequest.BrowserCall> batch =
|
||||
tryPrepareBrowserBatch(result.getToolCalls(), caller);
|
||||
if (batch != null) {
|
||||
messages.remove(messages.size() - 1);
|
||||
|
||||
BrowserToolRequest bt = new BrowserToolRequest();
|
||||
bt.setCalls(batch);
|
||||
String titles = batch.get(0).getTitle()
|
||||
+ (batch.size() > 1 ? " 等 " + batch.size() + " 项查询" : "");
|
||||
bt.setNotice("「" + titles + "」需要用你的登录态获取,正在通过你的浏览器执行…");
|
||||
resp.setBrowserTool(bt);
|
||||
resp.setReply(bt.getNotice());
|
||||
out.emit(AgentEvent.of("browser_tool", bt.getNotice(), bt));
|
||||
log.info("浏览器代执行挂起:session={} 工具={}", session.getSessionId(),
|
||||
batch.stream().map(BrowserToolRequest.BrowserCall::getName)
|
||||
.collect(java.util.stream.Collectors.joining(",")));
|
||||
finish(resp, session, totalPrompt, totalCompletion, out);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
||||
for (ToolCall call : result.getToolCalls()) {
|
||||
String toolName = call.getFunction() == null ? null : call.getFunction().getName();
|
||||
ToolDef def = toolName == null ? null : registry.get(toolName);
|
||||
Map<String, Object> args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments());
|
||||
|
||||
if (def == null) {
|
||||
// 模型幻觉出了不存在的工具:如实告知,让它改正
|
||||
String err = "不存在的工具:" + toolName;
|
||||
out.emit(AgentEvent.of("tool_result", "工具不可用:" + toolName));
|
||||
messages.add(ChatMessage.tool(call.getId(), "{\"success\":false,\"error\":\"" + err + "\"}"));
|
||||
ToolCallTrace trace = new ToolCallTrace();
|
||||
trace.setName(String.valueOf(toolName));
|
||||
trace.setTitle("未知工具");
|
||||
trace.setSuccess(false);
|
||||
trace.setError(err);
|
||||
resp.getToolCalls().add(trace);
|
||||
continue;
|
||||
}
|
||||
|
||||
out.emit(AgentEvent.of("tool_call", "正在执行:" + def.getTitle(),
|
||||
payloadOf(def, args)));
|
||||
|
||||
ToolResult tr = executor.execute(def, args, caller);
|
||||
auditService.record(session.getSessionId(), caller, def, args, tr);
|
||||
|
||||
messages.add(ChatMessage.tool(call.getId(), tr.getContent()));
|
||||
|
||||
ToolCallTrace trace = buildTrace(def, args, tr);
|
||||
resp.getToolCalls().add(trace);
|
||||
|
||||
if (!def.isReadOnly()) {
|
||||
resp.setWroteSomething(true);
|
||||
if (StringUtils.isNotBlank(def.getLandingPath())) {
|
||||
resp.setLandingPath(def.getLandingPath());
|
||||
}
|
||||
}
|
||||
|
||||
out.emit(AgentEvent.of("tool_result",
|
||||
def.getTitle() + (tr.isSuccess() ? " 完成" : " 失败:" + tr.getError()),
|
||||
payloadOf(def, args)));
|
||||
|
||||
log.info("工具 {} 执行 {} 耗时 {}ms", def.getName(),
|
||||
tr.isSuccess() ? "成功" : "失败(" + tr.getError() + ")", tr.getCostMs());
|
||||
}
|
||||
// 继续下一轮,让模型基于工具结果生成自然语言回复
|
||||
}
|
||||
|
||||
// 超过最大轮数:把最后一次内容兜底返回,避免"空转"
|
||||
String reply = StringUtils.isNotBlank(lastContent)
|
||||
? lastContent
|
||||
: "这个需求需要多步操作,我已经执行到这里,建议你到对应页面确认一下结果。";
|
||||
messages.add(new ChatMessage(ChatMessage.ROLE_ASSISTANT, reply));
|
||||
resp.setReply(reply);
|
||||
resp.setWarning("已达到单轮最大处理步数");
|
||||
finish(resp, session, totalPrompt, totalCompletion, out);
|
||||
return resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试把本轮所有工具调用准备成一个「浏览器代执行」批次。
|
||||
*
|
||||
* @return 批次;null = 无法整批代执行(存在未知工具 / 模块未配置地址 / 参数不合法 / 越权),
|
||||
* 调用方回退服务端直调老路径 —— 保证任何情况下消息历史的 tool_call/tool 结果配对完整。
|
||||
*/
|
||||
private List<BrowserToolRequest.BrowserCall> tryPrepareBrowserBatch(List<ToolCall> calls,
|
||||
CallerContext caller) {
|
||||
List<BrowserToolRequest.BrowserCall> batch = new ArrayList<>();
|
||||
for (ToolCall call : calls) {
|
||||
String toolName = call.getFunction() == null ? null : call.getFunction().getName();
|
||||
ToolDef def = registry.get(toolName);
|
||||
if (def == null || !settings.getModules().containsKey(def.getModule())) {
|
||||
return null;
|
||||
}
|
||||
// 越权的调用不进批次(浏览器白跑一趟没有意义),交回老路径立即拒绝
|
||||
if (executor.checkAccess(def, caller) != null) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments());
|
||||
try {
|
||||
batch.add(executor.prepareBrowserCall(def, args));
|
||||
} catch (Exception e) {
|
||||
log.info("代执行批次准备失败({}),回退服务端执行:{}", def.getName(), e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器代执行的续跑:前端已用<b>用户自己的登录态</b>调完模块接口并带回原始响应。
|
||||
*
|
||||
* <p>服务端重新查注册表解析结果(角色校验、{@code code=302} 识别等与服务端执行完全同源,
|
||||
* <b>不信任</b>前端回传的任何路径信息),构造合法的 {@code assistant(tool_calls) + tool} 消息
|
||||
* 喂回模型继续 —— 模型既可以整理出最终回答,也可以发起下一批工具调用(链式查询)。
|
||||
*/
|
||||
private ChatResponse resumeBrowser(ChatRequest req, CallerContext caller,
|
||||
Set<Integer> authorizedModIds, EventSink out) {
|
||||
Set<AgentRole> roles = caller.getRoles();
|
||||
SessionStore.SessionState session = sessionStore.getOrCreate(req.getSessionId(),
|
||||
caller.userIdAsInt(), caller.schoolIdAsInt(), rolesText(roles));
|
||||
|
||||
ChatResponse resp = new ChatResponse();
|
||||
resp.setSessionId(session.getSessionId());
|
||||
|
||||
List<ChatMessage> messages = session.getMessages();
|
||||
if (messages.isEmpty()) {
|
||||
resp.setReply("会话已过期,请重新说一次你的需求。");
|
||||
resp.setWarning("browser-resume:会话没有历史消息(服务重启或已过期)");
|
||||
finish(resp, session, 0, 0, out);
|
||||
return resp;
|
||||
}
|
||||
|
||||
String idPrefix = "br_" + System.currentTimeMillis() + "_";
|
||||
ChatMessage assistant = new ChatMessage(ChatMessage.ROLE_ASSISTANT, null);
|
||||
List<ToolCall> calls = new ArrayList<>();
|
||||
List<String> contents = new ArrayList<>();
|
||||
int idx = 0;
|
||||
|
||||
for (ChatRequest.ToolResultItem item : req.getToolResult()) {
|
||||
Map<String, Object> args = item.getArguments() == null
|
||||
? new LinkedHashMap<>() : new LinkedHashMap<>(item.getArguments());
|
||||
ToolDef def = registry.get(item.getName());
|
||||
|
||||
ToolResult tr;
|
||||
if (def == null) {
|
||||
tr = unknownToolResult(item.getName());
|
||||
} else {
|
||||
ToolResult denied = executor.checkAccess(def, caller);
|
||||
if (denied != null) {
|
||||
tr = denied;
|
||||
} else if (StringUtils.isNotBlank(item.getError())) {
|
||||
tr = ToolResult.fail(def, "浏览器调用未完成:" + item.getError(), 0);
|
||||
} else {
|
||||
tr = executor.parseBrowserResponse(def, item.getStatus(), item.getBody(), 0);
|
||||
}
|
||||
auditService.record(session.getSessionId(), caller, def, args, tr);
|
||||
resp.getToolCalls().add(buildTrace(def, args, tr));
|
||||
if (!def.isReadOnly()) {
|
||||
resp.setWroteSomething(true);
|
||||
if (StringUtils.isNotBlank(def.getLandingPath())) {
|
||||
resp.setLandingPath(def.getLandingPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.emit(AgentEvent.of("tool_result",
|
||||
(def == null ? item.getName() : def.getTitle())
|
||||
+ (tr.isSuccess() ? " 完成" : " 失败:" + tr.getError())));
|
||||
log.info("浏览器代执行回传 {} {} ", item.getName(), tr.isSuccess() ? "成功" : "失败");
|
||||
|
||||
ToolCall tc = new ToolCall();
|
||||
tc.setId(idPrefix + idx);
|
||||
ToolCall.Function fn = new ToolCall.Function();
|
||||
fn.setName(def != null ? def.getName() : item.getName());
|
||||
fn.setArguments(JSON.toJSONString(args));
|
||||
tc.setFunction(fn);
|
||||
calls.add(tc);
|
||||
contents.add(tr.getContent());
|
||||
idx++;
|
||||
}
|
||||
|
||||
assistant.setToolCalls(calls);
|
||||
messages.add(assistant);
|
||||
for (int i = 0; i < contents.size(); i++) {
|
||||
messages.add(ChatMessage.tool(idPrefix + i, contents.get(i)));
|
||||
}
|
||||
|
||||
return runLoop(session, messages, resp, caller, authorizedModIds, roles, true, true, out);
|
||||
}
|
||||
|
||||
/** 前端回传了未注册的工具名(正常不会发生):构造统一的失败结果回填给模型 */
|
||||
private ToolResult unknownToolResult(String name) {
|
||||
ToolResult tr = new ToolResult();
|
||||
tr.setToolName(name);
|
||||
tr.setTitle("未知工具");
|
||||
tr.setSuccess(false);
|
||||
tr.setError("不存在的工具:" + name);
|
||||
tr.setContent("{\"success\":false,\"error\":\"不存在的工具:" + name + "\"}");
|
||||
return tr;
|
||||
}
|
||||
|
||||
/* ==================== 表单:缺参拦截 与 直提交 ==================== */
|
||||
|
||||
/**
|
||||
* 预检本轮的写操作工具:若有必填参数缺失,则撤销刚加入的 assistant 消息并返回一张补全表单。
|
||||
*
|
||||
* @return 需要用户补全信息时返回响应(含 form);一切正常返回 null
|
||||
*/
|
||||
private ChatResponse interceptMissing(List<ToolCall> calls, List<ChatMessage> messages,
|
||||
CallerContext caller, String sessionId, EventSink out) {
|
||||
for (ToolCall call : calls) {
|
||||
String toolName = call.getFunction() == null ? null : call.getFunction().getName();
|
||||
ToolDef def = toolName == null ? null : registry.get(toolName);
|
||||
if (def == null || def.isReadOnly()) {
|
||||
continue;
|
||||
}
|
||||
// 越权的写操作不走表单(表单填了也执行不了),交由执行期统一拒绝并告知
|
||||
if (settings.isEnforceRolePermission() && !def.allowsAny(caller.getRoles())) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments());
|
||||
List<ToolParam> missing = missingRequired(def, args);
|
||||
if (missing.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 撤销本轮的 assistant(tool_calls),保持历史自洽(用户可能不填表、转而说别的)
|
||||
if (!messages.isEmpty()) {
|
||||
messages.remove(messages.size() - 1);
|
||||
}
|
||||
|
||||
FormRequest form = buildForm(def, args, missing, caller);
|
||||
ChatResponse resp = new ChatResponse();
|
||||
resp.setForm(form);
|
||||
resp.setReply(askText(def, missing));
|
||||
|
||||
out.emit(AgentEvent.of("form", "需要补充信息:" + String.join("、", form.getMissing()),
|
||||
payloadOf(def, args)));
|
||||
return resp;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单直提交:用户已填好参数 → 直接执行工具 → 再由模型把结果说成自然语言。
|
||||
*/
|
||||
private ChatResponse submitForm(ChatRequest req, String userDesc, CallerContext caller,
|
||||
Set<Integer> authorizedModIds, EventSink out) {
|
||||
ChatRequest.FormSubmit fs = req.getFormSubmit();
|
||||
Set<AgentRole> roles = caller.getRoles();
|
||||
SessionStore.SessionState session = sessionStore.getOrCreate(req.getSessionId(),
|
||||
caller.userIdAsInt(), caller.schoolIdAsInt(), rolesText(roles));
|
||||
|
||||
ChatResponse resp = new ChatResponse();
|
||||
resp.setSessionId(session.getSessionId());
|
||||
|
||||
ToolDef def = registry.get(fs.getTool());
|
||||
if (def == null) {
|
||||
resp.setReply("抱歉,这个操作暂时不可用。");
|
||||
resp.setWarning("未注册的工具:" + fs.getTool());
|
||||
return resp;
|
||||
}
|
||||
// 角色门禁:提交时再校验一次(模型侧过滤可被绕过,这里是硬约束)
|
||||
if (!def.allowsAny(roles)) {
|
||||
resp.setReply("抱歉,当前身份无权执行「" + def.getTitle() + "」(该操作仅限:" + def.roleLabel() + ")。");
|
||||
resp.setWarning("越权操作已被拒绝:" + def.getName());
|
||||
return resp;
|
||||
}
|
||||
|
||||
Map<String, Object> args = fs.getArgs() == null
|
||||
? new LinkedHashMap<>() : new LinkedHashMap<>(fs.getArgs());
|
||||
|
||||
// 兜底:表单若有漏填(前端校验被绕过),仍然回到表单而不是带着残缺参数去打接口
|
||||
List<ToolParam> missing = missingRequired(def, args);
|
||||
if (!missing.isEmpty()) {
|
||||
resp.setForm(buildForm(def, args, missing, caller));
|
||||
resp.setReply(askText(def, missing));
|
||||
return resp;
|
||||
}
|
||||
|
||||
List<ChatMessage> messages = session.getMessages();
|
||||
if (messages.isEmpty()) {
|
||||
messages.add(ChatMessage.system(buildSystemPrompt(userDesc, authorizedModIds, roles)));
|
||||
}
|
||||
// 把"用户填表提交了什么"写入历史,让后续对话有上下文
|
||||
messages.add(ChatMessage.user(renderSubmission(def, args)));
|
||||
|
||||
out.emit(AgentEvent.of("tool_call", "正在执行:" + def.getTitle(), payloadOf(def, args)));
|
||||
|
||||
// ★ 浏览器代执行(方案 A):模块接口只认浏览器会话,服务端提交必然 302。
|
||||
// 表单提交同样挂起给前端用用户登录态执行,结果回传后由 resumeBrowser 续跑;
|
||||
// 准备失败(缺参/模块未配置)时回退服务端直调老路径。
|
||||
if (settings.getBrowserExec().isEnabled()
|
||||
&& settings.getModules().containsKey(def.getModule())) {
|
||||
try {
|
||||
BrowserToolRequest.BrowserCall bc = executor.prepareBrowserCall(def, args);
|
||||
BrowserToolRequest bt = new BrowserToolRequest();
|
||||
bt.setCalls(new ArrayList<>(Collections.singletonList(bc)));
|
||||
bt.setNotice("「" + def.getTitle() + "」需要用你的登录态提交,正在通过你的浏览器执行…");
|
||||
resp.setBrowserTool(bt);
|
||||
resp.setReply(bt.getNotice());
|
||||
out.emit(AgentEvent.of("browser_tool", bt.getNotice(), bt));
|
||||
log.info("表单提交转浏览器代执行:session={} 工具={}", session.getSessionId(), def.getName());
|
||||
finish(resp, session, 0, 0, out);
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.warn("表单代执行准备失败,回退服务端执行:{} - {}", def.getName(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
ToolResult tr = executor.execute(def, args, caller);
|
||||
auditService.record(session.getSessionId(), caller, def, args, tr);
|
||||
|
||||
// 构造合法的 assistant(tool_calls) + tool 结果,模型据此组织语言
|
||||
String callId = "form_" + System.currentTimeMillis();
|
||||
ToolCall tc = new ToolCall();
|
||||
tc.setId(callId);
|
||||
ToolCall.Function fn = new ToolCall.Function();
|
||||
fn.setName(def.getName());
|
||||
fn.setArguments(JSON.toJSONString(args));
|
||||
tc.setFunction(fn);
|
||||
ChatMessage assistant = new ChatMessage(ChatMessage.ROLE_ASSISTANT, null);
|
||||
assistant.setToolCalls(Collections.singletonList(tc));
|
||||
messages.add(assistant);
|
||||
messages.add(ChatMessage.tool(callId, tr.getContent()));
|
||||
|
||||
resp.getToolCalls().add(buildTrace(def, args, tr));
|
||||
if (!def.isReadOnly()) {
|
||||
resp.setWroteSomething(true);
|
||||
if (StringUtils.isNotBlank(def.getLandingPath())) {
|
||||
resp.setLandingPath(def.getLandingPath());
|
||||
}
|
||||
}
|
||||
out.emit(AgentEvent.of("tool_result",
|
||||
def.getTitle() + (tr.isSuccess() ? " 完成" : " 失败:" + tr.getError()),
|
||||
payloadOf(def, args)));
|
||||
log.info("表单提交执行 {} {} 耗时 {}ms", def.getName(), tr.isSuccess() ? "成功" : "失败", tr.getCostMs());
|
||||
|
||||
String reply;
|
||||
int promptTokens = 0;
|
||||
int completionTokens = 0;
|
||||
try {
|
||||
ChatResult r = llmClient.chat(messages, null);
|
||||
promptTokens = nz(r.getPromptTokens());
|
||||
completionTokens = nz(r.getCompletionTokens());
|
||||
reply = StringUtils.isNotBlank(r.getContent()) ? r.getContent() : fallbackReply(def, tr);
|
||||
} catch (Exception e) {
|
||||
log.warn("表单提交后生成回复失败,改用兜底话术:{}", e.toString());
|
||||
reply = fallbackReply(def, tr);
|
||||
}
|
||||
messages.add(new ChatMessage(ChatMessage.ROLE_ASSISTANT, reply));
|
||||
resp.setReply(reply);
|
||||
finish(resp, session, promptTokens, completionTokens, out);
|
||||
return resp;
|
||||
}
|
||||
|
||||
/** 找出缺失的必填参数(fixed 固定参数视为已提供) */
|
||||
private List<ToolParam> missingRequired(ToolDef def, Map<String, Object> args) {
|
||||
Map<String, Object> effective = new LinkedHashMap<>();
|
||||
if (def.getFixed() != null) {
|
||||
effective.putAll(def.getFixed());
|
||||
}
|
||||
if (args != null) {
|
||||
effective.putAll(args);
|
||||
}
|
||||
|
||||
List<ToolParam> missing = new ArrayList<>();
|
||||
for (ToolParam p : def.getParams()) {
|
||||
if (!p.isRequired() || p.isHidden()) {
|
||||
continue;
|
||||
}
|
||||
Object v = effective.get(p.getName());
|
||||
if (v == null || (v instanceof String && StringUtils.isBlank((String) v))) {
|
||||
missing.add(p);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/** 由工具定义生成补全表单 */
|
||||
private FormRequest buildForm(ToolDef def, Map<String, Object> args, List<ToolParam> missing,
|
||||
CallerContext caller) {
|
||||
FormRequest form = new FormRequest();
|
||||
form.setTool(def.getName());
|
||||
form.setTitle(def.getTitle());
|
||||
form.setModule(def.getModule());
|
||||
form.setWrite(!def.isReadOnly());
|
||||
form.setLandingPath(def.getLandingPath());
|
||||
|
||||
// 本次构建内的字典缓存:同一字典只请求一次(字段下拉与已识别回显共用)
|
||||
Map<String, List<Map<String, String>>> dictCache = new LinkedHashMap<>();
|
||||
|
||||
List<FormField> fields = new ArrayList<>();
|
||||
List<String> missingLabels = new ArrayList<>();
|
||||
for (ToolParam p : missing) {
|
||||
FormField f = p.toFormField();
|
||||
// 字典类字段(如请假类型):先跑一次它的来源工具,把可选项直接摆到下拉框里
|
||||
// 判据是「声明了来源」而不是「当前没有选项」—— 参数可能已带内置兜底,
|
||||
// 实时字典取到就覆盖兜底,取不到则保留兜底(下拉框不会退化成数字框)。
|
||||
if (StringUtils.isNotBlank(p.getOptionsFrom())) {
|
||||
List<Map<String, String>> opts = dictOptions(p.getOptionsFrom(), caller, dictCache);
|
||||
if (!opts.isEmpty()) {
|
||||
f.setOptions(opts);
|
||||
f.setWidget("select");
|
||||
f.setOptionsFallback(false);
|
||||
}
|
||||
}
|
||||
fields.add(f);
|
||||
missingLabels.add(p.labelOf());
|
||||
}
|
||||
form.setFields(fields);
|
||||
form.setMissing(missingLabels);
|
||||
|
||||
// 已从对话里确定的参数 → 表单里只读回显(值翻译成中文,用户能一眼核对)
|
||||
Map<String, Object> preset = new LinkedHashMap<>();
|
||||
List<Map<String, String>> recognized = new ArrayList<>();
|
||||
if (args != null) {
|
||||
for (Map.Entry<String, Object> e : args.entrySet()) {
|
||||
Object v = e.getValue();
|
||||
if (v == null || (v instanceof String && StringUtils.isBlank((String) v))) {
|
||||
continue;
|
||||
}
|
||||
preset.put(e.getKey(), v);
|
||||
|
||||
ToolParam p = findParam(def, e.getKey());
|
||||
Map<String, String> row = new LinkedHashMap<>();
|
||||
row.put("name", e.getKey());
|
||||
row.put("label", p != null ? p.labelOf() : e.getKey());
|
||||
row.put("text", humanize(p, v, caller, dictCache));
|
||||
recognized.add(row);
|
||||
}
|
||||
}
|
||||
form.setPreset(preset);
|
||||
form.setRecognized(recognized);
|
||||
|
||||
form.setDescription("补齐下面的信息,我就直接帮你执行「" + def.getTitle() + "」。");
|
||||
form.setHint("带 * 为必填;时间类字段请选到具体时刻,我会按你选的时间提交。");
|
||||
return form;
|
||||
}
|
||||
|
||||
private ToolParam findParam(ToolDef def, String name) {
|
||||
for (ToolParam p : def.getParams()) {
|
||||
if (name.equals(p.getName())) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 参数值 → 可读文本:有静态枚举或动态字典时翻译成中文 */
|
||||
private String humanize(ToolParam p, Object value, CallerContext caller,
|
||||
Map<String, List<Map<String, String>>> cache) {
|
||||
String text = String.valueOf(value);
|
||||
if (p == null) {
|
||||
return text;
|
||||
}
|
||||
if (p.getEnumValues() != null && !p.getEnumValues().isEmpty()) {
|
||||
int idx = p.getEnumValues().indexOf(text);
|
||||
if (idx >= 0 && p.getEnumLabels() != null && idx < p.getEnumLabels().size()) {
|
||||
return p.getEnumLabels().get(idx);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
if (StringUtils.isNotBlank(p.getOptionsFrom())) {
|
||||
for (Map<String, String> o : dictOptions(p.getOptionsFrom(), caller, cache)) {
|
||||
if (text.equals(o.get("value"))) {
|
||||
return o.get("label");
|
||||
}
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** 取字典选项(带缓存;取不到返回空列表,调用方据此退化为普通输入框) */
|
||||
private List<Map<String, String>> dictOptions(String toolName, CallerContext caller,
|
||||
Map<String, List<Map<String, String>>> cache) {
|
||||
if (cache.containsKey(toolName)) {
|
||||
return cache.get(toolName);
|
||||
}
|
||||
List<Map<String, String>> opts = fetchDictOptions(toolName, caller);
|
||||
cache.put(toolName, opts == null ? new ArrayList<>() : opts);
|
||||
return cache.get(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行字典工具并尽力解析成 [{value,label}]。
|
||||
* <p>解析失败(结构不认识、接口不通)一律返回 null,前端退化成普通输入框,不影响主流程。
|
||||
*/
|
||||
private List<Map<String, String>> fetchDictOptions(String toolName, CallerContext caller) {
|
||||
try {
|
||||
ToolDef dict = registry.get(toolName);
|
||||
if (dict == null || !dict.isReadOnly()) {
|
||||
return null;
|
||||
}
|
||||
// 字典工具也受角色约束:越权则取不到选项,退化为普通输入框
|
||||
if (settings.isEnforceRolePermission() && !dict.allowsAny(caller.getRoles())) {
|
||||
return null;
|
||||
}
|
||||
ToolResult tr = executor.execute(dict, new LinkedHashMap<>(), caller);
|
||||
if (!tr.isSuccess()) {
|
||||
return null;
|
||||
}
|
||||
JSONArray arr = findArray(JSON.toJSON(tr.getData()), 0);
|
||||
if (arr == null || arr.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<Map<String, String>> options = new ArrayList<>();
|
||||
for (int i = 0; i < arr.size(); i++) {
|
||||
Object item = arr.get(i);
|
||||
if (!(item instanceof JSONObject)) {
|
||||
continue;
|
||||
}
|
||||
JSONObject o = (JSONObject) item;
|
||||
String value = firstOf(o, "id", "value", "code", "dictId", "typeId", "key");
|
||||
if (StringUtils.isBlank(value)) {
|
||||
continue;
|
||||
}
|
||||
String label = firstOf(o, "name", "label", "text", "title", "dictName", "value");
|
||||
Map<String, String> one = new LinkedHashMap<>();
|
||||
one.put("value", value);
|
||||
one.put("label", StringUtils.isBlank(label) ? value : label);
|
||||
options.add(one);
|
||||
}
|
||||
return options.isEmpty() ? null : options;
|
||||
} catch (Exception e) {
|
||||
log.warn("拉取字典选项失败 {}:{}", toolName, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String firstOf(JSONObject o, String... keys) {
|
||||
for (String k : keys) {
|
||||
Object v = o.get(k);
|
||||
if (v != null && StringUtils.isNotBlank(String.valueOf(v))) {
|
||||
return String.valueOf(v);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 递归找第一个数组(限深,避免异常结构下深度遍历) */
|
||||
private JSONArray findArray(Object node, int depth) {
|
||||
if (node == null || depth > 3) {
|
||||
return null;
|
||||
}
|
||||
if (node instanceof JSONArray) {
|
||||
return (JSONArray) node;
|
||||
}
|
||||
if (node instanceof JSONObject) {
|
||||
JSONObject o = (JSONObject) node;
|
||||
for (String k : o.keySet()) {
|
||||
JSONArray a = findArray(o.get(k), depth + 1);
|
||||
if (a != null) {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 缺参时的引导话术(表单之外的文字说明) */
|
||||
private String askText(ToolDef def, List<ToolParam> missing) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
for (ToolParam p : missing) {
|
||||
String l = p.labelOf();
|
||||
if (StringUtils.isNotBlank(p.getExample())) {
|
||||
l = l + "(格式如 " + p.getExample() + ")";
|
||||
}
|
||||
parts.add(l);
|
||||
}
|
||||
return "要完成「" + def.getTitle() + "」,还差这几项:" + String.join("、", parts)
|
||||
+ "。\n可以直接在下面的表单里填,填好点「提交」就行。";
|
||||
}
|
||||
|
||||
/** 把表单提交渲染成一条用户消息,写入会话历史 */
|
||||
private String renderSubmission(ToolDef def, Map<String, Object> args) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("我已在表单里填好并提交「").append(def.getTitle()).append("」,参数为:");
|
||||
for (Map.Entry<String, Object> e : args.entrySet()) {
|
||||
sb.append("\n- ").append(e.getKey()).append(" = ").append(String.valueOf(e.getValue()));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private ToolCallTrace buildTrace(ToolDef def, Map<String, Object> args, ToolResult tr) {
|
||||
ToolCallTrace trace = new ToolCallTrace();
|
||||
trace.setName(def.getName());
|
||||
trace.setTitle(def.getTitle());
|
||||
trace.setModule(def.getModule());
|
||||
trace.setArguments(args);
|
||||
trace.setSuccess(tr.isSuccess());
|
||||
trace.setError(tr.getError());
|
||||
trace.setWrite(!def.isReadOnly());
|
||||
trace.setLandingPath(def.getLandingPath());
|
||||
trace.setCostMs(tr.getCostMs());
|
||||
return trace;
|
||||
}
|
||||
|
||||
/** 大模型不可用时的兜底话术(表单提交场景,结果已经确定) */
|
||||
private String fallbackReply(ToolDef def, ToolResult tr) {
|
||||
if (tr.isSuccess()) {
|
||||
return "「" + def.getTitle() + "」已执行完成。";
|
||||
}
|
||||
return "「" + def.getTitle() + "」执行失败:" + (tr.getError() == null ? "未知原因" : tr.getError());
|
||||
}
|
||||
|
||||
private void finish(ChatResponse resp, SessionStore.SessionState session,
|
||||
int promptTokens, int completionTokens, EventSink out) {
|
||||
resp.setPromptTokens(promptTokens);
|
||||
resp.setCompletionTokens(completionTokens);
|
||||
sessionStore.trim(session);
|
||||
sessionStore.persist(session);
|
||||
out.emit(AgentEvent.of("message", resp.getReply()));
|
||||
out.emit(AgentEvent.of("done", "ok"));
|
||||
}
|
||||
|
||||
private int nz(Integer v) {
|
||||
return v == null ? 0 : v;
|
||||
}
|
||||
|
||||
/** 容错解析模型给的参数 JSON(模型经常返回非法 JSON 或空串) */
|
||||
private Map<String, Object> parseArgs(String arguments) {
|
||||
if (StringUtils.isBlank(arguments)) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
try {
|
||||
JSONObject obj = JSON.parseObject(arguments);
|
||||
if (obj == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
for (String k : obj.keySet()) {
|
||||
map.put(k, obj.get(k));
|
||||
}
|
||||
return map;
|
||||
} catch (Exception e) {
|
||||
log.warn("工具参数不是合法 JSON,已忽略:{}", arguments);
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private Object payloadOf(ToolDef def, Map<String, Object> args) {
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("name", def.getName());
|
||||
p.put("title", def.getTitle());
|
||||
p.put("module", def.getModule());
|
||||
p.put("arguments", args);
|
||||
p.put("write", !def.isReadOnly());
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 构造系统提示词:身份 + 角色 + 能力清单 + 行为准则 */
|
||||
private String buildSystemPrompt(String userDesc, Set<Integer> authorizedModIds, Set<AgentRole> roles) {
|
||||
String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
sb.append("你是「云海星通校园平台」的智能助手,帮助用户在系统里直接完成操作,而不是只做问答。\n\n");
|
||||
|
||||
sb.append("【当前用户】").append(StringUtils.isBlank(userDesc) ? "未知用户" : userDesc).append("\n");
|
||||
sb.append("【当前时间】").append(now).append("(星期").append(weekday()).append(")\n\n");
|
||||
|
||||
sb.append("【可用能力】以下是你当前身份可以调用的业务能力。调用后系统会把真实结果给你,")
|
||||
.append("你必须依据结果回答,绝不能编造成功或数据:\n");
|
||||
|
||||
Map<String, List<ToolDef>> grouped = registry.groupByModule(authorizedModIds, roles);
|
||||
for (Map.Entry<String, List<ToolDef>> e : grouped.entrySet()) {
|
||||
sb.append("- ").append(moduleLabel(e.getKey())).append(":");
|
||||
List<String> names = new ArrayList<>();
|
||||
for (ToolDef d : e.getValue()) {
|
||||
names.add(d.getTitle());
|
||||
}
|
||||
sb.append(String.join("、", names)).append("\n");
|
||||
}
|
||||
|
||||
sb.append("\n【行为准则】\n");
|
||||
sb.append("1. 用户表达一个操作意图(如“我要请假”“帮我看看作业”)时,直接调用对应工具完成,不要只给操作指引。\n");
|
||||
|
||||
sb.append("2. ★ 写操作(提交/审批/发布/撤销)缺参数时,**不要用文字反复追问**:\n")
|
||||
.append(" 请照常调用该工具,把能从用户话里确定的参数填上,**不确定的字段直接省略**。\n")
|
||||
.append(" 系统会自动弹出带中文标签、格式示例和下拉选项的表单让用户补齐,比口头追问准确得多。\n");
|
||||
|
||||
sb.append("3. 时间类参数统一用 yyyy-MM-dd HH:mm:ss;用户说“明天”“下周一”这类相对时间,")
|
||||
.append("请基于上面的当前时间换算成具体日期时间后再填,并在回答里说明你换算成的日期。\n");
|
||||
|
||||
sb.append("4. 一次可以调用多个工具(例如先查列表再取详情),但不要重复调用同一个工具做同样的事。\n");
|
||||
sb.append("5. 工具返回 success=false 时,把失败原因用通俗的话告诉用户,并给出下一步建议;不要假装成功。\n");
|
||||
sb.append("6. 回答用简体中文,简洁直白,先给结论再给必要细节;不要输出 JSON 原文、不要暴露内部工具名与技术细节。\n");
|
||||
sb.append("7. 涉及审批、删除、批量操作等有副作用的动作,执行后要明确告知“已完成什么、影响范围”。\n");
|
||||
|
||||
sb.append("8. ★ 权限边界:你**只能**调用上面列出的能力。如果用户请求需要其它身份(例如学生要求“审批请假”“发布公告”,")
|
||||
.append("或教师要求“提交学生的请假申请”),要如实说明“该操作需要 XX 身份”,不要试图越权,也不要承诺你会去办。\n");
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String weekday() {
|
||||
String[] w = {"日", "一", "二", "三", "四", "五", "六"};
|
||||
java.util.Calendar c = java.util.Calendar.getInstance();
|
||||
return w[c.get(java.util.Calendar.DAY_OF_WEEK) - 1];
|
||||
}
|
||||
|
||||
/** 角色集合 → 可读描述(落库快照用) */
|
||||
private String rolesText(Set<AgentRole> roles) {
|
||||
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();
|
||||
}
|
||||
|
||||
private String abbreviate(String s) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
String t = s.trim().replaceAll("\\s+", " ");
|
||||
return t.length() <= 60 ? t : t.substring(0, 60);
|
||||
}
|
||||
|
||||
private String moduleLabel(String module) {
|
||||
switch (module) {
|
||||
case "homeleave": return "学生请假";
|
||||
case "campusnotice": return "校园公告";
|
||||
case "dormnotice": return "宿舍通知";
|
||||
case "familyactivity": return "家校活动";
|
||||
case "coursehomework": return "课程作业";
|
||||
case "resourcelibrary": return "资源库";
|
||||
case "profession": return "教师专业发展";
|
||||
default: return module;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.yunhai.agent.service;
|
||||
|
||||
/**
|
||||
* 阶段事件出口:同步接口走空实现,SSE 接口把事件写给浏览器。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface EventSink {
|
||||
|
||||
void emit(AgentEvent event);
|
||||
|
||||
/** 什么都不做的实现 */
|
||||
EventSink NOOP = event -> {
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.yunhai.agent.service;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.yunhai.agent.config.AgentSettings;
|
||||
import com.yunhai.agent.entity.AgentMessage;
|
||||
import com.yunhai.agent.entity.AgentSession;
|
||||
import com.yunhai.agent.llm.dto.ChatMessage;
|
||||
import com.yunhai.agent.llm.dto.ToolCall;
|
||||
import com.yunhai.agent.repository.AgentMessageRepository;
|
||||
import com.yunhai.agent.repository.AgentSessionRepository;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 会话上下文存储(JPA 落库版)。
|
||||
*
|
||||
* <p>会话本体({@link AgentSession})与消息({@link AgentMessage})持久化到 MySQL,
|
||||
* 服务重启后可续接;内存里只做「当前请求内的加载态」。
|
||||
*
|
||||
* <p>安全:会话与 {@code userId} 绑定,跨用户读同一 sessionId 会被拒绝并重建。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SessionStore {
|
||||
|
||||
private final AgentSettings settings;
|
||||
private final AgentSessionRepository sessionRepo;
|
||||
private final AgentMessageRepository messageRepo;
|
||||
|
||||
/** 会话级轻量互斥,避免同一会话并发写导致消息全量重写互相覆盖 */
|
||||
private final Map<String, Object> locks = new ConcurrentHashMap<>();
|
||||
|
||||
public SessionStore(AgentSettings settings,
|
||||
AgentSessionRepository sessionRepo,
|
||||
AgentMessageRepository messageRepo) {
|
||||
this.settings = settings;
|
||||
this.sessionRepo = sessionRepo;
|
||||
this.messageRepo = messageRepo;
|
||||
}
|
||||
|
||||
/** 取会话;不存在或不属于该用户则新建(并落库会话行) */
|
||||
@Transactional
|
||||
public SessionState getOrCreate(String sessionId, Integer userId, Integer schId, String roleSnapshot) {
|
||||
if (StringUtils.isNotBlank(sessionId)) {
|
||||
Optional<AgentSession> exist = sessionRepo.findBySessionId(sessionId);
|
||||
if (exist.isPresent() && sameUser(exist.get(), userId)) {
|
||||
sessionRepo.touch(sessionId, LocalDateTime.now());
|
||||
return load(exist.get());
|
||||
}
|
||||
if (exist.isPresent()) {
|
||||
log.warn("会话 {} 归属不匹配(owner={} 请求者={}),已重建",
|
||||
sessionId, exist.get().getUserId(), userId);
|
||||
}
|
||||
}
|
||||
AgentSession s = new AgentSession();
|
||||
s.setSessionId(UUID.randomUUID().toString().replace("-", ""));
|
||||
s.setUserId(userId);
|
||||
s.setSchId(schId);
|
||||
s.setRoleSnapshot(roleSnapshot);
|
||||
s.setLastAccess(LocalDateTime.now());
|
||||
sessionRepo.save(s);
|
||||
|
||||
SessionState st = new SessionState();
|
||||
st.setSessionId(s.getSessionId());
|
||||
st.setUserId(userId);
|
||||
st.setSchId(schId);
|
||||
st.setMessages(new ArrayList<>());
|
||||
return st;
|
||||
}
|
||||
|
||||
private SessionState load(AgentSession s) {
|
||||
SessionState st = new SessionState();
|
||||
st.setSessionId(s.getSessionId());
|
||||
st.setUserId(s.getUserId());
|
||||
st.setSchId(s.getSchId());
|
||||
List<ChatMessage> msgs = new ArrayList<>();
|
||||
for (AgentMessage m : messageRepo.findBySessionIdOrderBySeqNoAsc(s.getSessionId())) {
|
||||
ChatMessage cm = new ChatMessage(m.getRole(), m.getContent());
|
||||
if (StringUtils.isNotBlank(m.getToolCalls())) {
|
||||
cm.setToolCalls(JSON.parseArray(m.getToolCalls(), ToolCall.class));
|
||||
}
|
||||
cm.setToolCallId(m.getToolCallId());
|
||||
msgs.add(cm);
|
||||
}
|
||||
st.setMessages(msgs);
|
||||
return st;
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化会话消息(全量重写该会话的消息表 + 刷新访问时间与标题)。
|
||||
* <p>消息量小(≤ historyMaxMessages),全量重写比增量对账更简单可靠,
|
||||
* 也天然处理了「拦截表单时撤销一条 assistant 消息」这类回退。
|
||||
*/
|
||||
@Transactional
|
||||
public void persist(SessionState state) {
|
||||
if (state == null || StringUtils.isBlank(state.getSessionId())) {
|
||||
return;
|
||||
}
|
||||
String sid = state.getSessionId();
|
||||
synchronized (lockOf(sid)) {
|
||||
messageRepo.deleteBySessionId(sid);
|
||||
int seq = 0;
|
||||
for (ChatMessage m : state.getMessages()) {
|
||||
AgentMessage em = new AgentMessage();
|
||||
em.setSessionId(sid);
|
||||
em.setSeqNo(seq++);
|
||||
em.setRole(m.getRole());
|
||||
em.setContent(m.getContent());
|
||||
em.setToolCallId(m.getToolCallId());
|
||||
if (m.getToolCalls() != null && !m.getToolCalls().isEmpty()) {
|
||||
em.setToolCalls(JSON.toJSONString(m.getToolCalls()));
|
||||
}
|
||||
messageRepo.save(em);
|
||||
}
|
||||
sessionRepo.touch(sid, LocalDateTime.now());
|
||||
// 标题兜底:取首条用户输入
|
||||
if (StringUtils.isNotBlank(state.getTitle())) {
|
||||
sessionRepo.findBySessionId(sid).ifPresent(s -> {
|
||||
if (StringUtils.isBlank(s.getTitle())) {
|
||||
s.setTitle(state.getTitle());
|
||||
sessionRepo.save(s);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void clear(String sessionId) {
|
||||
if (StringUtils.isBlank(sessionId)) {
|
||||
return;
|
||||
}
|
||||
messageRepo.deleteBySessionId(sessionId);
|
||||
sessionRepo.deleteBySessionId(sessionId);
|
||||
locks.remove(sessionId);
|
||||
}
|
||||
|
||||
/** 按保留条数裁剪历史(保留最近 N 条,避免上下文无界增长) */
|
||||
public void trim(SessionState state) {
|
||||
int max = settings.getLoop().getHistoryMaxMessages();
|
||||
List<ChatMessage> msgs = state.getMessages();
|
||||
if (msgs.size() > max) {
|
||||
// 从头部丢弃最老的消息,但要保证不以 role=tool 开头(否则非法)
|
||||
int drop = msgs.size() - max;
|
||||
while (drop < msgs.size() && ChatMessage.ROLE_TOOL.equals(msgs.get(drop).getRole())) {
|
||||
drop++;
|
||||
}
|
||||
state.setMessages(new ArrayList<>(msgs.subList(drop, msgs.size())));
|
||||
}
|
||||
}
|
||||
|
||||
/** 过期清理:每 10 分钟扫一次,删除空闲超时的会话及其消息 */
|
||||
@Scheduled(fixedDelay = 600_000, initialDelay = 600_000)
|
||||
@Transactional
|
||||
public void evictExpired() {
|
||||
long ttl = settings.getLoop().getSessionTtlMinutes();
|
||||
LocalDateTime deadline = LocalDateTime.now().minusMinutes(ttl);
|
||||
List<AgentSession> expired = sessionRepo.findByLastAccessBefore(deadline);
|
||||
if (expired.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (AgentSession s : expired) {
|
||||
messageRepo.deleteBySessionId(s.getSessionId());
|
||||
sessionRepo.delete(s);
|
||||
locks.remove(s.getSessionId());
|
||||
}
|
||||
log.info("会话过期清理:删除 {} 个空闲会话(TTL {} 分钟)", expired.size(), ttl);
|
||||
}
|
||||
|
||||
private boolean sameUser(AgentSession s, Integer userId) {
|
||||
return s.getUserId() == null ? userId == null : s.getUserId().equals(userId);
|
||||
}
|
||||
|
||||
private Object lockOf(String sid) {
|
||||
return locks.computeIfAbsent(sid, k -> new Object());
|
||||
}
|
||||
|
||||
/** 内存态会话(含本次请求加载的消息列表) */
|
||||
@Data
|
||||
public static class SessionState {
|
||||
private String sessionId;
|
||||
private Integer userId;
|
||||
private Integer schId;
|
||||
private String title;
|
||||
private List<ChatMessage> messages;
|
||||
}
|
||||
}
|
||||
@@ -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