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.Collection; 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 风格的工具调用循环): * *
 *  用户输入
 *    └─► 组装 system + 历史 + 本轮输入
 *         └─► 调大模型(带上「按角色过滤后」的可用工具 schema)
 *              ├─ 模型返回 tool_calls ─┬─ 写操作缺必填参数 ─► 【回补全表单】给用户填(不再口头追问)
 *              │                        └─ 参数齐全 ─► 逐个执行(转成对该模块的真实 HTTP 调用)
 *              │                                        ├─ 角色越权 ─► 服务端直接拒绝(留痕)
 *              │                                        └─► 结果回填为 role=tool 消息 ─► 再次调模型
 *              └─ 模型返回普通文本 ─► 这就是最终回复,结束
 * 
* *

表单提交({@link ChatRequest#getFormSubmit()})走另一条短路:直接执行指定工具, * 不再让模型二次决策,保证「用户填了什么就提交什么」。 */ @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 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 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 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):思考 → 需要工具时「挂起等浏览器」或「服务端执行」→ 再思考,直到给出自然语言回复。 * *

★ 浏览器代执行(方案 A):业务模块的用户态接口只认浏览器 CAS 会话, * 服务端直调必然 {@code code=302}(README 第 11 节四条证据)。开启 {@code agent.browser-exec.enabled} * 且本轮工具都能准备成功时,整批挂起下发给前端,用用户自己的登录态执行后回传续跑({@link #resumeBrowser}); * 准备失败(未知工具 / 模块未配置地址 / 缺参 / 越权)则回退服务端直调老路径(行为与历史版本一致)。 */ private ChatResponse runLoop(SessionStore.SessionState session, List messages, ChatResponse resp, CallerContext caller, Set authorizedModIds, Set 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 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 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); // 写操作 → 去系统里确认结果;只读**失败**(下游 302 / 会话没建起来)→ // 同样要给「去系统里自己查看」入口,否则模型嘴上说「点下方按钮」而界面没有按钮。 if (!def.isReadOnly() || !tr.isSuccess()) { 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 tryPrepareBrowserBatch(List calls, CallerContext caller) { List 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 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; } /** * 浏览器代执行的续跑:前端已用用户自己的登录态调完模块接口并带回原始响应。 * *

服务端重新查注册表解析结果(角色校验、{@code code=302} 识别等与服务端执行完全同源, * 不信任前端回传的任何路径信息),构造合法的 {@code assistant(tool_calls) + tool} 消息 * 喂回模型继续 —— 模型既可以整理出最终回答,也可以发起下一批工具调用(链式查询)。 */ private ChatResponse resumeBrowser(ChatRequest req, CallerContext caller, Set authorizedModIds, EventSink out) { Set 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 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 calls = new ArrayList<>(); List contents = new ArrayList<>(); int idx = 0; for (ChatRequest.ToolResultItem item : req.getToolResult()) { Map 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)); // 写操作 → 去系统里确认结果;只读**失败**(如下游 302 / 会话没建起来)→ // 同样要给「去系统里自己查看」入口,否则模型嘴上说「点下方按钮」而界面没有按钮。 if (!def.isReadOnly() || !tr.isSuccess()) { 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; } /* ==================== 表单:缺参拦截 与 直提交 ==================== */ /** * 裸意图直达:命中「只有意图、没有细节」的句子(如「我要请假」「发布校园公告」)时, * 按当前身份直接构建表单返回 —— 请假:学生拿「请假申请」、教职工拿「请假登记(帮学生请假)」; * 发布公告:只有教职工能直达,学生落回模型(由模型的权限准则如实说明)。 * *

判据与能力目录 / 模型侧过滤**完全一致**({@code visibleTools}), * 因此不存在「直达出一个本身份无权使用的表单」的可能;不命中或不适用就返回 {@code null}, * 照常交给大模型处理。 */ private ChatResponse directForm(String message, CallerContext caller, Set authorizedModIds, Set 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 empty = new LinkedHashMap<>(); List 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 calls, List 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 args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments()); List 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 authorizedModIds, EventSink out) { ChatRequest.FormSubmit fs = req.getFormSubmit(); Set 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 args = fs.getArgs() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(fs.getArgs()); // 兜底:表单若有漏填(前端校验被绕过),仍然回到表单而不是带着残缺参数去打接口 List missing = missingRequired(def, args); if (!missing.isEmpty()) { resp.setForm(buildForm(def, args, missing, caller)); resp.setReply(askText(def, missing)); return resp; } List 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 missingRequired(ToolDef def, Map args) { Map effective = new LinkedHashMap<>(); if (def.getFixed() != null) { effective.putAll(def.getFixed()); } if (args != null) { effective.putAll(args); } List 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; } /** * 由模块的**完整地址**推出它在站点根下的路径前缀(`homeleave` + `…/home-leave` → `/home-leave`)。 * *

★ 不能用模块 key 直接拼:key 只是 `agent.modules` 的内部标识,真实路由前缀可能不同 —— * 请假正是这种情况,key 是 `homeleave` 而前缀是 `/home-leave`(差一个连字符)。 * 早先附件上传按 `"/" + def.getModule() + "/api/base/FileUpload"` 拼,于是请假的佐证材料 * POST 到 `/homeleave/api/base/FileUpload`,被网关当成不存在的应用直接 **404** * (实测:`/home-leave/...` 回 302 走 CAS,`/homeleave/...` 回 caddy 404)。 * * @param module 模块标识(仅用于兜底) * @param moduleBaseUrl `agent.modules` 里配置的地址,如 `http://host/home-leave` */ public static String pathPrefix(String module, String moduleBaseUrl) { if (StringUtils.isBlank(moduleBaseUrl)) { return "/" + module; } String rest = moduleBaseUrl; int scheme = rest.indexOf("://"); if (scheme >= 0) { rest = rest.substring(scheme + 3); } int slash = rest.indexOf('/'); if (slash < 0) { // 形如 http://host(没有路径部分)⇒ 模块就挂在站点根,退回 key 前缀 return "/" + module; } String path = rest.substring(slash); while (path.length() > 1 && path.endsWith("/")) { path = path.substring(0, path.length() - 1); } return path.isEmpty() ? "/" + module : path; } /** * 附件上传地址(下发到前端的**相对站点根**路径)。 * *

★ 约定与 {@link ToolDef#getPath()} 完全一致:**参数只写模块内相对路径**,模块前缀一律在这里拼。 * 早先把 {@code uploadPathOverride} 当成「完整路径」直接用,于是 * {@code profession.publish} 声明 {@code /api/file/Upload} 就原样下发, * 线上打到 {@code http://base.yunhaixingtong.com/api/file/Upload} —— 站点根没有这个应用,**404** * (请假那次是「前缀用 key 拼错」,这次是「压根没拼」;同一个坑的两种形态)。 * *

为了容错,对「已经带了前缀」的写法保持幂等({@code /profession/api/file/Upload} 不会再被 * 拼成 {@code /profession/profession/...}),调用方不必猜该写哪一种。 * * @param module 模块标识 * @param moduleBaseUrl {@code agent.modules} 里配置的地址(用来推路径前缀) * @param override {@link ToolParam#getUploadPathOverride()},为空时用模块默认的 {@code /api/base/FileUpload} */ public static String resolveUploadPath(String module, String moduleBaseUrl, String override) { String prefix = pathPrefix(module, moduleBaseUrl); String rel = StringUtils.isNotBlank(override) ? override.trim() : "/api/base/FileUpload"; if (!rel.startsWith("/")) { rel = "/" + rel; } if (rel.equals(prefix) || rel.startsWith(prefix + "/")) { return rel; } return prefix + rel; } /** 由工具定义生成补全表单 */ private FormRequest buildForm(ToolDef def, Map args, List 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>> dictCache = new LinkedHashMap<>(); List fields = new ArrayList<>(); List missingLabels = new ArrayList<>(); // ★ 表单包含哪些字段:发布类工具(fullForm)渲染**全部字段**,与模块自己的「新建/发布」页一致 // (置顶 / 短信 / 定时发送 / 附件 / 回执方式等可选项也摆出来,否则用户设不了、发出去的数据与模块页不同); // 其余工具仍只补「缺失的必填项」。 List 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) // 时由参数覆盖 —— **覆盖值也只写模块内相对路径**,前缀一律由 resolveUploadPath 拼。 if ("file".equals(f.getWidget())) { f.setUploadPath(resolveUploadPath(def.getModule(), settings.getModules().get(def.getModule()), p.getUploadPathOverride())); } // 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。 // // ★ 为什么不在这里直接执行字典工具:业务模块的用户态接口只认浏览器 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> 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()); // 「栏目动态字段」(专业发展的栏目自定义字段):构建期枚举不出来,只能把「去哪儿取」 // 交给前端带用户登录态取回后就地渲染,提交时打包进 extraFieldsParam(contents)。 if (StringUtils.isNotBlank(def.getExtraFieldsFrom())) { // 占位符用**模块侧的查询参数名**(如 SerID),映射告诉前端该拿哪个表单字段去填 BrowserToolRequest.BrowserCall call = buildReadCall(def.getExtraFieldsFrom(), def.getExtraFieldsDepends().keySet(), caller); if (call != null) { FormRequest.ExtraFields extra = new FormRequest.ExtraFields(); extra.setCall(call); extra.setDependsOn(new LinkedHashMap<>(def.getExtraFieldsDepends())); extra.setParam(def.getExtraFieldsParam()); extra.setBefore(def.getExtraFieldsBefore()); // 级联字段的区划树(含 {{level}} 占位符,按字段配置取 2 或 3 级) if (StringUtils.isNotBlank(def.getExtraFieldsAreaFrom())) { extra.setAreaCall(buildReadCall(def.getExtraFieldsAreaFrom(), Collections.singletonList("level"), caller)); } form.setExtraFields(extra); } else { log.warn("栏目动态字段代取不可用:tool={} dict={}", def.getName(), def.getExtraFieldsFrom()); } } // 已从对话里确定的参数 → 表单里只读回显(值翻译成中文,用户能一眼核对) Map preset = new LinkedHashMap<>(); List> recognized = new ArrayList<>(); if (args != null) { for (Map.Entry 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 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>> 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 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}。 * *

用于业务模块的字典接口(班级、学生名单、请假类型…)——这些接口只认浏览器 CAS 会话, * 服务端直调必然 302,所以只能把地址交给前端、由前端带用户登录态去取。 */ private BrowserToolRequest.BrowserCall buildOptionCall(ToolParam p, CallerContext caller) { return buildReadCall(p.getOptionsFrom(), p.getDependsOn(), caller); } /** * 生成「浏览器代取」的调用描述(下拉字典与「栏目动态字段」共用)。 * *

差别只在取回的东西怎么用:字典取回的是选项(渲染成下拉), * 栏目字段取回的是**字段定义**(前端就地生成控件)。地址拼装与权限校验完全一致: * 只读工具 + 角色可见 + 依赖字段留 {@code {{占位符}}} 由前端替换。 * * @param toolName 只读工具名(如 leave.classStudents / profession.moduleFields) * @param dependsOn 需要留占位符的依赖字段名;为空 = 无参调用 * @return 调用描述;工具不存在 / 非只读 / 越权 / 模块未配地址时返回 {@code null} */ private BrowserToolRequest.BrowserCall buildReadCall(String toolName, Collection dependsOn, CallerContext caller) { if (StringUtils.isBlank(toolName)) { return null; } try { ToolDef dict = registry.get(toolName); if (dict == null || !dict.isReadOnly()) { return null; } // 与 fetchDictOptions 同源的角色约束:越权的字典/字段定义不外泄地址 if (settings.isEnforceRolePermission() && !dict.allowsAny(caller.getRoles())) { return null; } return executor.prepareOptionCall(dict, dependsOn); } catch (Exception e) { log.warn("生成代取描述失败 {}:{}", toolName, e.toString()); return null; } } /** 取字典选项(带缓存;取不到返回空列表,调用方据此退化为普通输入框) */ private List> dictOptions(String toolName, CallerContext caller, Map>> cache) { if (cache.containsKey(toolName)) { return cache.get(toolName); } List> opts = fetchDictOptions(toolName, caller); cache.put(toolName, opts == null ? new ArrayList<>() : opts); return cache.get(toolName); } /** * 执行字典工具并尽力解析成 [{value,label}]。 *

解析失败(结构不认识、接口不通)一律返回 null,前端退化成普通输入框,不影响主流程。 */ private List> 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> 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 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 missing) { List 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 args) { StringBuilder sb = new StringBuilder(); sb.append("我已在表单里填好并提交「").append(def.getTitle()).append("」,参数为:"); for (Map.Entry e : args.entrySet()) { sb.append("\n- ").append(e.getKey()).append(" = ").append(String.valueOf(e.getValue())); } return sb.toString(); } private ToolCallTrace buildTrace(ToolDef def, Map 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 的思维链。 * *

★ 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 parseArgs(String arguments) { if (StringUtils.isBlank(arguments)) { return new LinkedHashMap<>(); } try { JSONObject obj = JSON.parseObject(arguments); if (obj == null) { return new LinkedHashMap<>(); } Map 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 args) { Map 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 authorizedModIds, Set 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> grouped = registry.groupByModule(authorizedModIds, roles); for (Map.Entry> e : grouped.entrySet()) { sb.append("- ").append(moduleLabel(e.getKey())).append(":"); List 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 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; } } }