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.PreviewService;
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/previewChange/askUser,
* **全部只读/只渲染**)。流程知识在技能文本里(useSkill 载入,投影层把激活技能钉在「进行中的流程」
* 卡上跨轮续办)。写路径:previewChange/collectForm 只出卡片,用户点卡上按钮 → 确定性端点
* (/api/agent/preview/{id}/save、/api/agent/form/submit)校验后写 ai_op_queue(AI 侧唯一写入口),
* 执行由 ERP 侧负责。安全不变量在工具与端点内:身份内省 fail-closed、工具内鉴权、token 不进 prompt。
*
*
反编造护栏(无条件常开):零工具调用却答出数字 → 注入纠正话术重试一次(internal 事件,
* 前端不显示),复发则标注未核实;声称「已保存/已提交/已完成」却没出过预览卡/表单 → 附纠正提示。
*
*
帧格式:{@code {"type":"token|reset|done|error"}}、预览卡 {@code change_preview}、澄清
* {@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 CARD_TOOLS = Set.of("previewChange", "askUser", "collectForm");
/** 反编造护栏:agent 声称写操作已发生的说法(本轮没出过预览卡/表单时要纠正)。 */
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 PreviewService previews;
private final LedgerService ledger;
private final ExecutorService exec = Executors.newCachedThreadPool();
public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper,
ConversationService conversations, PreviewService previews, LedgerService ledger) {
this.agentFactory = agentFactory;
this.authz = authz;
this.mapper = mapper;
this.conversations = conversations;
this.previews = previews;
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), identity);
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 表单的结构化字段直达 create 校验(FK/类型/系统列)并写 ai_op_queue
* (用户点【保存】= 已当面授权 → sStatus=confirmed)。**xlyAi 到此为止**,执行由 ERP 侧负责。
* 同步返回 {queued,opId,summary,message} 或 {error}。
*/
@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()), identity);
try {
Map r = previews.saveCreate(identity, convId, entity, fields);
if (r.containsKey("error")) {
ledger.append(convId, "assistant", Map.of("text", String.valueOf(r.get("error"))), identity);
}
return r;
} catch (Exception e) {
log.warn("form submit failed (conv={})", convId, e);
out.put("error", "服务异常:" + e.getMessage());
return out;
}
}
/**
* 运行 ReAct agent,把流式回调转成 SSE。反编造护栏**无条件常开**(实测 Ollama 对
* tool_choice=required 不硬执行,只能在代码层兜底):零工具调用却答出数字 → 注入纠正话术
* 重试一次(internal 用户事件,前端不显示),复发则标注「未经核实」;声称已保存/已提交但本轮
* 没出过预览卡/表单 → 附纠正提示。
*/
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 carded = new AtomicBoolean(false);
TokenStream ts = agent.chat(convId, text);
ts.onPartialResponse(token -> send(emitter, "token", token))
.onToolExecuted(te -> {
toolCalls.incrementAndGet();
handleToolExecuted(emitter, te, carded);
})
.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 (!carded.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);按工具类型推对应卡片/控件事件
* (change_preview / question / form_collect)。落账由 EventLogChatMemory 统一完成
* (tool_call/tool_result 事件),这里只做 SSE 推送。
*/
private void handleToolExecuted(SseEmitter emitter, ToolExecution te, AtomicBoolean carded) {
send(emitter, "reset", "");
try {
String toolName = te.request() == null ? "" : te.request().name();
if (te.result() == null || !CARD_TOOLS.contains(toolName)) {
return;
}
JsonNode r = mapper.readTree(te.result());
String type = r.path("type").asText("");
if ("change_preview".equals(type) || "question".equals(type) || "form_collect".equals(type)) {
sendEvent(emitter, mapper.convertValue(r, Map.class));
if ("change_preview".equals(type) || "form_collect".equals(type)) {
carded.set(true);
}
}
} 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 已结束——忽略。
}
}
}