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 已结束——忽略。
}
}
}