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> 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; + } + } +} diff --git a/src/main/java/com/xly/web/AgentChatController.java b/src/main/java/com/xly/web/AgentChatController.java index f60e9f8..c98e9a2 100644 --- a/src/main/java/com/xly/web/AgentChatController.java +++ b/src/main/java/com/xly/web/AgentChatController.java @@ -2,6 +2,7 @@ package com.xly.web; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.ReActAgent; +import com.xly.service.ConversationService; import dev.langchain4j.service.TokenStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,11 +37,13 @@ public class AgentChatController { private final ReActAgent agent; private final ObjectMapper mapper; + private final ConversationService conversations; private final ExecutorService exec = Executors.newCachedThreadPool(); - public AgentChatController(ReActAgent agent, ObjectMapper mapper) { + public AgentChatController(ReActAgent agent, ObjectMapper mapper, ConversationService conversations) { this.agent = agent; this.mapper = mapper; + this.conversations = conversations; } /** 前端请求体:身份字段透传(M1 只用 userid + conversationId + text)。 */ @@ -58,6 +61,9 @@ public class AgentChatController { ? req.conversationId : ((req.userid == null ? "anon" : req.userid) + ":default"); + // 登记/更新会话(用于多会话侧栏;标题取首条消息) + conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); + exec.submit(() -> { try { TokenStream ts = agent.chat(convId, userInput); diff --git a/src/main/java/com/xly/web/ConversationController.java b/src/main/java/com/xly/web/ConversationController.java new file mode 100644 index 0000000..b704163 --- /dev/null +++ b/src/main/java/com/xly/web/ConversationController.java @@ -0,0 +1,55 @@ +package com.xly.web; + +import com.xly.service.ConversationService; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 会话管理 REST —— 支撑前端的「多命名会话」侧栏(列表 / 新建 / 删除 / 历史)。 + */ +@RestController +@RequestMapping("/api/agent/conversations") +public class ConversationController { + + private final ConversationService conversations; + + public ConversationController(ConversationService conversations) { + this.conversations = conversations; + } + + /** 列出某用户的会话(最近更新在前)。 */ + @GetMapping + public List> list(@RequestParam("userid") String userid) { + return conversations.list(userid); + } + + /** 新建空会话,返回 conversationId。 */ + @PostMapping + public Map create(@RequestBody Map body) { + String userid = body.getOrDefault("userid", "anon"); + return Map.of("conversationId", conversations.create(userid)); + } + + /** 删除会话(含其消息记忆)。 */ + @DeleteMapping("/{convId}") + public Map delete(@PathVariable("convId") String convId, + @RequestParam("userid") String userid) { + conversations.delete(userid, convId); + return Map.of("ok", true); + } + + /** 会话历史消息(用于切换会话时回填)。 */ + @GetMapping("/{convId}/messages") + public List> messages(@PathVariable("convId") String convId) { + return conversations.history(convId); + } +} diff --git a/src/main/resources/templates/chat.html b/src/main/resources/templates/chat.html index 7dedb0a..214daf4 100644 --- a/src/main/resources/templates/chat.html +++ b/src/main/resources/templates/chat.html @@ -423,11 +423,16 @@ - +

+
@@ -495,8 +500,88 @@ $('#messageInput').focus(); bindKeyboardEvents(); ensureInputAtBottom(); + initConversations(); }); + // ====================== 多命名会话(侧栏) ====================== + function convApi(path, opts) { + return fetch(CONFIG.backendUrl + '/api/agent/conversations' + path, opts || {}); + } + + async function initConversations() { + try { + const res = await convApi('?userid=' + encodeURIComponent(userid)); + const list = await res.json(); + renderConvList(list); + if (list && list.length > 0) { + await switchConversation(list[0].id); + } + } catch (e) { console.log('会话初始化失败', e); } + } + + async function loadConversations() { + try { + const res = await convApi('?userid=' + encodeURIComponent(userid)); + renderConvList(await res.json()); + } catch (e) { /* ignore */ } + } + + function renderConvList(list) { + const box = $('#convList'); + box.empty(); + (list || []).forEach(c => { + const isActive = c.id === conversationId; + const item = $('
') + .css({display:'flex', justifyContent:'space-between', alignItems:'center', gap:'6px'}); + if (isActive) item.css({background:'#667eea', color:'#fff', borderColor:'#667eea'}); + const title = $('').text(c.title || '会话') + .css({flex:'1', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}); + const del = $('×').css({cursor:'pointer', fontWeight:'bold', opacity:'0.6'}); + title.on('click', () => switchConversation(c.id)); + del.on('click', (ev) => { ev.stopPropagation(); deleteConversation(c.id); }); + item.append(title).append(del); + box.append(item); + }); + } + + async function switchConversation(convId) { + if (convId === conversationId) return; + conversationId = convId; + $('#chatMessages').empty(); + try { + const res = await convApi('/' + convId + '/messages'); + const msgs = await res.json(); + (msgs || []).forEach(m => addMessage(m.content, m.role === 'user' ? 'user' : 'ai')); + if (!msgs || msgs.length === 0) addMessage('(空会话)请提问。', 'ai'); + } catch (e) { console.log('加载历史失败', e); } + renderConvList(await (await convApi('?userid=' + encodeURIComponent(userid))).json()); + ensureInputAtBottom(); + } + + async function newConversation() { + try { + const res = await convApi('', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({userid: userid}) }); + conversationId = (await res.json()).conversationId; + } catch (e) { + conversationId = 'c-' + Date.now() + '-' + Math.random().toString(36).slice(2,8); + } + $('#chatMessages').empty(); + addMessage('新会话已开始,请提问。', 'ai'); + loadConversations(); + $('#messageInput').focus(); + } + + async function deleteConversation(convId) { + if (!confirm('删除这个会话?')) return; + try { await convApi('/' + convId + '?userid=' + encodeURIComponent(userid), { method:'DELETE' }); } catch (e) {} + if (convId === conversationId) { + conversationId = 'c-' + Date.now() + '-' + Math.random().toString(36).slice(2,8); + $('#chatMessages').empty(); + addMessage('会话已删除,可新建或选择其它会话。', 'ai'); + } + loadConversations(); + } + function generateRandomString(length) { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; @@ -645,6 +730,7 @@ button.prop('disabled', false); input.focus(); scrollToBottom(); + loadConversations(); } } -- libgit2 0.22.2