AgentChatController.java 5.29 KB
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 流式返回。
 *
 * <p>帧格式:{@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 {
            if (te.request() != null && "proposeUpdate".equals(te.request().name()) && 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<String, Object> card = new LinkedHashMap<>();
                    card.put("type", "write_proposal");
                    card.put("opId", opId);
                    card.put("summary", r.path("summary").asText(""));
                    sendEvent(emitter, card);
                }
            }
        } catch (Exception e) {
            log.warn("handle proposeUpdate result failed", e);
        }
    }

    private void send(SseEmitter emitter, String type, String content) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("type", type);
        m.put("content", content);
        sendEvent(emitter, m);
    }

    private void sendEvent(SseEmitter emitter, Map<String, Object> m) {
        try {
            emitter.send(SseEmitter.event().data(mapper.writeValueAsString(m), MediaType.APPLICATION_JSON));
        } catch (IOException | IllegalStateException e) {
            // 客户端已断开或 emitter 已结束——忽略。
        }
    }
}