package com.xly.agent; import com.xly.service.EventProjectionService; import com.xly.service.LedgerService; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.agent.tool.ToolExecutionRequest; import dev.langchain4j.data.message.ChatMessageSerializer; import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.ToolExecutionResultMessage; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.memory.ChatMemory; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 事件日志上的对话记忆:{@link #add} 把模型环消息逐条 append 成事件(rightPush 原子—— * 对话流与确认端点并发写互不覆盖,替代旧 chat:mem 整包读改写),{@link #messages()} 读取 * {@link EventProjectionService} 的四段式投影。日志即唯一事实源,本类不持有任何会话状态。 * *

写者分工:用户事件由控制器在收到请求时先落账(前端立即可见),本类对 UserMessage 做 * 去重跳过;{@code internalUserTurn=true} 时(反编造护栏重试的注入话术)例外——落一条 * {@code internal} 标记的用户事件,LLM 可见、前端历史不显示。模型消息(tool_call/tool_result/ai) * 只由本类落账。 */ public class EventLogChatMemory implements ChatMemory { private static final int LLM_EVENT_WINDOW = 400; private final String convId; private final LedgerService log; private final EventProjectionService projection; private final int charBudget; private final boolean internalUserTurn; /** system prompt 每次由 provider 提供,不落日志(保持日志纯业务事件)。 */ private volatile String systemText; /** * 本轮实际喂给模型的用户文本(原话 + 编排层附加的 grounding/状态后缀)。日志只存原话; * 投影时把当前轮用户消息替换为它。P2 编排层不再加后缀后,此字段恒空。 */ private volatile String currentTurnUserText; public EventLogChatMemory(String convId, LedgerService log, EventProjectionService projection, int charBudget, boolean internalUserTurn) { this.convId = convId; this.log = log; this.projection = projection; this.charBudget = charBudget; this.internalUserTurn = internalUserTurn; } @Override public Object id() { return convId; } @Override public void add(ChatMessage m) { if (m instanceof SystemMessage sm) { systemText = sm.text(); return; } if (m instanceof UserMessage um) { String text = um.hasSingleText() ? um.singleText() : String.valueOf(um.contents()); if (!internalUserTurn && alreadyLoggedPrefixOf(text)) { currentTurnUserText = text; return; } Map data = new LinkedHashMap<>(); data.put("text", text); if (internalUserTurn) { data.put("internal", true); } log.append(convId, "user", data); return; } if (m instanceof AiMessage am) { if (am.hasToolExecutionRequests()) { Map data = new LinkedHashMap<>(); data.put("payload", ChatMessageSerializer.messagesToJson(List.of(am))); data.put("text", am.text() == null ? "" : am.text()); List tools = new ArrayList<>(); for (ToolExecutionRequest r : am.toolExecutionRequests()) { tools.add(r.name()); } data.put("tools", tools); log.append(convId, "tool_call", data); } else if (am.text() != null && !am.text().isBlank()) { log.append(convId, "ai", Map.of("text", am.text())); } return; } if (m instanceof ToolExecutionResultMessage tr) { String text = tr.text() == null ? "" : tr.text(); Map data = new LinkedHashMap<>(); data.put("tcId", tr.id() == null ? "" : tr.id()); data.put("name", tr.toolName() == null ? "" : tr.toolName()); data.put("text", text); data.put("digest", digest(text)); log.append(convId, "tool_result", data); } } /** * 控制器已把本轮用户**原话**落账(最近的用户事件是喂给模型文本的前缀、且其后无模型事件) * → 跳过,避免重复。编排层可能在原话后附加 grounding/状态后缀,故用前缀而非全等判定。 */ private boolean alreadyLoggedPrefixOf(String text) { List> tail = log.events(convId, 6); for (int i = tail.size() - 1; i >= 0; i--) { String type = String.valueOf(tail.get(i).get("type")); switch (type) { case "user": String logged = String.valueOf(tail.get(i).get("text")); return !logged.isEmpty() && text.startsWith(logged); case "form_submit", "ai", "tool_call", "tool_result", "assistant": return false; default: // confirm/proposal 等按钮事件可能插在中间,继续往前找 } } return false; } @Override public List messages() { List out = projection.project( systemText, log.events(convId, LLM_EVENT_WINDOW), charBudget); String full = currentTurnUserText; if (full != null) { // 当前轮用户消息换成实际喂给模型的完整文本(原话+编排后缀) for (int i = out.size() - 1; i >= 0; i--) { if (out.get(i) instanceof UserMessage u && u.hasSingleText() && full.startsWith(u.singleText())) { out.set(i, UserMessage.from(full)); break; } } } return out; } /** 日志是唯一事实源,不因模型环异常清史;删除会话走 ConversationService 级联。 */ @Override public void clear() { } private static String digest(String text) { String t = text.replace('\n', ' ').trim(); return t.length() > EventProjectionService.TOOL_DIGEST_LEN ? t.substring(0, EventProjectionService.TOOL_DIGEST_LEN) + "…" : t; } }