feat: 提交校园智能助手后端工程源码(Spring Boot 2.7.18 + 平台 starter + JPA + Nacos)
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:
lijing
2026-10-12 11:18:29 +08:00
parent 8589afd69a
commit 8303b857e9
58 changed files with 6883 additions and 2 deletions
@@ -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;
}
}
}