Files
yunhai-agent-service/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java
T
lijing c16356db6e
build / build-push (push) Successful in 1m2s
feat(form): 发布表单与各模块「新建发布」页逐字段对齐(含审批流与作业两步发布)
把 5 个模块「新建/发布」页的字段与提交体逐个核对后,补齐此前缺失或形态不符的部分:

- 部门名镜像:公告/宿舍/活动三处在模块页是「字典 id + 反查名称」一起提交。
  新增 mirrorLabelOf 机制 —— 该字段不渲染控件、不进模型 schema,提交时按源字段
  选中项的显示名填充(deptName)
- 公告审批流:新增 flow 控件(widget=flow)+ 教师组织树字典 notice.teaTree,
  提交 flow:[{name,vote,approverTeaIds}];publishType 值域修正为 direct/approve;
  存草稿改用模块自己的独立接口 notice.saveDraft(POST /api/notice/SaveDraft)
- 活动分类:category 提交的是**分类名称**(模块页 :value="String(c.v)"),
  optionsKeys 由 ("k","v") 改为 ("v","v")
- 请假附件:fileIds 在 HomeLeave 是 number[](JSON 数组),去掉逗号串拼接;
  连续请假按 continuousLeaveNum 逐天展开成 continuousLeaveTime[](服务端/浏览器同源)
- 作业发布(整块新增):homework.publish / schedule / published / drafts /
  teachingSubjects / classTree。模块没有一步发布的接口,用 ToolDef.then 声明两步提交
  (Create 落草稿 → Publish | Schedule);附件是对象数组
  attachments:[{fileId,fileUrl,fileType,fileName,sort}],上传走 file 域
  /coursehomework/api/file/Upload
- 执行器:新增 followUp 两步提交(浏览器侧同源)、数组参数给标量时包成单元素数组、
  首步响应 data 取资源 id(对象形态取 .id,避免拼出 /homeworks/{"id":1}/publish)
- 新增 ModuleFormParityTest(13 项)逐条锁住上述契约

回归:mvn clean package → Tests run: 103, Failures: 0, Errors: 0, BUILD SUCCESS
真机:headless Chrome 走「表单 → 浏览器代执行 → 模块接口」,50 项断言全绿
2026-10-13 11:29:03 +08:00

