package com.xly.web; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.ReActAgent; 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.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 reset} 在工具执行时清掉模型的调用旁白;{@code write_proposal} 让前端渲染确认卡片
* (确认走确定性端点 {@code /api/agent/op/{id}/confirm},不经过 LLM)。
*/
@RestController
@RequestMapping("/api/agent")
public class AgentChatController {
private static final Logger log = LoggerFactory.getLogger(AgentChatController.class);
private final ReActAgent agent;
private final ObjectMapper mapper;
private final ConversationService conversations;
private final OpService ops;
private final ExecutorService exec = Executors.newCachedThreadPool();
public AgentChatController(ReActAgent agent, ObjectMapper mapper,
ConversationService conversations, OpService ops) {
this.agent = agent;
this.mapper = mapper;
this.conversations = conversations;
this.ops = ops;
}
public static class ChatReq {
public String text;
public String userid;
public String conversationId;
}
@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);
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;
}
/** 工具执行回调:清掉工具前的旁白(reset);若是写提议,关联会话并推确认卡片。 */
private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) {
send(emitter, "reset", "");
try {
String toolName = te.request() == null ? "" : te.request().name();
if (("proposeUpdate".equals(toolName) || "proposeDelete".equals(toolName)) && te.result() != null) {
JsonNode r = mapper.readTree(te.result());
String opId = r.path("opId").asText(null);
if (opId != null && !opId.isBlank()) {
ops.attachConversation(opId, convId);
Map