From 42330ffc940cf4651afb5dd07ca50885a2159e1b Mon Sep 17 00:00:00 2001
From: zichun <26684461+reporkey@users.noreply.github.com>
Date: Tue, 21 Jul 2026 19:49:37 +0800
Subject: [PATCH] feat(B): multi-named conversations + Redis-persisted memory + sidebar
---
src/main/java/com/xly/config/AgentConfig.java | 9 +++++++--
src/main/java/com/xly/config/RedisChatMemoryStore.java | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++
src/main/java/com/xly/service/ConversationService.java | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main/java/com/xly/web/AgentChatController.java | 8 +++++++-
src/main/java/com/xly/web/ConversationController.java | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main/resources/templates/chat.html | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 330 insertions(+), 4 deletions(-)
create mode 100644 src/main/java/com/xly/config/RedisChatMemoryStore.java
create mode 100644 src/main/java/com/xly/service/ConversationService.java
create mode 100644 src/main/java/com/xly/web/ConversationController.java
diff --git a/src/main/java/com/xly/config/AgentConfig.java b/src/main/java/com/xly/config/AgentConfig.java
index ca6449b..64369da 100644
--- a/src/main/java/com/xly/config/AgentConfig.java
+++ b/src/main/java/com/xly/config/AgentConfig.java
@@ -44,12 +44,17 @@ public class AgentConfig {
@Bean
public ReActAgent reActAgent(SystemPromptService systemPromptService,
KgQueryTool kgQueryTool,
- ErpReadTool erpReadTool) {
+ ErpReadTool erpReadTool,
+ RedisChatMemoryStore memoryStore) {
String systemPrompt = systemPromptService.buildSystemPrompt();
return AiServices.builder(ReActAgent.class)
.streamingChatModel(agentStreamingModel())
.tools(kgQueryTool, erpReadTool)
- .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20))
+ .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder()
+ .id(memoryId)
+ .maxMessages(30)
+ .chatMemoryStore(memoryStore)
+ .build())
.systemMessageProvider(memoryId -> systemPrompt)
.build();
}
diff --git a/src/main/java/com/xly/config/RedisChatMemoryStore.java b/src/main/java/com/xly/config/RedisChatMemoryStore.java
new file mode 100644
index 0000000..5cbea5e
--- /dev/null
+++ b/src/main/java/com/xly/config/RedisChatMemoryStore.java
@@ -0,0 +1,50 @@
+package com.xly.config;
+
+import dev.langchain4j.data.message.ChatMessage;
+import dev.langchain4j.data.message.ChatMessageDeserializer;
+import dev.langchain4j.data.message.ChatMessageSerializer;
+import dev.langchain4j.store.memory.chat.ChatMemoryStore;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Redis 持久化的 ChatMemoryStore —— 会话记忆按 conversationId 存到 Redis,重启/换实例不丢,
+ * 也让「重登录后接回上一次会话」成立(会话状态按稳定身份+conversationId 存,而非按 token)。
+ *
+ *
键:{@code chat:mem:{conversationId}};值:LangChain4j 序列化后的消息 JSON。30 天 TTL。
+ */
+@Component
+public class RedisChatMemoryStore implements ChatMemoryStore {
+
+ private static final String PREFIX = "chat:mem:";
+ private static final Duration TTL = Duration.ofDays(30);
+
+ private final StringRedisTemplate redis;
+
+ public RedisChatMemoryStore(StringRedisTemplate redis) {
+ this.redis = redis;
+ }
+
+ @Override
+ public List getMessages(Object memoryId) {
+ String json = redis.opsForValue().get(PREFIX + memoryId);
+ if (json == null || json.isBlank()) {
+ return new ArrayList<>();
+ }
+ return ChatMessageDeserializer.messagesFromJson(json);
+ }
+
+ @Override
+ public void updateMessages(Object memoryId, List messages) {
+ redis.opsForValue().set(PREFIX + memoryId, ChatMessageSerializer.messagesToJson(messages), TTL);
+ }
+
+ @Override
+ public void deleteMessages(Object memoryId) {
+ redis.delete(PREFIX + memoryId);
+ }
+}
diff --git a/src/main/java/com/xly/service/ConversationService.java b/src/main/java/com/xly/service/ConversationService.java
new file mode 100644
index 0000000..0e5bca5
--- /dev/null
+++ b/src/main/java/com/xly/service/ConversationService.java
@@ -0,0 +1,124 @@
+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