build / build-push (push) Successful in 1m7s
★「通知公告」与既有「校园公告」是两个应用,不是同一个: · 校园公告 CampusAnnouncement → key=campusnotice、前缀 /campusnotice、按班级下发、带审批流/草稿箱(既有 notice.*) · 通知公告 NoticeFront → key=notice、前缀 /notice、按用户下发,无审批/草稿/推送范围(本次新增 notify.*) 两者路由与页面名几乎同名(/list、/list/new、已收到/已发送),极易拿错源码,故工具前缀避开已占用的 notice.*。 新增 NoticeTools(9 个工具):收件箱/详情/一键已读、发布/编辑/我发出的/阅读情况、发文部门字典、组织树。 发布表单逐项对齐 NoticeFront/src/views/notice/NewNotice.vue#doAdd: title / content / deptId / receiveList / fileIds / timingSendTime (smsFlag 在页面里没有对应控件、恒为 0,故不放出;receiveList 的「全部用户」哨兵是 all。) 配套:ToolRegistry 注册、RolePolicy 把发布闭环与两个字典工具列为教职工专属、 application.yml 补地址 http://base.yunhaixingtong.com/notice 与默认落地页 / (AgentSettings 出厂默认表也要补 notice —— 契约测试用的是 new AgentSettings(),不读 yml)。 fix(ToolExecutor): 已按 joinSeparator 拼成逗号串的字段不再被包回数组 normalizeJsonArgs 的第二个循环「数组参数给了标量 → 包成单元素数组」会作用到 刚拼好的 receiveList/fileIds 上,把 101,102 变成 [101,102], 而模块侧 NoticeRequest.receiveList 是 String ⇒ 直接判非法。 此前一直没暴露:本地 mock 的提交体是原样 JSON.stringify(args)(本来就是数组), 看不出差异,只有真实后端才走到这一步。现在 join 过的字段直接跳过该循环。 测试:ModuleFormParityTest 新增「通知公告发布字段集不与校园公告互串」+ 「代取地址带 /notice 前缀」+「提交体是逗号串、哨兵互斥、提交路径带前缀」;全量 126/126 绿。
175 lines
7.2 KiB
Java
175 lines
7.2 KiB
Java
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.NoticeTools;
|
||
import com.yunhai.agent.tool.defs.ProfessionTools;
|
||
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(NoticeTools.defs());
|
||
register(DormNoticeTools.defs());
|
||
register(ActivityTools.defs());
|
||
register(HomeworkTools.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;
|
||
}
|
||
}
|