package com.xly.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.config.RedisChatMemoryStore; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.UserMessage; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 会话(conversation)元数据管理 —— 支持「多条命名会话/用户」。 * *

每用户一个会话列表存 Redis Hash {@code chat:convs:{userId}}(field=convId,value=JSON{id,title,updatedAt}); * 消息本体存在 {@link RedisChatMemoryStore}({@code chat:mem:{convId}})。标题取自该会话的首条用户消息。 */ @Service public class ConversationService { private static final String CONVS_KEY = "chat:convs:"; private final StringRedisTemplate redis; private final RedisChatMemoryStore memoryStore; private final ObjectMapper mapper; private final LedgerService ledger; private final StateService state; public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore, ObjectMapper mapper, LedgerService ledger, StateService state) { this.redis = redis; this.memoryStore = memoryStore; this.mapper = mapper; this.ledger = ledger; this.state = state; } /** 新建一个空会话,返回 convId。 */ public String create(String userId) { String convId = "c-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF)); writeMeta(userId, convId, "新会话"); return convId; } /** 每次对话时调用:会话不存在则建、标题空则用首条消息命名,并刷新 updatedAt。 */ public void touch(String userId, String convId, String firstMsg) { Object existing = redis.opsForHash().get(CONVS_KEY + userId, convId); String title = null; if (existing != null) { try { title = mapper.readTree(existing.toString()).path("title").asText(null); } catch (Exception ignore) { } } if (title == null || title.isBlank() || "新会话".equals(title)) { title = deriveTitle(firstMsg); } writeMeta(userId, convId, title); } private void writeMeta(String userId, String convId, String title) { try { Map m = new LinkedHashMap<>(); m.put("id", convId); m.put("title", title); m.put("updatedAt", System.currentTimeMillis()); redis.opsForHash().put(CONVS_KEY + userId, convId, mapper.writeValueAsString(m)); } catch (Exception ignore) { } } /** 该用户的会话列表,按最近更新倒序。 */ public List> list(String userId) { Map all = redis.opsForHash().entries(CONVS_KEY + userId); List> out = new ArrayList<>(); for (Object v : all.values()) { try { @SuppressWarnings("unchecked") Map m = mapper.readValue(v.toString(), Map.class); out.add(m); } catch (Exception ignore) { } } out.sort((a, b) -> Long.compare(num(b.get("updatedAt")), num(a.get("updatedAt")))); return out; } public void delete(String userId, String convId) { redis.opsForHash().delete(CONVS_KEY + userId, convId); memoryStore.deleteMessages(convId); ledger.delete(convId); state.delete(convId); } /** * 会话历史:优先从**会话账本**重放(含确定性路径的表单/澄清/提议/确认结果——消息记忆里没有这些); * 无账本的旧会话退回消息记忆。映射为 {role:user|ai, content}。 */ public List> history(String convId) { List> events = ledger.events(convId); if (!events.isEmpty()) { List> out = new ArrayList<>(); for (Map e : events) { String type = String.valueOf(e.get("type")); switch (type) { case "user" -> out.add(Map.of("role", "user", "content", s(e.get("text")))); case "assistant", "clarify" -> addAi(out, s(e.get("text"))); case "question" -> addAi(out, s(e.get("question"))); case "form" -> addAi(out, "【表单】新建" + s(e.get("entity")) + ":" + s(e.get("message"))); case "proposal" -> addAi(out, "【待确认】" + s(e.get("summary"))); case "confirm" -> addAi(out, ("executed".equals(s(e.get("status"))) ? "【已执行】" : "【执行失败】") + s(e.get("description")) + blankOr(s(e.get("msg")))); case "cancel" -> addAi(out, "【已取消】" + s(e.get("description"))); default -> { } // tool 等内部事件不进历史 } } return out; } List> out = new ArrayList<>(); for (ChatMessage m : memoryStore.getMessages(convId)) { if (m instanceof UserMessage um && um.hasSingleText()) { out.add(Map.of("role", "user", "content", um.singleText())); } else if (m instanceof AiMessage am && !am.hasToolExecutionRequests() && am.text() != null && !am.text().isBlank()) { out.add(Map.of("role", "ai", "content", am.text())); } } return out; } private static void addAi(List> out, String text) { if (text != null && !text.isBlank()) { out.add(Map.of("role", "ai", "content", text)); } } private static String s(Object o) { return o == null ? "" : o.toString(); } private static String blankOr(String msg) { return msg == null || msg.isBlank() ? "" : ("(" + msg + ")"); } private String deriveTitle(String s) { if (s == null) { return "新会话"; } String t = s.strip().replace("\n", " "); if (t.isEmpty()) { return "新会话"; } return t.length() > 18 ? t.substring(0, 18) + "…" : t; } private long num(Object o) { try { return Long.parseLong(String.valueOf(o)); } catch (Exception e) { return 0L; } } }