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.LedgerService;
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.RequestHeader;
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;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
/**
* 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。
*
*
编排:没有意图门、没有确定性路由——全部自由文本进唯一 ReAct agent
* (固定 7 工具:useSkill/findForms/readFormData/lookupRecord/collectForm/proposeWrite/askUser)。
* 流程知识在技能文本里(useSkill 载入,投影层把激活技能钉在「进行中的流程」卡上跨轮续办)。
* 安全不变量在工具与确认端点内,与编排无关:proposeWrite 只出草稿、确认走 {@code /api/agent/op/*}
* 确定性端点、身份内省 fail-closed、工具内鉴权。
*
*
反编造护栏(无条件常开):零工具调用却答出数字 → 注入纠正话术重试一次(internal 事件,
* 前端不显示),复发则标注未核实;声称「已生成/已完成」却没真正 proposeWrite → 附纠正提示。
*
*
帧格式:{@code {"type":"token|reset|done|error"}}、写提议 {@code write_proposal}、澄清
* {@code question}、表单收集 {@code form_collect}。确认/取消与表单提交走确定性端点,不经过 LLM。
*/
@RestController
@RequestMapping("/api/agent")
public class AgentChatController {
private static final Logger log = LoggerFactory.getLogger(AgentChatController.class);
private static final Set WRITE_TOOLS = Set.of("proposeWrite");
/** 反编造护栏:agent 声称「已生成/已完成」写操作的说法(无 proposeWrite 提议时要纠正)。 */
private static final Pattern WRITE_CLAIM = Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核)");
private final AgentFactory agentFactory;
private final AuthzService authz;
private final ObjectMapper mapper;
private final ConversationService conversations;
private final OpService ops;
private final LedgerService ledger;
private final ExecutorService exec = Executors.newCachedThreadPool();
public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper,
ConversationService conversations, OpService ops, LedgerService ledger) {
this.agentFactory = agentFactory;
this.authz = authz;
this.mapper = mapper;
this.conversations = conversations;
this.ops = ops;
this.ledger = ledger;
}
public static class ChatReq {
public String text;
public String conversationId;
/** 透传的 ERP 会话 token(token 绝不进 prompt)。身份一律由它内省得出,不接受客户端自报。 */
public String authorization;
}
@PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8")
public SseEmitter chat(@RequestBody ChatReq req,
@RequestHeader(value = "Authorization", required = false) String authHeader) {
SseEmitter emitter = new SseEmitter(180_000L);
final String userInput = req.text == null ? "" : req.text;
final AgentIdentity identity = authz.resolveIdentity(firstNonBlank(authHeader, req.authorization));
if (identity == null) {
send(emitter, "error", "登录已过期或未登录,请重新登录后再试。");
emitter.complete();
return emitter;
}
// 会话键绑定服务端身份 → 换个 conversationId 只能命中自己的会话,碰不到别人的记忆
final String convId = conversations.scopedId(identity, req.conversationId);
conversations.touch(identity.userId(), convId, userInput);
ledger.append(convId, "user", Map.of("text", userInput));
exec.submit(() -> {
try {
runAgentAttempt(emitter, convId, identity, userInput, true);
} catch (Exception e) {
log.error("agent chat failed (conv={})", convId, e);
send(emitter, "error", "服务异常:" + e.getMessage());
emitter.complete();
}
});
return emitter;
}
public static class FormSubmitReq {
public String entity;
public Map fields; // 字段中文名 -> 值
public String conversationId;
public String authorization;
}
/**
* 确定性表单提交:collectForm 表单的结构化字段**直达** proposeWrite(action=create),
* 不再拼自然语言消息让 LLM 重新编码(旧路径会丢字段/错角色)。同步返回提议或错误。
*/
@PostMapping("/form/submit")
public Map formSubmit(@RequestBody FormSubmitReq req,
@RequestHeader(value = "Authorization", required = false) String authHeader) {
Map out = new LinkedHashMap<>();
AgentIdentity identity = authz.resolveIdentity(firstNonBlank(authHeader, req.authorization));
if (identity == null) {
out.put("error", "登录已过期或未登录,请重新登录后再试。");
return out;
}
final String convId = conversations.scopedId(identity, req.conversationId);
String entity = req.entity == null ? "" : req.entity.trim();
Map fields = req.fields == null ? Map.of() : req.fields;
if (entity.isEmpty() || fields.isEmpty()) {
out.put("error", "缺少单据类型或表单字段。");
return out;
}
StringBuilder parts = new StringBuilder();
fields.forEach((k, v) -> {
if (v != null && !v.isBlank()) {
if (parts.length() > 0) parts.append(",");
parts.append(k).append("=").append(v);
}
});
conversations.touch(identity.userId(), convId, "提交「" + entity + "」新增表单");
ledger.append(convId, "form_submit", Map.of("entity", entity, "fields", parts.toString()));
String fieldsJson;
try {
fieldsJson = mapper.writeValueAsString(fields);
} catch (Exception e) {
out.put("error", "字段序列化失败:" + e.getMessage());
return out;
}
String result = agentFactory.proposeWriteTool(identity)
.proposeWrite("create", entity, null, null, null, fieldsJson);
try {
JsonNode r = mapper.readTree(result);
String opId = r.path("opId").asText(null);
if (opId != null && !opId.isBlank()) {
ops.attachConversation(opId, convId);
String summary = r.path("summary").asText("");
ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary));
out.put("opId", opId);
out.put("summary", summary);
out.put("message", r.path("message").asText("已生成待确认操作,请点【确认】。"));
} else {
String err = r.path("error").asText("无法完成该新增。");
ledger.append(convId, "assistant", Map.of("text", err));
out.put("error", err);
}
} catch (Exception e) {
out.put("error", "服务异常:" + e.getMessage());
}
return out;
}
/**
* 运行 ReAct agent,把流式回调转成 SSE。反编造护栏**无条件常开**(实测 Ollama 对
* tool_choice=required 不硬执行,只能在代码层兜底):零工具调用却答出数字 → 注入纠正话术
* 重试一次(internal 用户事件,前端不显示),复发则标注「未经核实」;声称已完成但没
* proposeWrite → 附纠正提示。
*/
private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity,
String text, boolean allowRetry) {
try {
// 重试轮的注入话术以 internal 用户事件落账:LLM 可见、前端历史不显示
ReActAgent agent = agentFactory.build(identity, convId, !allowRetry);
AtomicInteger toolCalls = new AtomicInteger();
AtomicBoolean proposed = new AtomicBoolean(false);
TokenStream ts = agent.chat(convId, text);
ts.onPartialResponse(token -> send(emitter, "token", token))
.onToolExecuted(te -> {
toolCalls.incrementAndGet();
handleToolExecuted(emitter, convId, te, proposed);
})
.onCompleteResponse(resp -> {
String answer = resp == null || resp.aiMessage() == null || resp.aiMessage().text() == null
? "" : resp.aiMessage().text();
if (toolCalls.get() == 0 && hasDigits(answer)) {
if (allowRetry) {
log.warn("anti-fab retry (conv={}): zero tools + digits", convId);
send(emitter, "reset", "");
runAgentAttempt(emitter, convId, identity,
"你上一条回答没有调用任何工具、数字疑似编造。请先用工具查询真实数据,再重新回答这个问题:" + text,
false);
return;
}
send(emitter, "token", "\n\n⚠️ 注意:以上数字未能经系统数据核实,仅供参考。");
}
if (!proposed.get() && WRITE_CLAIM.matcher(answer).find()) {
send(emitter, "token", "\n\n⚠️ 系统提示:本条回复没有真正生成待确认操作(没有出现提议卡片即未生效),请重新描述一次您要做的操作。");
}
// 最终答复由 EventLogChatMemory 落账(ai 事件),这里不再重复写
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();
}
}
private static boolean hasDigits(String s) {
if (s == null) {
return false;
}
for (int i = 0; i < s.length(); i++) {
if (Character.isDigit(s.charAt(i))) {
return true;
}
}
return false;
}
private static String firstNonBlank(String a, String b) {
if (a != null && !a.isBlank()) return a;
return b;
}
/**
* 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件。
* 落账由 EventLogChatMemory 统一完成(tool_call/tool_result 事件),这里只做 SSE 与 op 绑定。
*/
private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te, AtomicBoolean proposed) {
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);
proposed.set(true);
}
} else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) {
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 已结束——忽略。
}
}
}