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; public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore, ObjectMapper mapper) { this.redis = redis; this.memoryStore = memoryStore; this.mapper = mapper; } /** 新建一个空会话,返回 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); } /** 会话历史,映射为 {role:user|ai, content}。跳过系统消息、工具调用中间消息、工具结果。 */ public List> history(String convId) { 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 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; } } }