Commit 42330ffc940cf4651afb5dd07ca50885a2159e1b
1 parent
e32a1205
feat(B): multi-named conversations + Redis-persisted memory + sidebar
- RedisChatMemoryStore (ChatMemoryStore): persist per-conversation messages to Redis (chat:mem:{convId}, 30d TTL) -> survives restart; agent memory now Redis-backed via MessageWindowChatMemory.builder().chatMemoryStore(...)
- ConversationService: per-user conversation metadata (chat:convs:{userId} hash), title from first message, list/create/delete/history
- ConversationController: GET/POST/DELETE /api/agent/conversations + GET /{id}/messages
- AgentChatController: touch conversation on each turn
- chat.html: conversation sidebar (list/switch/new/delete), load history on switch, refresh on send
Verified: multi-turn memory recalls facts; after full restart, conversation list + history + memory all persist (agent still recalls prior-turn facts).
Showing
6 changed files
with
330 additions
and
4 deletions
src/main/java/com/xly/config/AgentConfig.java
| ... | ... | @@ -44,12 +44,17 @@ public class AgentConfig { |
| 44 | 44 | @Bean |
| 45 | 45 | public ReActAgent reActAgent(SystemPromptService systemPromptService, |
| 46 | 46 | KgQueryTool kgQueryTool, |
| 47 | - ErpReadTool erpReadTool) { | |
| 47 | + ErpReadTool erpReadTool, | |
| 48 | + RedisChatMemoryStore memoryStore) { | |
| 48 | 49 | String systemPrompt = systemPromptService.buildSystemPrompt(); |
| 49 | 50 | return AiServices.builder(ReActAgent.class) |
| 50 | 51 | .streamingChatModel(agentStreamingModel()) |
| 51 | 52 | .tools(kgQueryTool, erpReadTool) |
| 52 | - .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) | |
| 53 | + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() | |
| 54 | + .id(memoryId) | |
| 55 | + .maxMessages(30) | |
| 56 | + .chatMemoryStore(memoryStore) | |
| 57 | + .build()) | |
| 53 | 58 | .systemMessageProvider(memoryId -> systemPrompt) |
| 54 | 59 | .build(); |
| 55 | 60 | } | ... | ... |
src/main/java/com/xly/config/RedisChatMemoryStore.java
0 → 100644
| 1 | +package com.xly.config; | |
| 2 | + | |
| 3 | +import dev.langchain4j.data.message.ChatMessage; | |
| 4 | +import dev.langchain4j.data.message.ChatMessageDeserializer; | |
| 5 | +import dev.langchain4j.data.message.ChatMessageSerializer; | |
| 6 | +import dev.langchain4j.store.memory.chat.ChatMemoryStore; | |
| 7 | +import org.springframework.data.redis.core.StringRedisTemplate; | |
| 8 | +import org.springframework.stereotype.Component; | |
| 9 | + | |
| 10 | +import java.time.Duration; | |
| 11 | +import java.util.ArrayList; | |
| 12 | +import java.util.List; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * Redis 持久化的 ChatMemoryStore —— 会话记忆按 conversationId 存到 Redis,重启/换实例不丢, | |
| 16 | + * 也让「重登录后接回上一次会话」成立(会话状态按稳定身份+conversationId 存,而非按 token)。 | |
| 17 | + * | |
| 18 | + * <p>键:{@code chat:mem:{conversationId}};值:LangChain4j 序列化后的消息 JSON。30 天 TTL。 | |
| 19 | + */ | |
| 20 | +@Component | |
| 21 | +public class RedisChatMemoryStore implements ChatMemoryStore { | |
| 22 | + | |
| 23 | + private static final String PREFIX = "chat:mem:"; | |
| 24 | + private static final Duration TTL = Duration.ofDays(30); | |
| 25 | + | |
| 26 | + private final StringRedisTemplate redis; | |
| 27 | + | |
| 28 | + public RedisChatMemoryStore(StringRedisTemplate redis) { | |
| 29 | + this.redis = redis; | |
| 30 | + } | |
| 31 | + | |
| 32 | + @Override | |
| 33 | + public List<ChatMessage> getMessages(Object memoryId) { | |
| 34 | + String json = redis.opsForValue().get(PREFIX + memoryId); | |
| 35 | + if (json == null || json.isBlank()) { | |
| 36 | + return new ArrayList<>(); | |
| 37 | + } | |
| 38 | + return ChatMessageDeserializer.messagesFromJson(json); | |
| 39 | + } | |
| 40 | + | |
| 41 | + @Override | |
| 42 | + public void updateMessages(Object memoryId, List<ChatMessage> messages) { | |
| 43 | + redis.opsForValue().set(PREFIX + memoryId, ChatMessageSerializer.messagesToJson(messages), TTL); | |
| 44 | + } | |
| 45 | + | |
| 46 | + @Override | |
| 47 | + public void deleteMessages(Object memoryId) { | |
| 48 | + redis.delete(PREFIX + memoryId); | |
| 49 | + } | |
| 50 | +} | ... | ... |
src/main/java/com/xly/service/ConversationService.java
0 → 100644
| 1 | +package com.xly.service; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 4 | +import com.xly.config.RedisChatMemoryStore; | |
| 5 | +import dev.langchain4j.data.message.AiMessage; | |
| 6 | +import dev.langchain4j.data.message.ChatMessage; | |
| 7 | +import dev.langchain4j.data.message.UserMessage; | |
| 8 | +import org.springframework.data.redis.core.StringRedisTemplate; | |
| 9 | +import org.springframework.stereotype.Service; | |
| 10 | + | |
| 11 | +import java.util.ArrayList; | |
| 12 | +import java.util.LinkedHashMap; | |
| 13 | +import java.util.List; | |
| 14 | +import java.util.Map; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * 会话(conversation)元数据管理 —— 支持「多条命名会话/用户」。 | |
| 18 | + * | |
| 19 | + * <p>每用户一个会话列表存 Redis Hash {@code chat:convs:{userId}}(field=convId,value=JSON{id,title,updatedAt}); | |
| 20 | + * 消息本体存在 {@link RedisChatMemoryStore}({@code chat:mem:{convId}})。标题取自该会话的首条用户消息。 | |
| 21 | + */ | |
| 22 | +@Service | |
| 23 | +public class ConversationService { | |
| 24 | + | |
| 25 | + private static final String CONVS_KEY = "chat:convs:"; | |
| 26 | + | |
| 27 | + private final StringRedisTemplate redis; | |
| 28 | + private final RedisChatMemoryStore memoryStore; | |
| 29 | + private final ObjectMapper mapper; | |
| 30 | + | |
| 31 | + public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore, ObjectMapper mapper) { | |
| 32 | + this.redis = redis; | |
| 33 | + this.memoryStore = memoryStore; | |
| 34 | + this.mapper = mapper; | |
| 35 | + } | |
| 36 | + | |
| 37 | + /** 新建一个空会话,返回 convId。 */ | |
| 38 | + public String create(String userId) { | |
| 39 | + String convId = "c-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF)); | |
| 40 | + writeMeta(userId, convId, "新会话"); | |
| 41 | + return convId; | |
| 42 | + } | |
| 43 | + | |
| 44 | + /** 每次对话时调用:会话不存在则建、标题空则用首条消息命名,并刷新 updatedAt。 */ | |
| 45 | + public void touch(String userId, String convId, String firstMsg) { | |
| 46 | + Object existing = redis.opsForHash().get(CONVS_KEY + userId, convId); | |
| 47 | + String title = null; | |
| 48 | + if (existing != null) { | |
| 49 | + try { | |
| 50 | + title = mapper.readTree(existing.toString()).path("title").asText(null); | |
| 51 | + } catch (Exception ignore) { | |
| 52 | + } | |
| 53 | + } | |
| 54 | + if (title == null || title.isBlank() || "新会话".equals(title)) { | |
| 55 | + title = deriveTitle(firstMsg); | |
| 56 | + } | |
| 57 | + writeMeta(userId, convId, title); | |
| 58 | + } | |
| 59 | + | |
| 60 | + private void writeMeta(String userId, String convId, String title) { | |
| 61 | + try { | |
| 62 | + Map<String, Object> m = new LinkedHashMap<>(); | |
| 63 | + m.put("id", convId); | |
| 64 | + m.put("title", title); | |
| 65 | + m.put("updatedAt", System.currentTimeMillis()); | |
| 66 | + redis.opsForHash().put(CONVS_KEY + userId, convId, mapper.writeValueAsString(m)); | |
| 67 | + } catch (Exception ignore) { | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + /** 该用户的会话列表,按最近更新倒序。 */ | |
| 72 | + public List<Map<String, Object>> list(String userId) { | |
| 73 | + Map<Object, Object> all = redis.opsForHash().entries(CONVS_KEY + userId); | |
| 74 | + List<Map<String, Object>> out = new ArrayList<>(); | |
| 75 | + for (Object v : all.values()) { | |
| 76 | + try { | |
| 77 | + @SuppressWarnings("unchecked") | |
| 78 | + Map<String, Object> m = mapper.readValue(v.toString(), Map.class); | |
| 79 | + out.add(m); | |
| 80 | + } catch (Exception ignore) { | |
| 81 | + } | |
| 82 | + } | |
| 83 | + out.sort((a, b) -> Long.compare(num(b.get("updatedAt")), num(a.get("updatedAt")))); | |
| 84 | + return out; | |
| 85 | + } | |
| 86 | + | |
| 87 | + public void delete(String userId, String convId) { | |
| 88 | + redis.opsForHash().delete(CONVS_KEY + userId, convId); | |
| 89 | + memoryStore.deleteMessages(convId); | |
| 90 | + } | |
| 91 | + | |
| 92 | + /** 会话历史,映射为 {role:user|ai, content}。跳过系统消息、工具调用中间消息、工具结果。 */ | |
| 93 | + public List<Map<String, String>> history(String convId) { | |
| 94 | + List<Map<String, String>> out = new ArrayList<>(); | |
| 95 | + for (ChatMessage m : memoryStore.getMessages(convId)) { | |
| 96 | + if (m instanceof UserMessage um && um.hasSingleText()) { | |
| 97 | + out.add(Map.of("role", "user", "content", um.singleText())); | |
| 98 | + } else if (m instanceof AiMessage am && !am.hasToolExecutionRequests() | |
| 99 | + && am.text() != null && !am.text().isBlank()) { | |
| 100 | + out.add(Map.of("role", "ai", "content", am.text())); | |
| 101 | + } | |
| 102 | + } | |
| 103 | + return out; | |
| 104 | + } | |
| 105 | + | |
| 106 | + private String deriveTitle(String s) { | |
| 107 | + if (s == null) { | |
| 108 | + return "新会话"; | |
| 109 | + } | |
| 110 | + String t = s.strip().replace("\n", " "); | |
| 111 | + if (t.isEmpty()) { | |
| 112 | + return "新会话"; | |
| 113 | + } | |
| 114 | + return t.length() > 18 ? t.substring(0, 18) + "…" : t; | |
| 115 | + } | |
| 116 | + | |
| 117 | + private long num(Object o) { | |
| 118 | + try { | |
| 119 | + return Long.parseLong(String.valueOf(o)); | |
| 120 | + } catch (Exception e) { | |
| 121 | + return 0L; | |
| 122 | + } | |
| 123 | + } | |
| 124 | +} | ... | ... |
src/main/java/com/xly/web/AgentChatController.java
| ... | ... | @@ -2,6 +2,7 @@ package com.xly.web; |
| 2 | 2 | |
| 3 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 4 | 4 | import com.xly.agent.ReActAgent; |
| 5 | +import com.xly.service.ConversationService; | |
| 5 | 6 | import dev.langchain4j.service.TokenStream; |
| 6 | 7 | import org.slf4j.Logger; |
| 7 | 8 | import org.slf4j.LoggerFactory; |
| ... | ... | @@ -36,11 +37,13 @@ public class AgentChatController { |
| 36 | 37 | |
| 37 | 38 | private final ReActAgent agent; |
| 38 | 39 | private final ObjectMapper mapper; |
| 40 | + private final ConversationService conversations; | |
| 39 | 41 | private final ExecutorService exec = Executors.newCachedThreadPool(); |
| 40 | 42 | |
| 41 | - public AgentChatController(ReActAgent agent, ObjectMapper mapper) { | |
| 43 | + public AgentChatController(ReActAgent agent, ObjectMapper mapper, ConversationService conversations) { | |
| 42 | 44 | this.agent = agent; |
| 43 | 45 | this.mapper = mapper; |
| 46 | + this.conversations = conversations; | |
| 44 | 47 | } |
| 45 | 48 | |
| 46 | 49 | /** 前端请求体:身份字段透传(M1 只用 userid + conversationId + text)。 */ |
| ... | ... | @@ -58,6 +61,9 @@ public class AgentChatController { |
| 58 | 61 | ? req.conversationId |
| 59 | 62 | : ((req.userid == null ? "anon" : req.userid) + ":default"); |
| 60 | 63 | |
| 64 | + // 登记/更新会话(用于多会话侧栏;标题取首条消息) | |
| 65 | + conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); | |
| 66 | + | |
| 61 | 67 | exec.submit(() -> { |
| 62 | 68 | try { |
| 63 | 69 | TokenStream ts = agent.chat(convId, userInput); | ... | ... |
src/main/java/com/xly/web/ConversationController.java
0 → 100644
| 1 | +package com.xly.web; | |
| 2 | + | |
| 3 | +import com.xly.service.ConversationService; | |
| 4 | +import org.springframework.web.bind.annotation.DeleteMapping; | |
| 5 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 6 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 7 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 8 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 9 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 10 | +import org.springframework.web.bind.annotation.RequestParam; | |
| 11 | +import org.springframework.web.bind.annotation.RestController; | |
| 12 | + | |
| 13 | +import java.util.List; | |
| 14 | +import java.util.Map; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * 会话管理 REST —— 支撑前端的「多命名会话」侧栏(列表 / 新建 / 删除 / 历史)。 | |
| 18 | + */ | |
| 19 | +@RestController | |
| 20 | +@RequestMapping("/api/agent/conversations") | |
| 21 | +public class ConversationController { | |
| 22 | + | |
| 23 | + private final ConversationService conversations; | |
| 24 | + | |
| 25 | + public ConversationController(ConversationService conversations) { | |
| 26 | + this.conversations = conversations; | |
| 27 | + } | |
| 28 | + | |
| 29 | + /** 列出某用户的会话(最近更新在前)。 */ | |
| 30 | + @GetMapping | |
| 31 | + public List<Map<String, Object>> list(@RequestParam("userid") String userid) { | |
| 32 | + return conversations.list(userid); | |
| 33 | + } | |
| 34 | + | |
| 35 | + /** 新建空会话,返回 conversationId。 */ | |
| 36 | + @PostMapping | |
| 37 | + public Map<String, String> create(@RequestBody Map<String, String> body) { | |
| 38 | + String userid = body.getOrDefault("userid", "anon"); | |
| 39 | + return Map.of("conversationId", conversations.create(userid)); | |
| 40 | + } | |
| 41 | + | |
| 42 | + /** 删除会话(含其消息记忆)。 */ | |
| 43 | + @DeleteMapping("/{convId}") | |
| 44 | + public Map<String, Object> delete(@PathVariable("convId") String convId, | |
| 45 | + @RequestParam("userid") String userid) { | |
| 46 | + conversations.delete(userid, convId); | |
| 47 | + return Map.of("ok", true); | |
| 48 | + } | |
| 49 | + | |
| 50 | + /** 会话历史消息(用于切换会话时回填)。 */ | |
| 51 | + @GetMapping("/{convId}/messages") | |
| 52 | + public List<Map<String, String>> messages(@PathVariable("convId") String convId) { | |
| 53 | + return conversations.history(convId); | |
| 54 | + } | |
| 55 | +} | ... | ... |
src/main/resources/templates/chat.html
| ... | ... | @@ -423,11 +423,16 @@ |
| 423 | 423 | <option value="process">小羚羊印刷行业大模型</option> |
| 424 | 424 | <option value="general">qwen2.5:14b</option> |
| 425 | 425 | </select> |
| 426 | - <button class="model-selector" onclick="clearChat()">清空对话</button> | |
| 426 | + <button class="model-selector" onclick="newConversation()">新建会话</button> | |
| 427 | 427 | </div> |
| 428 | 428 | </div> |
| 429 | 429 | |
| 430 | 430 | <div class="chat-body"> |
| 431 | + <div class="sidebar"> | |
| 432 | + <div class="sidebar-title">会话</div> | |
| 433 | + <button class="preset-question" style="text-align:center;font-weight:600;background:#eef;" onclick="newConversation()">+ 新建会话</button> | |
| 434 | + <div id="convList"></div> | |
| 435 | + </div> | |
| 431 | 436 | <div class="chat-main"> |
| 432 | 437 | <div class="messages-container"> |
| 433 | 438 | <div class="chat-messages" id="chatMessages"> |
| ... | ... | @@ -495,8 +500,88 @@ |
| 495 | 500 | $('#messageInput').focus(); |
| 496 | 501 | bindKeyboardEvents(); |
| 497 | 502 | ensureInputAtBottom(); |
| 503 | + initConversations(); | |
| 498 | 504 | }); |
| 499 | 505 | |
| 506 | + // ====================== 多命名会话(侧栏) ====================== | |
| 507 | + function convApi(path, opts) { | |
| 508 | + return fetch(CONFIG.backendUrl + '/api/agent/conversations' + path, opts || {}); | |
| 509 | + } | |
| 510 | + | |
| 511 | + async function initConversations() { | |
| 512 | + try { | |
| 513 | + const res = await convApi('?userid=' + encodeURIComponent(userid)); | |
| 514 | + const list = await res.json(); | |
| 515 | + renderConvList(list); | |
| 516 | + if (list && list.length > 0) { | |
| 517 | + await switchConversation(list[0].id); | |
| 518 | + } | |
| 519 | + } catch (e) { console.log('会话初始化失败', e); } | |
| 520 | + } | |
| 521 | + | |
| 522 | + async function loadConversations() { | |
| 523 | + try { | |
| 524 | + const res = await convApi('?userid=' + encodeURIComponent(userid)); | |
| 525 | + renderConvList(await res.json()); | |
| 526 | + } catch (e) { /* ignore */ } | |
| 527 | + } | |
| 528 | + | |
| 529 | + function renderConvList(list) { | |
| 530 | + const box = $('#convList'); | |
| 531 | + box.empty(); | |
| 532 | + (list || []).forEach(c => { | |
| 533 | + const isActive = c.id === conversationId; | |
| 534 | + const item = $('<div class="preset-question conv-item"></div>') | |
| 535 | + .css({display:'flex', justifyContent:'space-between', alignItems:'center', gap:'6px'}); | |
| 536 | + if (isActive) item.css({background:'#667eea', color:'#fff', borderColor:'#667eea'}); | |
| 537 | + const title = $('<span></span>').text(c.title || '会话') | |
| 538 | + .css({flex:'1', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}); | |
| 539 | + const del = $('<span title="删除">×</span>').css({cursor:'pointer', fontWeight:'bold', opacity:'0.6'}); | |
| 540 | + title.on('click', () => switchConversation(c.id)); | |
| 541 | + del.on('click', (ev) => { ev.stopPropagation(); deleteConversation(c.id); }); | |
| 542 | + item.append(title).append(del); | |
| 543 | + box.append(item); | |
| 544 | + }); | |
| 545 | + } | |
| 546 | + | |
| 547 | + async function switchConversation(convId) { | |
| 548 | + if (convId === conversationId) return; | |
| 549 | + conversationId = convId; | |
| 550 | + $('#chatMessages').empty(); | |
| 551 | + try { | |
| 552 | + const res = await convApi('/' + convId + '/messages'); | |
| 553 | + const msgs = await res.json(); | |
| 554 | + (msgs || []).forEach(m => addMessage(m.content, m.role === 'user' ? 'user' : 'ai')); | |
| 555 | + if (!msgs || msgs.length === 0) addMessage('(空会话)请提问。', 'ai'); | |
| 556 | + } catch (e) { console.log('加载历史失败', e); } | |
| 557 | + renderConvList(await (await convApi('?userid=' + encodeURIComponent(userid))).json()); | |
| 558 | + ensureInputAtBottom(); | |
| 559 | + } | |
| 560 | + | |
| 561 | + async function newConversation() { | |
| 562 | + try { | |
| 563 | + const res = await convApi('', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({userid: userid}) }); | |
| 564 | + conversationId = (await res.json()).conversationId; | |
| 565 | + } catch (e) { | |
| 566 | + conversationId = 'c-' + Date.now() + '-' + Math.random().toString(36).slice(2,8); | |
| 567 | + } | |
| 568 | + $('#chatMessages').empty(); | |
| 569 | + addMessage('新会话已开始,请提问。', 'ai'); | |
| 570 | + loadConversations(); | |
| 571 | + $('#messageInput').focus(); | |
| 572 | + } | |
| 573 | + | |
| 574 | + async function deleteConversation(convId) { | |
| 575 | + if (!confirm('删除这个会话?')) return; | |
| 576 | + try { await convApi('/' + convId + '?userid=' + encodeURIComponent(userid), { method:'DELETE' }); } catch (e) {} | |
| 577 | + if (convId === conversationId) { | |
| 578 | + conversationId = 'c-' + Date.now() + '-' + Math.random().toString(36).slice(2,8); | |
| 579 | + $('#chatMessages').empty(); | |
| 580 | + addMessage('会话已删除,可新建或选择其它会话。', 'ai'); | |
| 581 | + } | |
| 582 | + loadConversations(); | |
| 583 | + } | |
| 584 | + | |
| 500 | 585 | function generateRandomString(length) { |
| 501 | 586 | const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; |
| 502 | 587 | let result = ''; |
| ... | ... | @@ -645,6 +730,7 @@ |
| 645 | 730 | button.prop('disabled', false); |
| 646 | 731 | input.focus(); |
| 647 | 732 | scrollToBottom(); |
| 733 | + loadConversations(); | |
| 648 | 734 | } |
| 649 | 735 | } |
| 650 | 736 | ... | ... |