package com.xly.web; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.AgentIdentity; import com.xly.agent.ReActAgent; import com.xly.config.AgentFactory; import com.xly.service.AuthzService; import com.xly.service.ConversationService; import com.xly.service.OpService; import dev.langchain4j.service.TokenStream; import dev.langchain4j.service.tool.ToolExecution; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 * *

帧格式:{@code {"type":"token|reset|done|error"}} 或写提议 {@code {"type":"write_proposal","opId","summary"}}、 * 澄清问题 {@code {"type":"question","question","options"}}、表单收集 {@code {"type":"form_collect",...}}。 * {@code reset} 在工具执行时清掉模型的调用旁白;确认/取消走确定性端点 {@code /api/agent/op/{id}/...},不经过 LLM。 * *

**按请求身份组装 agent**(§5/§7 per-call context):从请求里解析透传的 ERP token + 稳定身份, * 用 {@link AgentFactory} 新建携带该身份(token + 表单权限集)的工具实例,从根上保证鉴权与 token 正确。 */ @RestController @RequestMapping("/api/agent") public class AgentChatController { private static final Logger log = LoggerFactory.getLogger(AgentChatController.class); private static final Set WRITE_TOOLS = Set.of("proposeUpdate", "proposeDelete", "proposeCreate", "proposeExamine"); private final AgentFactory agentFactory; private final AuthzService authz; private final ObjectMapper mapper; private final ConversationService conversations; private final OpService ops; private final ExecutorService exec = Executors.newCachedThreadPool(); public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, ConversationService conversations, OpService ops) { this.agentFactory = agentFactory; this.authz = authz; this.mapper = mapper; this.conversations = conversations; this.ops = ops; } public static class ChatReq { public String text; public String userid; public String conversationId; // 透传的 ERP 会话 token + 稳定身份(前端逐请求带上;token 绝不进 prompt) public String authorization; public String username; public String brandsid; public String subsidiaryid; public String usertype; } @PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8") public SseEmitter chat(@RequestBody ChatReq req) { SseEmitter emitter = new SseEmitter(180_000L); final String userInput = req.text == null ? "" : req.text; final String convId = (req.conversationId != null && !req.conversationId.isBlank()) ? req.conversationId : ((req.userid == null ? "anon" : req.userid) + ":default"); conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); final AgentIdentity identity = resolveIdentity(req); final ReActAgent agent = agentFactory.build(identity); exec.submit(() -> { try { TokenStream ts = agent.chat(convId, userInput); ts.onPartialResponse(token -> send(emitter, "token", token)) .onToolExecuted(te -> handleToolExecuted(emitter, convId, te)) .onCompleteResponse(resp -> { send(emitter, "done", ""); emitter.complete(); }) .onError(err -> { log.warn("agent chat stream error (conv={})", convId, err); send(emitter, "error", err.getMessage() == null ? "服务异常" : err.getMessage()); emitter.complete(); }) .start(); } catch (Exception e) { log.error("agent invoke failed (conv={})", convId, e); send(emitter, "error", "服务异常:" + e.getMessage()); emitter.complete(); } }); return emitter; } /** 解析本次请求身份:带透传 token → 真实用户身份(按 sAuthsId 收紧);否则 → dev-login。 */ private AgentIdentity resolveIdentity(ChatReq req) { try { if (req.authorization != null && !req.authorization.isBlank()) { return authz.userIdentity(req.authorization.trim(), req.userid, req.username, req.brandsid, req.subsidiaryid, req.usertype); } } catch (Exception e) { log.warn("resolve user identity failed, fall back to dev-login: {}", e.getMessage()); } return authz.devIdentity(); } /** * 工具执行回调:清掉工具前的旁白(reset);再按工具类型推对应卡片/控件事件(写提议 / 澄清问题 / 表单收集)。 */ private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) { send(emitter, "reset", ""); try { String toolName = te.request() == null ? "" : te.request().name(); if (te.result() == null) { return; } if (WRITE_TOOLS.contains(toolName)) { JsonNode r = mapper.readTree(te.result()); String opId = r.path("opId").asText(null); if (opId != null && !opId.isBlank()) { ops.attachConversation(opId, convId); Map card = new LinkedHashMap<>(); card.put("type", "write_proposal"); card.put("opId", opId); card.put("summary", r.path("summary").asText("")); sendEvent(emitter, card); } } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) { // AskUser / FormCollect 工具直接返回结构化 payload(type=question / form_collect)→ 原样转成 SSE 事件 JsonNode r = mapper.readTree(te.result()); String type = r.path("type").asText(""); if ("question".equals(type) || "form_collect".equals(type)) { sendEvent(emitter, mapper.convertValue(r, Map.class)); } } } catch (Exception e) { log.warn("handle tool result failed ({})", te.request() == null ? "?" : te.request().name(), e); } } private void send(SseEmitter emitter, String type, String content) { Map m = new LinkedHashMap<>(); m.put("type", type); m.put("content", content); sendEvent(emitter, m); } private void sendEvent(SseEmitter emitter, Map m) { try { emitter.send(SseEmitter.event().data(mapper.writeValueAsString(m), MediaType.APPLICATION_JSON)); } catch (IOException | IllegalStateException e) { // 客户端已断开或 emitter 已结束——忽略。 } } }