1098 lines
54 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
private final IntentShortcut intentShortcut;
@Autowired
public AgentOrchestrator(AgentSettings settings, LlmClient llmClient, ToolRegistry registry,
ToolExecutor executor, SessionStore sessionStore,
AgentAuditService auditService, IntentShortcut intentShortcut) {
this.settings = settings;
this.llmClient = llmClient;
this.registry = registry;
this.executor = executor;
this.sessionStore = sessionStore;
this.auditService = auditService;
this.intentShortcut = intentShortcut;
}
/**
* 执行一轮对话。
*
* @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());
// ★ 裸意图直达:「我要请假」「发布校园公告」这类只有意图、没有细节的句子,直接按身份弹表单,
// 不交给大模型决策(模型可能先去查字典、或自行编造参数直接提交,都不合预期)。
ChatResponse direct = directForm(req.getMessage(), caller, authorizedModIds, roles, session, out);
if (direct != null) {
return direct;
}
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(assistantOf(reply, result.getReasoningContent()));
resp.setReply(reply);
finish(resp, session, totalPrompt, totalCompletion, out);
return resp;
}
// 记录 assistant 的 tool_calls(后续必须以 role=tool 逐条应答,否则上下文非法)
// ★ 同时带上 reasoning_content:thinking mode 下带 tools 的请求要求历史 assistant 原样回传该字段,
// 否则下一轮请求会被接口判 400(The reasoning_content ... must be passed back to the API)。
ChatMessage assistantMsg = assistantOf(result.getContent(), result.getReasoningContent());
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() + "_";
// 注:这条 assistant 是「服务端补造」的(模型当轮的消息在挂起时已撤销),没有真实 reasoning_content。
// thinking mode 关闭时(agent.llm.thinking=disabled,默认)不需要该字段;若改为开启,此处可能被接口判 400。
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;
}
/* ==================== 表单:缺参拦截 与 直提交 ==================== */
/**
* 裸意图直达:命中「只有意图、没有细节」的句子(如「我要请假」「发布校园公告」)时,
* 按当前身份直接构建表单返回 —— 请假:学生拿「请假申请」、教职工拿「请假登记(帮学生请假)」;
* 发布公告:只有教职工能直达,学生落回模型(由模型的权限准则如实说明)。
*
* <p>判据与能力目录 / 模型侧过滤**完全一致**({@code visibleTools}),
* 因此不存在「直达出一个本身份无权使用的表单」的可能;不命中或不适用就返回 {@code null},
* 照常交给大模型处理。
*/
private ChatResponse directForm(String message, CallerContext caller, Set<Integer> authorizedModIds,
Set<AgentRole> roles, SessionStore.SessionState session, EventSink out) {
IntentShortcut.Intent intent = intentShortcut.match(message);
if (intent == null) {
return null;
}
boolean staff = roles != null
&& (roles.contains(AgentRole.TEACHER) || roles.contains(AgentRole.ADMIN));
String toolName = staff ? intent.getStaffTool() : intent.getStudentTool();
ToolDef def = toolName == null ? null : registry.get(toolName);
if (def == null) {
return null;
}
boolean visible = registry.visibleTools(authorizedModIds, roles).stream()
.anyMatch(t -> t.getName().equals(def.getName()));
if (!visible) {
return null;
}
Map<String, Object> empty = new LinkedHashMap<>();
List<ToolParam> missing = missingRequired(def, empty);
if (missing.isEmpty()) {
return null;
}
String reply = staff ? intent.getStaffReply() : intent.getStudentReply();
FormRequest form = buildForm(def, empty, missing, caller);
form.setDescription(reply);
ChatResponse resp = new ChatResponse();
resp.setSessionId(session.getSessionId());
resp.setForm(form);
resp.setReply(reply);
out.emit(AgentEvent.of("form", "需要补充信息:" + String.join("、", form.getMissing())));
log.info("意图直达弹表单:session={} 意图={} 身份={} 工具={}", session.getSessionId(),
intent.getName(), staff ? "教职工" : "学生", def.getName());
finish(resp, session, 0, 0, out);
return resp;
}
/**
* 预检本轮的写操作工具:若有必填参数缺失,则撤销刚加入的 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);
// 同上:服务端补造的 assistant(无真实 reasoning_content),thinking 关闭时无需该字段
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<>();
// ★ 表单包含哪些字段:发布类工具(fullForm)渲染**全部字段**,与模块自己的「新建/发布」页一致
// (置顶 / 短信 / 定时发送 / 附件 / 回执方式等可选项也摆出来,否则用户设不了、发出去的数据与模块页不同);
// 其余工具仍只补「缺失的必填项」。
List<ToolParam> shown = new ArrayList<>();
if (def.isFormAll()) {
for (ToolParam p : def.getParams()) {
if (!p.isHidden()) {
shown.add(p);
}
}
} else {
shown.addAll(missing);
}
for (ToolParam p : shown) {
FormField f = p.toFormField();
// 附件控件:补上传地址(模块的附件上传只认浏览器 CAS 会话,必须前端带登录态 POST)。
// 缺省按 「/<模块>/api/base/FileUpload」,个别模块不同(作业是 /api/file/Upload)时由参数覆盖。
if ("file".equals(f.getWidget())) {
f.setUploadPath(StringUtils.isNotBlank(p.getUploadPathOverride())
? p.getUploadPathOverride()
: "/" + def.getModule() + "/api/base/FileUpload");
}
// 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。
//
// ★ 为什么不在这里直接执行字典工具:业务模块的用户态接口只认浏览器 CAS 会话,
// 服务端直调必然 code=302(README 第 11 节)。以前"服务端取一次、失败就退化成
// 数字输入框",结果教师登记请假时得手填 classId/stuId 编号——根本没法用。
// 现在把「去哪儿取」以 optionCall 下发(依赖字段留 {{占位符}}),
// 前端在学生选完班级后自己拉名单,下拉框才是真的下拉框。
BrowserToolRequest.BrowserCall optionCall = buildOptionCall(p, caller);
if (optionCall != null) {
f.setOptionCall(optionCall);
// ★ 多值控件必须保留原样:multiselect(接收班级/宿舍)、scopes(公告推送范围)、
// flow(公告审批流)都是「选项从字典代取 + 用户多选」,降级成单选下拉会直接丢掉多选能力。
String w = f.getWidget();
if (!"multiselect".equals(w) && !"scopes".equals(w) && !"flow".equals(w)) {
f.setWidget("select");
}
// 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖
f.setOptionsFallback(p.isUsingFallbackOptions());
} else if (StringUtils.isNotBlank(p.getOptionsFrom())) {
// 没有代取通道(模块未配地址 / 越权)→ 退回服务端取一次,取不到就保留原控件让用户手填编号
List<Map<String, String>> opts = dictOptions(p.getOptionsFrom(), caller, dictCache);
if (!opts.isEmpty()) {
f.setOptions(opts);
String w2 = f.getWidget();
if (!"multiselect".equals(w2) && !"scopes".equals(w2) && !"flow".equals(w2)) {
f.setWidget("select");
}
f.setOptionsFallback(false);
}
}
fields.add(f);
}
for (ToolParam p : missing) {
missingLabels.add(p.labelOf());
}
form.setFields(fields);
form.setMissing(missingLabels);
form.setAllFields(def.isFormAll());
// 已从对话里确定的参数 → 表单里只读回显(值翻译成中文,用户能一眼核对)
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");
}
}
}
// 服务端取不到实时字典时,至少把内置兜底的中文名翻出来(leaveType=1 → 病假),
// 免得「已识别」标签里显示一个裸编号让用户看不懂。
if (p.getFallbackValues() != null && !p.getFallbackValues().isEmpty()) {
int idx = p.getFallbackValues().indexOf(text);
if (idx >= 0 && p.getFallbackLabels() != null && idx < p.getFallbackLabels().size()) {
return p.getFallbackLabels().get(idx);
}
}
return text;
}
/**
* 生成某个字段「浏览器代取选项」的调用描述;不可用时返回 {@code null}。
*
* <p>用于业务模块的字典接口(班级、学生名单、请假类型…)——这些接口只认浏览器 CAS 会话,
* 服务端直调必然 302,所以只能把地址交给前端、由前端带用户登录态去取。
*/
private BrowserToolRequest.BrowserCall buildOptionCall(ToolParam p, CallerContext caller) {
if (StringUtils.isBlank(p.getOptionsFrom())) {
return null;
}
try {
ToolDef dict = registry.get(p.getOptionsFrom());
if (dict == null || !dict.isReadOnly()) {
return null;
}
// 与 fetchDictOptions 同源的角色约束:越权的字典不外泄地址
if (settings.isEnforceRolePermission() && !dict.allowsAny(caller.getRoles())) {
return null;
}
return executor.prepareOptionCall(dict, p.getDependsOn());
} catch (Exception e) {
log.warn("生成代取选项描述失败 {}:{}", p.getOptionsFrom(), e.toString());
return null;
}
}
/** 取字典选项(带缓存;取不到返回空列表,调用方据此退化为普通输入框) */
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;
// "k"/"v" 是平台通用字典(/api/dict/List)的形态,与业务 VO(classId/className)并存
String value = firstOf(o, "id", "value", "code", "dictId", "typeId", "key", "k");
if (StringUtils.isBlank(value)) {
continue;
}
String label = firstOf(o, "name", "label", "text", "title", "dictName", "v", "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());
}
/**
* 构造 assistant 消息,并统一挂上 thinking mode 的思维链。
*
* <p>★ DeepSeek V4 系列默认开启 thinking mode,官方协议要求:只要请求带 {@code tools},
* 历史里所有 assistant 消息的 {@code reasoning_content} 必须原样回传,否则 HTTP 400。
* 因此模型每一次返回的思维链都要落进对应的 assistant 消息里。
*/
private ChatMessage assistantOf(String content, String reasoningContent) {
ChatMessage m = new ChatMessage(ChatMessage.ROLE_ASSISTANT, content);
m.setReasoningContent(reasoningContent);
return m;
}
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");
sb.append("9. ★ 请假要分清两条链路:「提交请假申请」= 学生给自己请假(提交后待班主任审核);")
.append("「帮学生登记请假」= 教师给学生登记(免审直接生效,必须先选班级与学生)。")
.append("下发给你的工具已按当前身份过滤,按标题选即可,不要互换。\n");
sb.append("10. ★ 「发布校园公告」是**立即发布、全站可见**的动作:必须拿到标题、正文、发文部门三项;")
.append("缺哪项就照常调用工具让系统弹表单,不要自己编造发文部门,也不要用文字反复追问。")
.append("该工具只对教职工下发(学生身份看不到),学生要求发布公告时如实说明身份不够。\n");
sb.append("11. ★ 「发布宿舍通知」与「发布校园公告」是**两个不同模块**,不要混用:")
.append("宿舍通知发给**家长**、可要求家长回执(回执方式:无需/确认/填报),发布时要填通知类型与发文部门;")
.append("校园公告是全校范围的公告。用户说「发通知」而没指明模块时,先问清楚是校园公告还是宿舍通知。\n");
sb.append("12. ★ 「发布家校活动」要拿到标题、说明、活动分类、主办部门、活动地点与起止时间;")
.append("活动默认要求家长报名。缺项就照常调用工具让系统弹表单,分类与主办部门必须来自字典,不要编造。\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 "profession": return "教师专业发展";
default: return module;
}
}
}